spatialdds 0.3.1__tar.gz → 0.4.0__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 (148) hide show
  1. {spatialdds-0.3.1 → spatialdds-0.4.0}/PKG-INFO +28 -4
  2. {spatialdds-0.3.1 → spatialdds-0.4.0}/README.md +27 -3
  3. {spatialdds-0.3.1 → spatialdds-0.4.0}/pyproject.toml +1 -1
  4. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/__init__.py +1 -1
  5. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/serve.py +45 -4
  6. spatialdds-0.4.0/spatialdds/server/commands.py +131 -0
  7. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/gateway.py +26 -6
  8. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/gw_http.py +108 -3
  9. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds.egg-info/PKG-INFO +28 -4
  10. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds.egg-info/SOURCES.txt +1 -0
  11. {spatialdds-0.3.1 → spatialdds-0.4.0}/setup.cfg +0 -0
  12. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/idl/core.idl +0 -0
  13. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/idl/discovery.idl +0 -0
  14. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/idl/types.idl +0 -0
  15. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/builtin_Time.json +0 -0
  16. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorDelta.json +0 -0
  17. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorEntry.json +0 -0
  18. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorSet.json +0 -0
  19. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorSetRequest.json +0 -0
  20. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_anchors_AnchorSetResponse.json +0 -0
  21. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_NodeGeo.json +0 -0
  22. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_QualityRequirements.json +0 -0
  23. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_VpsRequest.json +0 -0
  24. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_argeo_VpsResponse.json +0 -0
  25. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_AssetRef.json +0 -0
  26. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_FrameRef.json +0 -0
  27. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_KV.json +0 -0
  28. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_MetaKV.json +0 -0
  29. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_common_Vec2.json +0 -0
  30. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_Aabb3.json +0 -0
  31. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_BlobChunk.json +0 -0
  32. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_BlobRef.json +0 -0
  33. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_ComponentRef.json +0 -0
  34. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_Edge.json +0 -0
  35. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_EntityBinding.json +0 -0
  36. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_FrameTransform.json +0 -0
  37. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_FramedPose.json +0 -0
  38. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_GeoAnchor.json +0 -0
  39. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_GeoPose.json +0 -0
  40. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_NavSatStatus.json +0 -0
  41. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_Node.json +0 -0
  42. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_PlannedTrajectory.json +0 -0
  43. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_PlannedWaypoint.json +0 -0
  44. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_PoseSE3.json +0 -0
  45. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_SnapshotRequest.json +0 -0
  46. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_SnapshotResponse.json +0 -0
  47. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_TileKey.json +0 -0
  48. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_TileMeta.json +0 -0
  49. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_core_TilePatch.json +0 -0
  50. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Announce.json +0 -0
  51. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Capabilities.json +0 -0
  52. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ContentAnnounce.json +0 -0
  53. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageElement.json +0 -0
  54. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageFilter.json +0 -0
  55. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageHint.json +0 -0
  56. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageQuery.json +0 -0
  57. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_CoverageResponse.json +0 -0
  58. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Depart.json +0 -0
  59. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_KV.json +0 -0
  60. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ProfileSupport.json +0 -0
  61. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ServiceSummary.json +0 -0
  62. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_TopicMeta.json +0 -0
  63. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_Transform.json +0 -0
  64. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_disco_ValidityWindow.json +0 -0
  65. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_CrossingLine.json +0 -0
  66. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_SpatialEvent.json +0 -0
  67. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_SpatialZone.json +0 -0
  68. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_events_ZoneState.json +0 -0
  69. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_Edge.json +0 -0
  70. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapAlignment.json +0 -0
  71. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapEvent.json +0 -0
  72. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapMeta.json +0 -0
  73. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_mapping_MapQuality.json +0 -0
  74. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_owm_Entity.json +0 -0
  75. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_owm_ModelCommand.json +0 -0
  76. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_owm_ModelPose.json +0 -0
  77. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection2D.json +0 -0
  78. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection2DSet.json +0 -0
  79. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection3D.json +0 -0
  80. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Detection3DSet.json +0 -0
  81. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_FusedTrack.json +0 -0
  82. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_FusedTrackSet.json +0 -0
  83. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_Keypoint3D.json +0 -0
  84. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_semantics_KeypointLink.json +0 -0
  85. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_Axis.json +0 -0
  86. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_FrameHeader.json +0 -0
  87. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_FrameQuality.json +0 -0
  88. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_Linspace.json +0 -0
  89. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_ROI.json +0 -0
  90. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_ROIReply.json +0 -0
  91. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_ROIRequest.json +0 -0
  92. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_common_StreamMeta.json +0 -0
  93. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarDetection.json +0 -0
  94. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarDetectionSet.json +0 -0
  95. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarFrame.json +0 -0
  96. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarMeta.json +0 -0
  97. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadDetection.json +0 -0
  98. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadDetectionSet.json +0 -0
  99. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadSensorMeta.json +0 -0
  100. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadTensorFrame.json +0 -0
  101. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rad_RadTensorMeta.json +0 -0
  102. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioObservation.json +0 -0
  103. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioScan.json +0 -0
  104. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioSensorMeta.json +0 -0
  105. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamArraySet.json +0 -0
  106. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamFrame.json +0 -0
  107. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamMeta.json +0 -0
  108. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_CamIntrinsics.json +0 -0
  109. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_Keypoint2D.json +0 -0
  110. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_Track2D.json +0 -0
  111. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionDetections.json +0 -0
  112. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionFrame.json +0 -0
  113. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionMeta.json +0 -0
  114. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_CameraInfo.json +0 -0
  115. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_Feature2D.json +0 -0
  116. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_FeatureMatch.json +0 -0
  117. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_KeyframeFeatures.json +0 -0
  118. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_Landmark.json +0 -0
  119. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_MatchSet.json +0 -0
  120. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_TrackObs.json +0 -0
  121. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_slam_frontend_Tracklet.json +0 -0
  122. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_FusedState.json +0 -0
  123. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_ImuInfo.json +0 -0
  124. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_ImuSample.json +0 -0
  125. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_MagnetometerSample.json +0 -0
  126. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/schemas/spatial_vio_SensorFusionSample.json +0 -0
  127. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/artifacts/serializer_tables.json +0 -0
  128. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/canonical.py +0 -0
  129. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/canonjson.py +0 -0
  130. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/client.py +0 -0
  131. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/demo/assets/duck.gltf +0 -0
  132. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/demo/assets/fountain-label.txt +0 -0
  133. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/demo/assets/fountain-marker.gltf +0 -0
  134. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/demo/fountain-anchors.mcap +0 -0
  135. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/demo/manifests/content-fountain-label.json +0 -0
  136. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/demo/manifests/content-fountain-marker.json +0 -0
  137. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/errors.py +0 -0
  138. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/records.py +0 -0
  139. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/__init__.py +0 -0
  140. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/idlmodel.py +0 -0
  141. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/pin.py +0 -0
  142. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/publish.py +0 -0
  143. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/webjson.py +0 -0
  144. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds/server/xcdr2.py +0 -0
  145. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds.egg-info/dependency_links.txt +0 -0
  146. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds.egg-info/entry_points.txt +0 -0
  147. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds.egg-info/requires.txt +0 -0
  148. {spatialdds-0.3.1 → spatialdds-0.4.0}/spatialdds.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdds
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N), and the reference registry behind one command.
5
5
  Author: Open AR Cloud
6
6
  License: Apache-2.0
@@ -356,13 +356,37 @@ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
356
356
  the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
357
357
  site, nowhere else.
358
358
 
359
- ## Not in 0.3.1
359
+ ## New in 0.4.0: a command surface on the server half
360
+
361
+ `spatialdds[server]` now implements Appendix N.6. A command is a POST to the
362
+ lane that accepts one, and an embedder registers what a verb means:
363
+
364
+ | Part | What it is |
365
+ | --- | --- |
366
+ | the seam | `spatialdds.server.commands.Registry().register(fqn, handler)` |
367
+ | a handler gets | the validated, lifted record — the same JSON a GET would serve |
368
+ | a handler returns | `Outcome.accept()` or `Outcome.decline("why")` |
369
+ | the wire | `202` accepted, `409` declined with the reason, `405` where no handler is registered |
370
+
371
+ `python -m spatialdds.serve --demo` registers one so the surface is reachable
372
+ without writing a backend. An endpoint with no handlers is northbound-only and
373
+ every command POST is a clean `405`, which is what N.6 requires and what every
374
+ earlier version did.
375
+
376
+ **The client half has no command method.** This release adds the surface, not
377
+ a helper for calling it — `requests.post(topic_url, json=record)` with the
378
+ operator token is the whole of it, and `docs/WIRE.md` documents the response
379
+ shapes. Said plainly because "commands are supported" invites a reader to look
380
+ for `hit.command(...)`, and there isn't one.
381
+
382
+ ## Not in 0.4.0
360
383
 
361
384
  Stated so nobody waits for them: no async API, no caching beyond ETag
362
385
  revalidation, no retry policy, no MCAP tooling beyond the journal the server
363
- writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
386
+ writes, no client-side command helper, and no DDS dependency of any kind. The
387
+ wire is HTTP and a WebSocket.
364
388
 
365
- 0.3.1 is this page: two code blocks on it had no gate behind them and both
389
+ 0.3.1 was this page: two code blocks on it had no gate behind them and both
366
390
  were broken, and a long description is baked into the artifact, so the page on
367
391
  PyPI only heals with a release. Every block here is executed by a gate now,
368
392
  and the gate also refuses a relative link, an empty table header and a serve
@@ -332,13 +332,37 @@ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
332
332
  the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
333
333
  site, nowhere else.
334
334
 
335
- ## Not in 0.3.1
335
+ ## New in 0.4.0: a command surface on the server half
336
+
337
+ `spatialdds[server]` now implements Appendix N.6. A command is a POST to the
338
+ lane that accepts one, and an embedder registers what a verb means:
339
+
340
+ | Part | What it is |
341
+ | --- | --- |
342
+ | the seam | `spatialdds.server.commands.Registry().register(fqn, handler)` |
343
+ | a handler gets | the validated, lifted record — the same JSON a GET would serve |
344
+ | a handler returns | `Outcome.accept()` or `Outcome.decline("why")` |
345
+ | the wire | `202` accepted, `409` declined with the reason, `405` where no handler is registered |
346
+
347
+ `python -m spatialdds.serve --demo` registers one so the surface is reachable
348
+ without writing a backend. An endpoint with no handlers is northbound-only and
349
+ every command POST is a clean `405`, which is what N.6 requires and what every
350
+ earlier version did.
351
+
352
+ **The client half has no command method.** This release adds the surface, not
353
+ a helper for calling it — `requests.post(topic_url, json=record)` with the
354
+ operator token is the whole of it, and `docs/WIRE.md` documents the response
355
+ shapes. Said plainly because "commands are supported" invites a reader to look
356
+ for `hit.command(...)`, and there isn't one.
357
+
358
+ ## Not in 0.4.0
336
359
 
337
360
  Stated so nobody waits for them: no async API, no caching beyond ETag
338
361
  revalidation, no retry policy, no MCAP tooling beyond the journal the server
339
- writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
362
+ writes, no client-side command helper, and no DDS dependency of any kind. The
363
+ wire is HTTP and a WebSocket.
340
364
 
341
- 0.3.1 is this page: two code blocks on it had no gate behind them and both
365
+ 0.3.1 was this page: two code blocks on it had no gate behind them and both
342
366
  were broken, and a long description is baked into the artifact, so the page on
343
367
  PyPI only heals with a release. Every block here is executed by a gate now,
344
368
  and the gate also refuses a relative link, an empty table header and a serve
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spatialdds"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N), and the reference registry behind one command."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -37,7 +37,7 @@ __all__ = [
37
37
  "content_manifest", "geo_anchor", "service_uri", "content_uri",
38
38
  ]
39
39
 
40
- __version__ = "0.3.1"
40
+ __version__ = "0.4.0"
41
41
  #: The SpatialDDS-spec commit whose Appendix N and generated artifacts this
42
42
  #: package implements. Package versions and specification versions move
43
43
  #: independently; this is the one that says what the wire looks like.
@@ -42,10 +42,10 @@ def _server():
42
42
  """The server half, or one sentence saying how to get it."""
43
43
  try:
44
44
  from aiohttp import web
45
- from .server import gateway, gw_http, pin, publish
45
+ from .server import commands, gateway, gw_http, pin, publish
46
46
  except ImportError as e: # pragma: no cover
47
47
  raise SystemExit(f"spatialdds.serve: {MISSING_SERVER}\n\n({e})")
48
- return web, gateway, gw_http, pin, publish
48
+ return web, commands, gateway, gw_http, pin, publish
49
49
 
50
50
 
51
51
  def build_parser(ap: argparse.ArgumentParser) -> argparse.ArgumentParser:
@@ -102,8 +102,33 @@ def build_parser(ap: argparse.ArgumentParser) -> argparse.ArgumentParser:
102
102
  return ap
103
103
 
104
104
 
105
+ def _demo_command(record: dict):
106
+ """The `--demo` command handler: both of M.1's outcomes, no side effects.
107
+
108
+ A reference handler that pretended to act would be worse than none: it
109
+ would make the surface look wired to something. So this decides, says
110
+ why, and changes nothing --- which is all a demo registry can honestly do,
111
+ having no backend behind it.
112
+
113
+ `method == "DECLINE_ME"` is declined on purpose. Without a reachable
114
+ decline, the only exercised path would be acceptance, and M.1's whole
115
+ point is that a refusal is a first-class observable outcome; a surface
116
+ that can only say yes has not implemented it.
117
+ """
118
+ from .server.commands import Outcome # the server half is optional
119
+ if record.get("method") == "DECLINE_ME":
120
+ return Outcome.decline(
121
+ "the demo handler declines this method by arrangement, so a "
122
+ "caller can see M.1's decline path without a backend: a refusal "
123
+ "carries a reason and is not an absence")
124
+ return Outcome.accept(
125
+ "accepted by the --demo handler, which records nothing: this registry "
126
+ "has no backend, and a handler that claimed to act would make the "
127
+ "surface look wired to something it is not")
128
+
129
+
105
130
  def main(argv: list[str] | None = None) -> int:
106
- web, gateway, gw_http, pin, publish = _server()
131
+ web, commands, gateway, gw_http, pin, publish = _server()
107
132
  ap = build_parser(argparse.ArgumentParser(
108
133
  prog="python -m spatialdds.serve",
109
134
  description="Stand up a SpatialDDS registry."))
@@ -130,6 +155,21 @@ def main(argv: list[str] | None = None) -> int:
130
155
  if a.mcap is not None and not a.mcap.is_file():
131
156
  raise SystemExit(f"no such recording: {a.mcap}")
132
157
 
158
+ # The N.6 demo handler. `--demo` exists so the surface is exercisable
159
+ # without a backend, and a command surface nobody can POST to is a route
160
+ # with no evidence behind it -- the conformance suite's first clause had
161
+ # never been exercised by any run in this program precisely because there
162
+ # was nothing to exercise it against.
163
+ #
164
+ # It accepts a `GeoAnchor` command and declines one verb, so both of M.1's
165
+ # outcomes are reachable from a bare `--demo`: acceptance, and a decline
166
+ # carrying a reason. It does nothing to the registry, because `--demo` has
167
+ # no backend to do anything with -- which is the honest shape for a
168
+ # reference handler and the reason its reason string says so.
169
+ cmd_registry = commands.Registry()
170
+ if a.demo:
171
+ cmd_registry.register("spatial.core.GeoAnchor", _demo_command)
172
+
133
173
  token = a.token or secrets.token_urlsafe(24)
134
174
  st = gateway.build_state(
135
175
  a.mcap, a.manifests if a.manifests and a.manifests.is_dir() else None)
@@ -284,7 +324,8 @@ def main(argv: list[str] | None = None) -> int:
284
324
  try:
285
325
  web.run_app(gw_http.make_app(st, base, token, a.publish, jr,
286
326
  a.assets if a.assets
287
- and a.assets.is_dir() else None),
327
+ and a.assets.is_dir() else None,
328
+ command_registry=cmd_registry),
288
329
  host=a.host, port=a.port, print=None)
289
330
  except OSError as e:
290
331
  # An already-bound port is the one failure where everything downstream
@@ -0,0 +1,131 @@
1
+ """N.6 commands: the gateway owns the HTTP leg, a backend supplies the verbs.
2
+
3
+ Appendix N.6 is four sentences long and delegates its conventions to M.1, so
4
+ most of this module is the reasoning that fills the gap between them. The
5
+ ruling this implements: **N.6 is a gateway surface whose semantics a backend
6
+ supplies.** The gateway owns the route, the auth, the validation and the
7
+ response shape; a backend registers a handler per command type and decides
8
+ what a verb means. An endpoint whose backend registers nothing stays
9
+ northbound-only, and its POST stays exactly as conformant as it was before
10
+ this module existed.
11
+
12
+ **Why there is a seam at all.** The first consumer of this surface is
13
+ `SpatialDDS-demo`'s web bridge, which embeds the gateway against a live DDS
14
+ bus: a `goto` arriving as a POST has to leave as a keyed `ModelCommand` on a
15
+ DDS topic, and nothing in this repository knows how to do that or should. So
16
+ the deliverable is not only the route but the boundary — the narrowest thing
17
+ an embedder can implement to own the verbs without owning the HTTP.
18
+
19
+ **What N.6 fixes and what it leaves open.** It fixes that a command is a POST,
20
+ that the response carries the outcome *including an explicit decline*, and
21
+ that an endpoint without a command lane refuses with `405` or `404` and never
22
+ accepts a command it cannot act on. It says nothing about status codes for the
23
+ outcomes themselves, nothing about how a client discovers that commands are
24
+ accepted, and nothing about completion tracking. Those are decided here and
25
+ documented in `docs/WIRE.md`; none of them is invented where the text speaks.
26
+
27
+ **M.1's vocabulary, which N.6 points at.** A decline is "a first-class,
28
+ observable outcome, not an absence", carrying a terminal state and a
29
+ human-readable reason. The value of the pattern is that an observer can
30
+ distinguish *refused* from *not-yet-answered* from *not-for-me*. That is why
31
+ `Outcome` carries a reason on the decline path and why a declined command is
32
+ not reported with a success status: a caller that checks only the status must
33
+ not read a refusal as an acceptance, which would turn M.1's first-class
34
+ outcome back into the absence it exists to replace.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from dataclasses import dataclass
39
+ from typing import Any, Callable
40
+
41
+ #: The manifest member that advertises a lane as accepting commands.
42
+ #:
43
+ #: **Implementation-defined, and flagged as a specification gap.** N.7's
44
+ #: topic-entry table is a MUST-carry list of six fields plus two optional
45
+ #: ones, and none of them can say "this lane accepts commands" --- while N.6
46
+ #: says "where an endpoint exposes a command surface" and gives a client no
47
+ #: way to find out whether it does. A client would otherwise have to POST and
48
+ #: read the refusal, which is discovery by trial.
49
+ #:
50
+ #: So this member exists, under this repository's own name, documented in
51
+ #: `docs/WIRE.md`, and filed for 1.9 rather than treated as settled. N.7's
52
+ #: list is a floor rather than a ceiling, so carrying an extra member is
53
+ #: conformant; a *client* may not depend on it until the specification adopts
54
+ #: one, and nothing in this repository reads it except the manifest's own
55
+ #: reader and the conformance suite.
56
+ ADVERTISEMENT = "accepts_commands"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Outcome:
61
+ """What a backend did with a command.
62
+
63
+ Two states and a reason, which is M.1's vocabulary and not a richer one:
64
+ there is no `PENDING` because N.6 requires no completion tracking and
65
+ inventing it would put this gateway ahead of the specification it
66
+ implements. A backend that needs asynchronous completion reports
67
+ acceptance here and publishes the completion on its own lane, which is
68
+ what the DDS side already does.
69
+ """
70
+
71
+ accepted: bool
72
+ #: Required on a decline, and M.1 is why: "a decline is a first-class,
73
+ #: observable outcome, not an absence." A refusal with no reason is an
74
+ #: absence with a status code.
75
+ reason: str = ""
76
+
77
+ def __post_init__(self) -> None:
78
+ if not self.accepted and not self.reason:
79
+ raise ValueError(
80
+ "a declined command must carry a reason: M.1 makes a decline "
81
+ "a first-class outcome, and a refusal nobody can read is the "
82
+ "absence it exists to replace")
83
+
84
+ @classmethod
85
+ def accept(cls, reason: str = "") -> "Outcome":
86
+ return cls(True, reason)
87
+
88
+ @classmethod
89
+ def decline(cls, reason: str) -> "Outcome":
90
+ return cls(False, reason)
91
+
92
+
93
+ #: A handler receives the validated, lifted record --- the same JSON shape a
94
+ #: GET of that type would serve, after N.1 canonicalization and schema
95
+ #: validation --- and returns an `Outcome`. It never sees the request, the
96
+ #: token or the socket: an embedder owns verbs, not HTTP.
97
+ Handler = Callable[[dict[str, Any]], Outcome]
98
+
99
+
100
+ class Registry:
101
+ """The handlers an embedder has registered, keyed by type FQN.
102
+
103
+ Keyed by FQN rather than by topic so that a backend which accepts
104
+ `spatial.owm.ModelCommand` accepts it wherever a scene advertises that
105
+ type, which is how every other type-keyed table in this package works
106
+ (`DURABILITY`, `PUBLISHABLE`, the schema index).
107
+
108
+ Empty is the normal state. A gateway serving a recording has no verbs to
109
+ offer, and N.6 says a conformance suite MUST NOT require a command
110
+ surface to exist.
111
+ """
112
+
113
+ def __init__(self) -> None:
114
+ self._handlers: dict[str, Handler] = {}
115
+
116
+ def register(self, fqn: str, handler: Handler) -> None:
117
+ """Take commands of one type. Re-registering replaces, deliberately:
118
+ an embedder reconfiguring a lane should not have to tear down."""
119
+ self._handlers[fqn] = handler
120
+
121
+ def accepts(self, fqn: str) -> bool:
122
+ return fqn in self._handlers
123
+
124
+ def handler(self, fqn: str) -> Handler | None:
125
+ return self._handlers.get(fqn)
126
+
127
+ def types(self) -> list[str]:
128
+ return sorted(self._handlers)
129
+
130
+ def __bool__(self) -> bool:
131
+ return bool(self._handlers)
@@ -31,11 +31,11 @@ import json
31
31
  import math
32
32
  from dataclasses import dataclass, field
33
33
  from pathlib import Path
34
- from typing import Any, Iterable
34
+ from typing import Any, Callable, Iterable
35
35
 
36
36
  from mcap.reader import make_reader
37
37
 
38
- from . import idlmodel, pin, webjson, xcdr2
38
+ from . import commands, idlmodel, pin, webjson, xcdr2
39
39
 
40
40
  PUBLIC = "public-cacheable"
41
41
  OPERATOR = "operator-scoped"
@@ -553,7 +553,9 @@ def declared_service(st: State, scene: str) -> dict | None:
553
553
 
554
554
 
555
555
  def service_manifest(st: State, scene: str, base: str,
556
- declared: dict | None = None) -> dict:
556
+ declared: dict | None = None,
557
+ accepts_commands: Callable[[str], bool] | None = None
558
+ ) -> dict:
557
559
  """The §8.2.3 service manifest for one scene.
558
560
 
559
561
  `service.connection` is deliberately absent: it carries DDS connection
@@ -587,6 +589,21 @@ def service_manifest(st: State, scene: str, base: str,
587
589
  "access": res.access,
588
590
  "url": base + path,
589
591
  })
592
+ # N.6 asks a client to POST a command to the lane that accepts one,
593
+ # and N.7's topic entry has no member that says which lane that is:
594
+ # its table is six MUST-carry fields plus `qos_profile` and
595
+ # `liveliness`, none of which can carry "verbs welcome here". Without
596
+ # something, a client discovers the command surface by POSTing and
597
+ # reading the refusal, which is discovery by trial.
598
+ #
599
+ # So this member is added, under this repository's own name, and
600
+ # filed as a specification gap rather than treated as settled -- see
601
+ # docs/WIRE.md and the 1.9 candidates entry. N.7's list is a floor
602
+ # and not a ceiling, so an extra member is conformant; it is omitted
603
+ # entirely rather than written `false`, so a manifest from an endpoint
604
+ # with no verbs is byte-identical to one from before this existed.
605
+ if accepts_commands and accepts_commands(res.fqn):
606
+ topics_block[-1][commands.ADVERTISEMENT] = True
590
607
  man: dict[str, Any] = {
591
608
  "id": f"spatialdds://spatialdds-web.local/{scene}/service/gateway",
592
609
  "profile": "spatial.manifest/1.8",
@@ -659,7 +676,8 @@ def service_manifest(st: State, scene: str, base: str,
659
676
 
660
677
  def search(st: State, base: str, geohash: str | None = None,
661
678
  kinds: list[str] | None = None,
662
- coverage: list[dict] | None = None) -> dict:
679
+ coverage: list[dict] | None = None,
680
+ accepts_commands: Callable[[str], bool] | None = None) -> dict:
663
681
  """The §3.3.0 search response: `{"results": [...], "next_page_token": ""}`."""
664
682
  wanted: list[tuple[float, float, float, float]] = []
665
683
  if geohash:
@@ -679,10 +697,12 @@ def search(st: State, base: str, geohash: str | None = None,
679
697
  for scene in st.scenes:
680
698
  declared = declared_services(st, scene)
681
699
  if declared:
682
- manifests.extend(service_manifest(st, scene, base, d)
700
+ manifests.extend(service_manifest(st, scene, base, d,
701
+ accepts_commands)
683
702
  for d in declared)
684
703
  else:
685
- manifests.append(service_manifest(st, scene, base))
704
+ manifests.append(service_manifest(st, scene, base,
705
+ accepts_commands=accepts_commands))
686
706
  for man in manifests:
687
707
  if kinds and man["service"]["kind"] not in kinds:
688
708
  continue
@@ -36,7 +36,7 @@ from typing import Any
36
36
 
37
37
  from aiohttp import WSMsgType, web
38
38
 
39
- from . import gateway, publish, webjson
39
+ from . import commands, gateway, publish, webjson
40
40
 
41
41
  WELL_KNOWN = "/.well-known/spatialdds"
42
42
 
@@ -211,7 +211,8 @@ async def search(request: web.Request) -> web.Response:
211
211
  kinds = [k] if k else []
212
212
  try:
213
213
  doc = gateway.search(st, base, geohash=geohash, kinds=kinds,
214
- coverage=coverage)
214
+ coverage=coverage,
215
+ accepts_commands=request.app["commands"].accepts)
215
216
  except ValueError as e:
216
217
  raise web.HTTPBadRequest(
217
218
  body=_body({"type": PROBLEM_NS + "bad-query",
@@ -481,6 +482,98 @@ async def _broadcast(request: web.Request, topic: str, event: dict) -> None:
481
482
  q.put_nowait(event)
482
483
 
483
484
 
485
+ async def post_command(request: web.Request) -> web.Response:
486
+ """N.6: a command is a POST to the resource it acts on.
487
+
488
+ The resource is the lane, not the instance, and the addressee rides in the
489
+ body. That is M.1's shape -- "a shared keyed command topic, keyed by the
490
+ addressee" -- and it is also the URL the manifest advertises, which is the
491
+ URL a client holding a manifest already has. Posting to an instance path
492
+ would make a client build a URL by string surgery on a key, which is the
493
+ thing N.7's `url` member exists to abolish.
494
+
495
+ Four outcomes, and the status carries which:
496
+
497
+ * **202** the backend accepted it. Acceptance, not completion: N.6
498
+ requires no completion tracking, so this gateway invents none.
499
+ * **409** the backend declined, with M.1's reason in the problem document.
500
+ Not a 200: a caller that checks only the status must not read a refusal
501
+ as an acceptance, which would turn M.1's first-class outcome back into
502
+ the absence it exists to replace.
503
+ * **405** no handler for this type, or this lane is not a command lane.
504
+ N.6 names 405 or 404 for an endpoint with nothing to accept a verb for,
505
+ and the body is still an N.8 problem document so the absence is stated
506
+ rather than left to a framework's default page.
507
+ * **400/401** the body is not valid for the type, or no bearer credential.
508
+ Commands are writes, so N.4's operator-scoped path applies.
509
+ """
510
+ st: gateway.State = request.app["state"]
511
+ reg: commands.Registry = request.app["commands"]
512
+ m = request.match_info
513
+ path = f"/{m['scene']}/{m['stream']}/{m['type']}/{m['version']}"
514
+ res = st.resources.get(path)
515
+
516
+ # Refuse before authenticating when there is nothing here to command: a
517
+ # 401 would tell an anonymous caller that a lane exists, and the lane's
518
+ # existence is public information that this endpoint simply does not have.
519
+ if res is None:
520
+ return _rejected(publish.Rejected(
521
+ 405, "no-command-lane", "No command lane here",
522
+ f"This endpoint serves no resource at {path}, so there is no verb "
523
+ f"to accept. N.6: an endpoint with no command lane refuses "
524
+ f"cleanly and never accepts a command it cannot act on."), request)
525
+ if not reg.accepts(res.fqn):
526
+ return _rejected(publish.Rejected(
527
+ 405, "no-command-lane", "No command lane here",
528
+ f"This endpoint serves {res.fqn} read-only: no backend has "
529
+ f"registered a handler for it, so there is nothing that could act "
530
+ f"on a verb. N.6 makes that a clean refusal rather than a silent "
531
+ f"acceptance.",
532
+ accepts=reg.types()), request)
533
+
534
+ if not _authorized(request, request.app["token"]):
535
+ return _unauthorized(str(request.rel_url))
536
+
537
+ try:
538
+ body = await request.json()
539
+ except Exception as e:
540
+ return _rejected(publish.Rejected(
541
+ 400, "not-json", "Body is not JSON",
542
+ f"The body must be N.1 canonical JSON: {e}"), request)
543
+ try:
544
+ publish.validate_schema(res.fqn, body)
545
+ data, _raw = publish.canonicalize(st, res.fqn, body)
546
+ except publish.Rejected as r:
547
+ return _rejected(r, request)
548
+
549
+ handler = reg.handler(res.fqn)
550
+ assert handler is not None # reg.accepts() was true above
551
+ try:
552
+ outcome = handler(data)
553
+ except Exception as e:
554
+ # A backend that raises is a backend that did not decide. Reporting
555
+ # 500 is honest; reporting a decline would put words in its mouth.
556
+ return _problem(500, "handler-failed", "The command handler failed",
557
+ f"The backend registered for {res.fqn} raised "
558
+ f"{type(e).__name__}: {e}",
559
+ instance=str(request.rel_url))
560
+
561
+ if outcome.accepted:
562
+ doc: dict[str, Any] = {"outcome": "accepted", "type": res.fqn}
563
+ if outcome.reason:
564
+ doc["reason"] = outcome.reason
565
+ # Built directly rather than through `_json_response`, which is for
566
+ # resources: it answers 200 and honours If-None-Match, and a command
567
+ # outcome is neither cacheable nor re-fetchable. `no-store` because
568
+ # N.4 forbids any lifetime on an operator-scoped surface.
569
+ return web.Response(body=_body(doc), status=202,
570
+ content_type="application/json",
571
+ headers={"Cache-Control": "no-store"})
572
+ return _rejected(publish.Rejected(
573
+ 409, "command-declined", "The command was declined",
574
+ outcome.reason, outcome="declined", type=res.fqn), request)
575
+
576
+
484
577
  async def put_instance(request: web.Request) -> web.Response:
485
578
  """PUT on the same instance URL a GET already serves."""
486
579
  if not request.app["publish"]:
@@ -705,7 +798,9 @@ async def root(request: web.Request) -> web.Response:
705
798
  def make_app(state: gateway.State, base: str, token: str | None,
706
799
  publish_enabled: bool = False,
707
800
  journal: "publish.Journal | None" = None,
708
- assets: Path | None = None) -> web.Application:
801
+ assets: Path | None = None,
802
+ command_registry: "commands.Registry | None" = None
803
+ ) -> web.Application:
709
804
  app = web.Application()
710
805
  app["state"] = state
711
806
  app["base"] = base.rstrip("/")
@@ -715,6 +810,10 @@ def make_app(state: gateway.State, base: str, token: str | None,
715
810
  # default page, and so that the surface's absence is something the
716
811
  # endpoint states rather than something a caller infers from a 405.
717
812
  app["publish"] = publish_enabled
813
+ # The N.6 seam. Empty is the normal state: a gateway serving a recording
814
+ # has no verbs, and N.6 says a suite MUST NOT require a command surface
815
+ # to exist. An embedder registers handlers on this.
816
+ app["commands"] = command_registry or commands.Registry()
718
817
  app["journal"] = journal
719
818
  app["subs"] = {}
720
819
  app["assets"] = assets
@@ -732,6 +831,12 @@ def make_app(state: gateway.State, base: str, token: str | None,
732
831
  # `spatialdds/` removed, collection then instance.
733
832
  web.get("/{scene}/{stream}/{type}/{version}", collection),
734
833
  web.get("/{scene}/{stream}/{type}/{version}/{key}", instance),
834
+ # N.6: a command POST on the lane's own collection URL -- the one
835
+ # the manifest advertises, so a client follows a link rather than
836
+ # building a path. Registered unconditionally, like the write
837
+ # surface below and for the same reason: a refusal should be a stated
838
+ # N.8 problem document, not a framework's default 405 page.
839
+ web.post("/{scene}/{stream}/{type}/{version}", post_command),
735
840
  # The write surface, on the same URLs the GETs serve.
736
841
  web.put("/{scene}/{stream}/{type}/{version}/{key}", put_instance),
737
842
  web.delete("/{scene}/{stream}/{type}/{version}/{key}",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdds
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N), and the reference registry behind one command.
5
5
  Author: Open AR Cloud
6
6
  License: Apache-2.0
@@ -356,13 +356,37 @@ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
356
356
  the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
357
357
  site, nowhere else.
358
358
 
359
- ## Not in 0.3.1
359
+ ## New in 0.4.0: a command surface on the server half
360
+
361
+ `spatialdds[server]` now implements Appendix N.6. A command is a POST to the
362
+ lane that accepts one, and an embedder registers what a verb means:
363
+
364
+ | Part | What it is |
365
+ | --- | --- |
366
+ | the seam | `spatialdds.server.commands.Registry().register(fqn, handler)` |
367
+ | a handler gets | the validated, lifted record — the same JSON a GET would serve |
368
+ | a handler returns | `Outcome.accept()` or `Outcome.decline("why")` |
369
+ | the wire | `202` accepted, `409` declined with the reason, `405` where no handler is registered |
370
+
371
+ `python -m spatialdds.serve --demo` registers one so the surface is reachable
372
+ without writing a backend. An endpoint with no handlers is northbound-only and
373
+ every command POST is a clean `405`, which is what N.6 requires and what every
374
+ earlier version did.
375
+
376
+ **The client half has no command method.** This release adds the surface, not
377
+ a helper for calling it — `requests.post(topic_url, json=record)` with the
378
+ operator token is the whole of it, and `docs/WIRE.md` documents the response
379
+ shapes. Said plainly because "commands are supported" invites a reader to look
380
+ for `hit.command(...)`, and there isn't one.
381
+
382
+ ## Not in 0.4.0
360
383
 
361
384
  Stated so nobody waits for them: no async API, no caching beyond ETag
362
385
  revalidation, no retry policy, no MCAP tooling beyond the journal the server
363
- writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
386
+ writes, no client-side command helper, and no DDS dependency of any kind. The
387
+ wire is HTTP and a WebSocket.
364
388
 
365
- 0.3.1 is this page: two code blocks on it had no gate behind them and both
389
+ 0.3.1 was this page: two code blocks on it had no gate behind them and both
366
390
  were broken, and a long description is baked into the artifact, so the page on
367
391
  PyPI only heals with a release. Every block here is executed by a gate now,
368
392
  and the gate also refuses a relative link, an empty table header and a serve
@@ -136,6 +136,7 @@ spatialdds/demo/assets/fountain-marker.gltf
136
136
  spatialdds/demo/manifests/content-fountain-label.json
137
137
  spatialdds/demo/manifests/content-fountain-marker.json
138
138
  spatialdds/server/__init__.py
139
+ spatialdds/server/commands.py
139
140
  spatialdds/server/gateway.py
140
141
  spatialdds/server/gw_http.py
141
142
  spatialdds/server/idlmodel.py
File without changes