plesty-lib 0.3.5.dev2__tar.gz → 0.3.5.dev3__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 (163) hide show
  1. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/CHANGELOG.md +68 -0
  2. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/PKG-INFO +1 -1
  3. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/base_device.md +6 -1
  4. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/test_helper.md +25 -1
  5. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/gl-secret-detection-report.json +5 -5
  6. plesty_lib-0.3.5.dev3/plesty/lib/device/device_utils.py +231 -0
  7. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/funcs.py +18 -2
  8. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/client_field_test.py +88 -9
  9. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/field_test.py +330 -24
  10. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/report_artifact.py +7 -0
  11. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/__init__.py +17 -1
  12. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/apt.py +9 -0
  13. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/serial.py +5 -0
  14. plesty_lib-0.3.5.dev3/plesty/lib/traffic/serial_ports.py +86 -0
  15. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/qt/plot.py +2 -2
  16. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/qt/shell.py +1 -1
  17. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_client_field_test.py +123 -0
  18. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_funcs.py +64 -1
  19. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_field_test.py +250 -2
  20. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_report_artifact.py +7 -0
  21. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_traffic_drivers.py +101 -0
  22. plesty_lib-0.3.5.dev2/plesty/lib/device/device_utils.py +0 -108
  23. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/.gitignore +0 -0
  24. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/.gitlab-ci.yml +0 -0
  25. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/LICENSE +0 -0
  26. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/LICENSES/LGPL-3.0-or-later.txt +0 -0
  27. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/README.md +0 -0
  28. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/REUSE.toml +0 -0
  29. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/analyzer.md +0 -0
  30. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/api_reference.md +0 -0
  31. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/architecture.md +0 -0
  32. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/contributing.md +0 -0
  33. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/data_schemas.md +0 -0
  34. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/cmd_solver.md +0 -0
  35. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/composite_device.md +0 -0
  36. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/error_handling.md +0 -0
  37. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/func_system.md +0 -0
  38. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/index.md +0 -0
  39. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/logging_system.md +0 -0
  40. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/param_system.md +0 -0
  41. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/device/traffic_manager.md +0 -0
  42. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/experiment.md +0 -0
  43. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/imgs/device_layers.svg +0 -0
  44. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/imgs/device_standard.svg +0 -0
  45. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/imgs/plesty_framework.svg +0 -0
  46. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/index.md +0 -0
  47. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/monitor.md +0 -0
  48. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/quickstart.md +0 -0
  49. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/toc.yaml +0 -0
  50. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/docs/ui.md +0 -0
  51. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/__init__.py +0 -0
  52. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/analyzer/__init__.py +0 -0
  53. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/analyzer/base_analyzer.py +0 -0
  54. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/__init__.py +0 -0
  55. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/array.py +0 -0
  56. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/ctype_manager.py +0 -0
  57. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/io.py +0 -0
  58. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/table.py +0 -0
  59. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/types.py +0 -0
  60. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/data/units.py +0 -0
  61. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/__init__.py +0 -0
  62. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/async_wrapper.py +0 -0
  63. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/base_apt_device.py +0 -0
  64. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/base_device_sync.py +0 -0
  65. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/base_tcp_scpi_device.py +0 -0
  66. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/base_visa_scpi_device.py +0 -0
  67. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/composite_device.py +0 -0
  68. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/doc.py +0 -0
  69. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/params.py +0 -0
  70. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/device/telemetry.py +0 -0
  71. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/__init__.py +0 -0
  72. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/assets/preflight.yaml +0 -0
  73. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/base_experiment.py +0 -0
  74. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/journal.py +0 -0
  75. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/preflight.py +0 -0
  76. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/runs.py +0 -0
  77. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/experiment/schedule.py +0 -0
  78. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/monitor/__init__.py +0 -0
  79. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/monitor/assets/viz.yaml +0 -0
  80. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/monitor/base_monitor.py +0 -0
  81. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/monitor/sources.py +0 -0
  82. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/monitor/viz.py +0 -0
  83. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/service/__init__.py +0 -0
  84. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/service/resource_manager.py +0 -0
  85. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/service/tcp_ip_client.py +0 -0
  86. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/service/tcp_ip_server.py +0 -0
  87. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/sim/__init__.py +0 -0
  88. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/sim/apt.py +0 -0
  89. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/sim/data_generator.py +0 -0
  90. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/sim/demo_device.py +0 -0
  91. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/solver/__init__.py +0 -0
  92. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/solver/iceblock.py +0 -0
  93. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/solver/scpi.py +0 -0
  94. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/__init__.py +0 -0
  95. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/analyzer_pipeline.py +0 -0
  96. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/device_func_system.py +0 -0
  97. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/device_param_system.py +0 -0
  98. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/device_pipeline.py +0 -0
  99. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/experiment_pipeline.py +0 -0
  100. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/field_test_concurrency.py +0 -0
  101. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/grouped_param_keys.py +0 -0
  102. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/resource_allocation.py +0 -0
  103. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/schema_params.py +0 -0
  104. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/test/schema_refresh.py +0 -0
  105. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/apt_protocol.py +0 -0
  106. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/tcp_ip.py +0 -0
  107. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/usb_utils.py +0 -0
  108. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/utils.py +0 -0
  109. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/traffic/visa.py +0 -0
  110. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/__init__.py +0 -0
  111. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/assets/palette.json +0 -0
  112. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/assets/shell.qss +0 -0
  113. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/monitor_panel.py +0 -0
  114. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/panel.py +0 -0
  115. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/qt/__init__.py +0 -0
  116. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/qt/app.py +0 -0
  117. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/qt/recorder.py +0 -0
  118. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/ui/theme.py +0 -0
  119. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/__init__.py +0 -0
  120. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/config.py +0 -0
  121. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/dll_utils.py +0 -0
  122. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/error_utils.py +0 -0
  123. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/logger.py +0 -0
  124. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/registry.py +0 -0
  125. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/plesty/lib/utils/settings.py +0 -0
  126. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/pyproject.toml +0 -0
  127. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_analyzer.py +0 -0
  128. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_analyzer_pipeline.py +0 -0
  129. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_async_wrapper_threading.py +0 -0
  130. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_composite_config.py +0 -0
  131. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_config.py +0 -0
  132. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_data_array.py +0 -0
  133. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_data_io_hdf5.py +0 -0
  134. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_data_types.py +0 -0
  135. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_demo_device.py +0 -0
  136. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_apt.py +0 -0
  137. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_base.py +0 -0
  138. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_data_path.py +0 -0
  139. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_params.py +0 -0
  140. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_pipeline.py +0 -0
  141. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_scpi.py +0 -0
  142. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_device_telemetry.py +0 -0
  143. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_experiment.py +0 -0
  144. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_experiment_pipeline.py +0 -0
  145. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_experiment_preflight.py +0 -0
  146. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_experiment_runs.py +0 -0
  147. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_misc.py +0 -0
  148. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_monitor.py +0 -0
  149. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_schema_params.py +0 -0
  150. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_schema_refresh.py +0 -0
  151. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_service_manager.py +0 -0
  152. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_settings.py +0 -0
  153. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_shipped_imports.py +0 -0
  154. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_sim.py +0 -0
  155. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_solver.py +0 -0
  156. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_tcp_ip_resources.py +0 -0
  157. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_traffic_orphan.py +0 -0
  158. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_traffic_utils.py +0 -0
  159. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_ui.py +0 -0
  160. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_utils.py +0 -0
  161. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_visa_discovery.py +0 -0
  162. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/tests/test_viz.py +0 -0
  163. {plesty_lib-0.3.5.dev2 → plesty_lib-0.3.5.dev3}/uv.lock +0 -0
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.5.dev3 — 2026-08-18
4
+
5
+ Development pre-release for field-testing operation kinds (#39): operations
6
+ declare what they do and the field test drives a motion out and back, a
7
+ lifecycle op once, a control op at rest; plus the pol-pl rig findings #35
8
+ and #38. Same line as 0.3.5.dev2 — the final release is 0.4.0.
9
+
10
+ - **Operations declare what they do, and the field test drives them by
11
+ kind** (#39). `@expose_to_api(kind=…)` and a schema `kind` block record
12
+ one of `read | motion | acquire | lifecycle | control | configure`
13
+ (`plesty.lib.device.device_utils.OpKind`; `FuncDoc.kind`, the describe
14
+ payload and `function_docs()` carry it). `FieldTestPipeline` acts on it
15
+ instead of asking the developer per operation: a `motion` op is driven
16
+ one `step` out from what its `position_key` reports and put back (the
17
+ functions gate checks it came back; the stability gate repeats out and
18
+ back), a `lifecycle` op runs once when allow-listed and never on repeat,
19
+ a `control` op once at rest after the motions (skipped when nothing
20
+ moved), a `configure` op is never called. `op_kinds={…}` overrides the
21
+ module's declaration. On plesty-k10cr1 the operation every pol-pl row
22
+ calls could not be tested at all: `move_absolute(position)` was called
23
+ bare and failed on the missing argument.
24
+ - **The client tier drives operations by kind too.** `ClientFieldTest`
25
+ takes `op_kinds=` (the generated `field_test_client.py` passes the host
26
+ tier's): a motion is queried through `position_key`, moved one step out
27
+ and back over the wire as many pairs as the host tier's call count, a
28
+ lifecycle op once, a control op once after the motions, a configure op
29
+ never; a motion without `position_key`/`target`/`step` fails the gate.
30
+ - **A provocation that breaks is a failed gate, not a forced timeout.**
31
+ The drain and recovery gates called the slow op with the payload as one
32
+ positional dict (`getattr(device, slow_op)(dict(payload))`), so an
33
+ `@expose_to_api` slow op raised `TypeError` before any transport
34
+ traffic — and both gates counted the exception as an abandoned call and
35
+ passed. The call now goes through the operation's own call form, only a
36
+ timeout-class error counts as forced, and a motion slow op is a delta
37
+ from where the part is, moved back afterwards (`motion_restored` in the
38
+ evidence). The slow-op payload is prepared before the timeout is
39
+ shortened, so reading the start position cannot itself time out.
40
+
41
+ - **A serial port that is held says so** (#35). pyserial reports a wrong
42
+ port name and a port open in another process with the same
43
+ `SerialException`, and on the K10CR1 rig `Access is denied` on the right
44
+ `COM4` — Kinesis had the stage loaded — cost the session to driver and
45
+ VCP checks. `TrafficManager.open` now asks the subclass to explain a
46
+ failure (`_explain_open_error`, `None` by default) and appends the answer
47
+ to the raised message. `AptTrafficManager` and `SerialTrafficManager`
48
+ answer for EACCES/EBUSY: on Windows and for EBUSY, the port exists but is
49
+ open elsewhere — close Kinesis/APT (a loaded stage locks the FTDI device
50
+ via D2XX and blocks the VCP), stray interpreters, serial terminals, replug
51
+ to drop a stale handle; on Linux EACCES, add the user to `dialout`. The
52
+ port's `list_ports` description and hwid follow, so the user sees at once
53
+ whether the address is the intended device. Windows sets no `errno` on
54
+ the exception, so the number is read out of the message text. Other
55
+ failures (`ENOENT`, timeouts) are reported as before.
56
+ - `ModuleInfo.from_project` and the field-test version lookup no longer
57
+ answer for whatever repository `GIT_DIR` names: a git hook exports it for
58
+ the repository being pushed, and every `git -C <root>` in `report_artifact`
59
+ followed it — a field test run from a pre-push hook reported the hook's
60
+ remote and version, and the pre-push suite failed on the tmp-dir module
61
+ test. `GIT_*` is scrubbed from the subprocess environment.
62
+
63
+ - **Client-tier `functions` gate no longer demands a dict of every
64
+ operation** (#38). It required `isinstance(answer, dict)` for each
65
+ allow-listed operation, while the host tier asks that only of
66
+ schema-declared ones; on lightfield_spectrometer `acquire` (`None`) and
67
+ `get_recent_file` (`str`) came back 21 times each with no failure and
68
+ the gate still failed. A call that returns is the pass; the answer's
69
+ type is recorded in the evidence.
70
+
3
71
  ## 0.3.5.dev2 — 2026-08-17
4
72
 
5
73
  Development pre-release for field-testing the rig start-up (#36, #37):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plesty-lib
3
- Version: 0.3.5.dev2
3
+ Version: 0.3.5.dev3
4
4
  Summary: A library for the Plesty ecosystem.
5
5
  Author: Plesty Development Team
6
6
  Author-email: Yunshuang Yuan <yunshuang.yuan@fkp.uni-hannover.de>
@@ -261,7 +261,12 @@ model.standard_methods # the common device API (connect/write/query/reset/...)
261
261
  `functions` unifies the two ways a device gains operations: schema-registered
262
262
  operations (`source="schema"`) and `@expose_to_api` methods
263
263
  (`source="custom"`); framework plumbing is kept out and surfaced separately
264
- under `standard_methods` (`source="standard"`). Per-parameter and return
264
+ under `standard_methods` (`source="standard"`). Each `FuncDoc` also carries
265
+ its `kind` — what the operation does to the world (`read`, `motion`,
266
+ `acquire`, `lifecycle`, `control`, `configure`), declared with
267
+ `@expose_to_api(kind=..., position_key=..., step=...)` or a `"kind"` block in
268
+ `schema_func.json` and used by the field test to drive the operation
269
+ (see *test_helper*, "Kinds"). Per-parameter and return
265
270
  descriptions are parsed from Google-style docstring `Args:`/`Returns:`
266
271
  sections — write those in your device methods and the generated documentation
267
272
  inherits them.
@@ -455,7 +455,7 @@ if __name__ == "__main__":
455
455
  | 2 | `test_connect_lifecycle` | connect / identity / `check_errors` / disconnect / reconnect, each timed; identity stable across the reconnect |
456
456
  | 3 | `test_param_roundtrip` | every writable parameter round-trips; the value read back is written back, so device state is unchanged |
457
457
  | 4 | `test_param_constraints` | hardware MIN/MAX and option sets against the schema envelope — a schema wider than the hardware fails |
458
- | 5 | `test_functions` | every operation on the safe allow-list returns a `dict` |
458
+ | 5 | `test_functions` | every operation on the safe allow-list can be called — by kind: a motion one step out and back, a lifecycle op once, a control op once at rest after the motions |
459
459
  | 6 | `test_buffer_drain` | a query abandoned mid-flight must not leave its answer for the next query to consume |
460
460
  | 7 | `test_error_recovery` | the device recovers from a forced timeout, and the first command after a reconnect succeeds |
461
461
  | 8 | `test_stability` | every queryable parameter and safe op repeated `repetitions` times to collect the statistics |
@@ -464,6 +464,29 @@ if __name__ == "__main__":
464
464
  (a `"safe": true` entry in `schema_func.json` is forwarded automatically). Without an allow-list
465
465
  the function gate is skipped — no motion or emission operation ever runs unattended.
466
466
 
467
+ **Kinds.** *How* an allow-listed operation is called comes from what the module declares it
468
+ does — `@expose_to_api(kind=...)` or a `"kind"` block in `schema_func.json`, read back as
469
+ `OpKind` (`plesty.lib.device.device_utils`):
470
+
471
+ | kind | the gates |
472
+ |---|---|
473
+ | `read` (default) | called with its declared payload, repeated as it is |
474
+ | `motion` | driven from what `position_key` reports: `target = start ± step` (`relative=True` sends the step itself), then put back; the functions gate fails if it does not come back, the stability gate repeats out-and-back; a motion `slow_op` is a delta from the start and is moved back after the provocation |
475
+ | `acquire` | as `read`, but recorded as acting |
476
+ | `lifecycle` (home, reset) | once, when allow-listed; never repeated |
477
+ | `control` (stop, abort) | once, at rest, after the motions; skipped when nothing moved |
478
+ | `configure` | never called — the round-trip gate covers settings |
479
+
480
+ ```python
481
+ @expose_to_api(kind="motion", position_key="MO.Position", step=1.0)
482
+ def move_absolute(self, position: float) -> float: ...
483
+ ```
484
+
485
+ `op_kinds={"move_absolute": OpKind("motion", position_key="MO.Position", step=1.0)}` on the
486
+ pipeline overrides the declaration wholesale. A motion without `position_key`, `target` (the
487
+ first required argument by default) or `step` fails the gate rather than guessing how far a part
488
+ may move.
489
+
467
490
  **Key parameters:**
468
491
 
469
492
  | Parameter | Default | Description |
@@ -471,6 +494,7 @@ the function gate is skipped — no motion or emission operation ever runs unatt
471
494
  | `repetitions` | `20` | Per-command repeat count in the stability gate |
472
495
  | `report_path` | `None` | Base path; `run()` writes `<base>.jsonl` (appended) and `<base>.md` |
473
496
  | `safe_ops` / `unsafe_ops` | `None` | Operation allow-list and opt-out list |
497
+ | `op_kinds` | `None` | Per-operation `OpKind` overriding the module's declaration |
474
498
  | `op_payloads` | `None` | Explicit payload per operation instead of a generated one |
475
499
  | `ignore_keys` | `None` | Config keys excluded from every parameter gate |
476
500
  | `drain_probe` | auto | The two distinguishable keys used by the drain and recovery gates |
@@ -19,19 +19,19 @@
19
19
  "version": "8.30.1"
20
20
  },
21
21
  "type": "secret_detection",
22
- "start_time": "2026-08-17T15:29:32",
23
- "end_time": "2026-08-17T15:29:32",
22
+ "start_time": "2026-08-18T13:39:46",
23
+ "end_time": "2026-08-18T13:39:47",
24
24
  "status": "success",
25
25
  "observability": {
26
26
  "events": [
27
27
  {
28
28
  "event": "collect_secrets_analyzer_scan_metrics_from_pipeline",
29
- "time_s": 0.45769703,
29
+ "time_s": 0.527730295,
30
30
  "exit_code": 0,
31
31
  "git_strategy": "FetchShallow",
32
- "repo_size_kb": 708,
32
+ "repo_size_kb": 709,
33
33
  "commit_count": 1,
34
- "bytes_scanned": 220,
34
+ "bytes_scanned": 317,
35
35
  "pipeline_type": "Tag"
36
36
  },
37
37
  {
@@ -0,0 +1,231 @@
1
+ # SPDX-FileCopyrightText: 2026 Plesty Development Team
2
+ # SPDX-FileContributor: Yunshuang Yuan
3
+ #
4
+ # SPDX-License-Identifier: LGPL-3.0-or-later
5
+
6
+ """Utility functions for device connectivity and configuration."""
7
+
8
+ import socket
9
+ from dataclasses import dataclass, replace
10
+ from typing import Any, Callable
11
+ import functools
12
+ import inspect
13
+
14
+
15
+ class ResponseParser:
16
+ """Parser to be used in the base device class. Once registered along with a config parameter,
17
+ the parser will be called to parse the raw response from `_query_()` function of the device.
18
+ The parsed result will be used to update the corresponding configuration parameter in the
19
+ `query()` function.
20
+ """
21
+
22
+ def __call__(self, response, param, **kwargs) -> Any:
23
+ """Parse the raw response from the device and return a structured result."""
24
+ # Call the parse method implemented by subclasses
25
+ return self.parse(response, param=param, **kwargs)
26
+
27
+ def parse(self, response, param, **kwargs) -> Any:
28
+ """Alias for __call__, allowing explicit parsing calls."""
29
+ raise NotImplementedError(
30
+ "The parse method should be implemented by subclasses of ResponseParser."
31
+ )
32
+
33
+
34
+ # define decoretor for device methods that require operatability check
35
+ def operatable(func: Callable[..., Any]) -> Callable[..., Any]:
36
+ """Decorator that checks device operatability before executing the wrapped function."""
37
+ if inspect.iscoroutinefunction(func):
38
+
39
+ @functools.wraps(func)
40
+ async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
41
+ if not self.is_operatable:
42
+ raise RuntimeError("Device is not operatable. Please connect to the device first.")
43
+ return await func(self, *args, **kwargs)
44
+
45
+ return async_wrapper
46
+ else:
47
+
48
+ @functools.wraps(func)
49
+ def sync_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
50
+ if not self.is_operatable:
51
+ raise RuntimeError("Device is not operatable. Please connect to the device first.")
52
+ return func(self, *args, **kwargs)
53
+
54
+ return sync_wrapper
55
+
56
+
57
+ # define decoretor for device methods that require error checking after execution
58
+ def error_check(func: Callable[..., Any]) -> Callable[..., Any]:
59
+ """Decorator that checks for device errors after executing the wrapped function."""
60
+ if inspect.iscoroutinefunction(func):
61
+
62
+ @functools.wraps(func)
63
+ async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
64
+ result = await func(self, *args, **kwargs)
65
+ errors = await self.check_errors()
66
+ if errors:
67
+ raise RuntimeError(
68
+ f"Device reported errors after executing '{func.__name__}': {errors}"
69
+ )
70
+ return result
71
+
72
+ return async_wrapper
73
+ else:
74
+
75
+ @functools.wraps(func)
76
+ def sync_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
77
+ result = func(self, *args, **kwargs)
78
+ errors = self.check_errors()
79
+ if errors:
80
+ raise RuntimeError(
81
+ f"Device reported errors after executing '{func.__name__}': {errors}"
82
+ )
83
+ return result
84
+
85
+ return sync_wrapper
86
+
87
+
88
+ def get_local_ip() -> str:
89
+ """Return the local IP address of this machine."""
90
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
91
+ try:
92
+ # doesn't need to be reachable
93
+ s.connect(("8.8.8.8", 80))
94
+ ip = s.getsockname()[0]
95
+ finally:
96
+ s.close()
97
+ return ip
98
+
99
+
100
+ #: What an operation does to the world, as far as an unattended test needs to
101
+ #: know. Declared once by the module; read by the field test, which decides per
102
+ #: kind how an operation is called, repeated, made slow and put back — instead
103
+ #: of asking the developer to re-derive it for every operation.
104
+ OP_KINDS: tuple[str, ...] = ("read", "motion", "acquire", "lifecycle", "control", "configure")
105
+
106
+ #: Attribute the exposure marker and the kind are stored under on the method.
107
+ EXPOSE_MARKER = "__plesty_expose_in_api__"
108
+ OP_KIND_MARKER = "__plesty_op_kind__"
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class OpKind:
113
+ """The class of an operation and, for motion, what the test needs to drive it.
114
+
115
+ Attributes:
116
+ kind: One of :data:`OP_KINDS`. ``read`` returns a value and changes
117
+ nothing. ``motion`` moves a part whose position a parameter reports.
118
+ ``acquire`` emits or exposes (a shutter, an exposure) and produces
119
+ data. ``lifecycle`` is homing, reset, calibration — done once, never
120
+ on repeat. ``control`` is stop/abort — meaningful only with a motion
121
+ in flight. ``configure`` changes a setting that outlives the call.
122
+ position_key: Motion only — the configuration key that reports where
123
+ the moving part is, in the same units the operation takes.
124
+ target: Motion only — the argument that receives the target. Defaults
125
+ to the operation's first required argument.
126
+ relative: Motion only — the target is a signed step from the current
127
+ position rather than an absolute position.
128
+ step: Motion only — how far the field test may move the part, in
129
+ ``position_key`` units. ``None`` leaves it to the test configuration.
130
+ """
131
+
132
+ kind: str = "read"
133
+ position_key: str | None = None
134
+ target: str | None = None
135
+ relative: bool = False
136
+ step: float | None = None
137
+
138
+ def __post_init__(self) -> None:
139
+ """Reject a kind nothing knows how to handle."""
140
+ if self.kind not in OP_KINDS:
141
+ raise ValueError(f"unknown operation kind {self.kind!r}; expected one of {OP_KINDS}")
142
+
143
+ @property
144
+ def acts(self) -> bool:
145
+ """Whether the operation changes the world rather than only reading it."""
146
+ return self.kind != "read"
147
+
148
+ def with_defaults(self, **fields: Any) -> "OpKind":
149
+ """Return a copy where every ``None`` field is filled from *fields*."""
150
+ filled = {name: value for name, value in fields.items() if getattr(self, name) is None}
151
+ return replace(self, **filled) if filled else self
152
+
153
+ def to_dict(self) -> dict[str, Any]:
154
+ """Serialise for a describe payload or a report."""
155
+ return {
156
+ "kind": self.kind,
157
+ "position_key": self.position_key,
158
+ "target": self.target,
159
+ "relative": self.relative,
160
+ "step": self.step,
161
+ }
162
+
163
+ @classmethod
164
+ def from_options(cls, options: dict[str, Any] | None) -> "OpKind":
165
+ """Build from registration options or a schema entry (``kind``, ``position_key``, …).
166
+
167
+ Args:
168
+ options: A mapping that may carry the fields of this class. Unknown
169
+ keys are ignored; a missing ``kind`` is ``read``.
170
+ """
171
+ options = options or {}
172
+ return cls(
173
+ kind=str(options.get("kind") or "read"),
174
+ position_key=options.get("position_key"),
175
+ target=options.get("target"),
176
+ relative=bool(options.get("relative", False)),
177
+ step=options.get("step"),
178
+ )
179
+
180
+
181
+ def op_kind_of(func: Any) -> OpKind:
182
+ """Return the :class:`OpKind` a method was exposed with (``read`` when undeclared)."""
183
+ if isinstance(func, (staticmethod, classmethod)):
184
+ func = func.__func__
185
+ return getattr(func, OP_KIND_MARKER, None) or OpKind()
186
+
187
+
188
+ def expose_to_api(
189
+ func: Any = None,
190
+ *,
191
+ kind: str = "read",
192
+ position_key: str | None = None,
193
+ target: str | None = None,
194
+ relative: bool = False,
195
+ step: float | None = None,
196
+ ) -> Any:
197
+ """Mark a device method so it is listed by the describe endpoint.
198
+
199
+ Bare ``@expose_to_api`` marks a ``read``. With arguments it also declares
200
+ what the operation does, which the field test needs to call it unattended::
201
+
202
+ @expose_to_api(kind="motion", position_key="MO.Position")
203
+ def move_absolute(self, position: float) -> float: ...
204
+
205
+ @expose_to_api(kind="motion", position_key="MO.Position", relative=True)
206
+ def move_relative(self, step: float) -> float: ...
207
+
208
+ @expose_to_api(kind="lifecycle")
209
+ def home_stage(self) -> bool: ...
210
+
211
+ Args:
212
+ func: The method, when used without parentheses.
213
+ kind: One of :data:`OP_KINDS`.
214
+ position_key: Motion — the parameter that reports the moving part's position.
215
+ target: Motion — the argument taking the target; the first required one by default.
216
+ relative: Motion — the target is a step from the current position.
217
+ step: Motion — how far the field test may move, in ``position_key`` units.
218
+ """
219
+ op_kind = OpKind(
220
+ kind=kind, position_key=position_key, target=target, relative=relative, step=step
221
+ )
222
+
223
+ def decorate(method: Any) -> Any:
224
+ real = method.__func__ if isinstance(method, (staticmethod, classmethod)) else method
225
+ setattr(real, EXPOSE_MARKER, True)
226
+ setattr(real, OP_KIND_MARKER, op_kind)
227
+ return method
228
+
229
+ if func is not None:
230
+ return decorate(func)
231
+ return decorate
@@ -14,7 +14,7 @@ import re
14
14
  import reprlib
15
15
  import numpy as np
16
16
 
17
- from plesty.lib.device.device_utils import ResponseParser
17
+ from plesty.lib.device.device_utils import OpKind, ResponseParser, op_kind_of
18
18
  from plesty.lib.data import (
19
19
  PlestyArray,
20
20
  PlestyTable2D,
@@ -192,6 +192,9 @@ class FuncDoc:
192
192
  description: Human-readable description (from the schema or docstring).
193
193
  iparams: Input parameters.
194
194
  oparams: Output values.
195
+ kind: What the operation does to the world (see
196
+ :class:`~plesty.lib.device.device_utils.OpKind`); ``read`` when
197
+ the module declared nothing.
195
198
  """
196
199
 
197
200
  name: str
@@ -199,6 +202,7 @@ class FuncDoc:
199
202
  description: str
200
203
  iparams: list["FuncParam"]
201
204
  oparams: list["FuncOutput"]
205
+ kind: OpKind = field(default_factory=OpKind)
202
206
 
203
207
 
204
208
  #: Size-bounded repr for operation log lines, so array-valued parameters do
@@ -450,6 +454,7 @@ class FunctionSystem:
450
454
  description=str(meta.get("registration_options", {}).get("description", "")),
451
455
  iparams=list(meta["iparams"]),
452
456
  oparams=list(meta["oparams"]),
457
+ kind=OpKind.from_options(meta.get("registration_options")),
453
458
  )
454
459
  )
455
460
 
@@ -523,6 +528,7 @@ class FunctionSystem:
523
528
  description=docstring,
524
529
  iparams=iparams,
525
530
  oparams=oparams,
531
+ kind=op_kind_of(func),
526
532
  )
527
533
 
528
534
  def register_from_op_schema(
@@ -584,6 +590,13 @@ class FunctionSystem:
584
590
  # Marks an operation the on-hardware field test may call
585
591
  # unattended (see plesty.lib.test.field_test.FieldTestPipeline).
586
592
  options.setdefault("safe", bool(op_cfg["safe"]))
593
+ # What the operation does (OpKind): the field test drives a
594
+ # "motion" differently from a "read". Validated here so a typo in
595
+ # the schema fails at registration, not in a test on hardware.
596
+ for name in ("kind", "position_key", "target", "relative", "step"):
597
+ if name in op_cfg:
598
+ options.setdefault(name, op_cfg[name])
599
+ OpKind.from_options(options)
587
600
 
588
601
  self.register_func(
589
602
  op_name,
@@ -821,7 +834,10 @@ class FunctionSystem:
821
834
  "output": self._signature_output_docs(signature, default_type="Any"),
822
835
  "description": description,
823
836
  "parser": None,
824
- "registration_options": {"source": "expose-to-api"},
837
+ "registration_options": {
838
+ "source": "expose-to-api",
839
+ **op_kind_of(func).to_dict(),
840
+ },
825
841
  "runtime_request_shape": {"op": name, "params": "dict[str, Any]"},
826
842
  }
827
843
 
@@ -67,6 +67,7 @@ from pathlib import Path
67
67
  from collections.abc import Sequence
68
68
  from typing import Any
69
69
 
70
+ from plesty.lib.device.device_utils import OpKind
70
71
  from plesty.lib.test.report_artifact import (
71
72
  Coverage,
72
73
  Instrument,
@@ -148,6 +149,13 @@ class ClientFieldTest:
148
149
  safe_ops: Served functions the run may call. Nothing not named here is
149
150
  called: the client tier inherits the host tier's safety judgement
150
151
  rather than making a second one that could disagree with it.
152
+ op_kinds: What each operation does, per name, as the host tier was
153
+ told (:class:`~plesty.lib.device.device_utils.OpKind` or its
154
+ fields as a mapping). The client cannot read the module's
155
+ declaration through ``describe``'s flat method list, so the same
156
+ kinds are handed over: a ``motion`` is driven one ``step`` out
157
+ from what its ``position_key`` reports and back, a ``lifecycle``
158
+ once, a ``control`` once after the motions, a ``configure`` never.
151
159
  resources: Lockable resources to contend for. Two clients ask for
152
160
  these at once, and the second must be refused.
153
161
  expected_types: Parameter key to the type it is declared as. The
@@ -182,6 +190,7 @@ class ClientFieldTest:
182
190
  safe_ops: Sequence[str] | None = None,
183
191
  resources: Sequence[str] | None = None,
184
192
  expected_types: dict[str, type] | None = None,
193
+ op_kinds: dict[str, OpKind | dict[str, Any]] | None = None,
185
194
  fixed_threading: bool = True,
186
195
  label: str = "",
187
196
  instrument: Instrument | None = None,
@@ -200,6 +209,10 @@ class ClientFieldTest:
200
209
  self.address = address or f"tcp://127.0.0.1:{_free_port()}"
201
210
  self.params = list(params or [])
202
211
  self.safe_ops = list(safe_ops or [])
212
+ self.op_kinds: dict[str, OpKind] = {
213
+ name: kind if isinstance(kind, OpKind) else OpKind.from_options(dict(kind))
214
+ for name, kind in (op_kinds or {}).items()
215
+ }
203
216
  self.resources = list(resources or [])
204
217
  self._expected_types = dict(expected_types or {})
205
218
  self.fixed_threading = fixed_threading
@@ -451,23 +464,89 @@ class ClientFieldTest:
451
464
 
452
465
  started = time.perf_counter()
453
466
  problems: list[str] = []
454
- for name in self.safe_ops:
467
+ answered: dict[str, str] = {}
468
+ skipped: dict[str, str] = {}
469
+ kinds = {name: self.op_kinds.get(name, OpKind()) for name in self.safe_ops}
470
+ # Motions first, controls after — a stop follows something that moved.
471
+ ordered = sorted(self.safe_ops, key=lambda name: kinds[name].kind == "control")
472
+ moved = False
473
+ for name in ordered:
474
+ kind = kinds[name]
475
+ if kind.kind == "configure":
476
+ skipped[name] = "configure: changes a setting that outlives the call; never called"
477
+ continue
478
+ if kind.kind == "control" and not moved:
479
+ skipped[name] = "control: no motion operation is allow-listed to act on"
480
+ continue
455
481
  try:
456
- answer = self._timed_call(f"op:{name}", client.call, name)
457
- for _ in range(self._repeat(f"op:{name}") - 1):
458
- self._timed_call(f"op:{name}", client.call, name)
482
+ if kind.kind == "motion":
483
+ answer = self._motion_over_the_wire(client, name, kind)
484
+ moved = True
485
+ else:
486
+ answer = self._timed_call(f"op:{name}", client.call, name)
487
+ # A lifecycle or control operation is done once, whatever
488
+ # the host tier's count: it homes, resets or stops.
489
+ if kind.kind in ("read", "acquire"):
490
+ for _ in range(self._repeat(f"op:{name}") - 1):
491
+ self._timed_call(f"op:{name}", client.call, name)
459
492
  except Exception as exc: # noqa: BLE001 - the reason is the result
460
493
  problems.append(f"{name}: raised {type(exc).__name__}: {exc}")
461
494
  continue
462
- if not isinstance(answer, dict):
463
- problems.append(f"{name}: answered {type(answer).__name__}, not a dict")
464
- else:
465
- self._called.add(name)
495
+ # A call that came back is the whole claim of this tier: the
496
+ # transport carried the request and its answer. What the answer
497
+ # is shaped like is the host tier's business — it demands a dict
498
+ # of a schema operation and nothing of an @expose_to_api method,
499
+ # and describe() flattens both into one list, so demanding a
500
+ # dict here failed acquire (None) and get_recent_file (str) on a
501
+ # module whose experiment consumes exactly those (lib#38).
502
+ answered[name] = type(answer).__name__
503
+ self._called.add(name)
466
504
 
467
505
  elapsed = time.perf_counter() - started
468
506
  if problems:
469
507
  return GateResult("functions", FAIL, elapsed, error="; ".join(problems))
470
- return GateResult("functions", PASS, elapsed, evidence={"called": sorted(self._called)})
508
+ evidence: dict[str, Any] = {"called": sorted(self._called), "answered": answered}
509
+ if skipped:
510
+ evidence["skipped"] = skipped
511
+ return GateResult("functions", PASS, elapsed, evidence=evidence)
512
+
513
+ def _motion_over_the_wire(self, client: Any, name: str, kind: OpKind) -> Any:
514
+ """Drive a motion one step out and back, as many times as the host tier did.
515
+
516
+ The host tier's count for a motion is out-and-back pairs, so half of
517
+ it is the number of pairs to make here; at least one.
518
+
519
+ Args:
520
+ client: A connected client.
521
+ name: The motion operation.
522
+ kind: Its declaration — ``position_key``, ``target`` and ``step``
523
+ must all be known.
524
+
525
+ Returns:
526
+ The answer to the last call.
527
+
528
+ Raises:
529
+ ValueError: If the declaration is incomplete.
530
+ """
531
+ if kind.position_key is None or kind.target is None or kind.step is None:
532
+ missing = [f for f in ("position_key", "target", "step") if getattr(kind, f) is None]
533
+ raise ValueError(
534
+ f"motion operation '{name}' declares no {'/'.join(missing)};"
535
+ " the client tier will not guess how far a part may move."
536
+ )
537
+ start = float(
538
+ self._timed_call(f"query:{kind.position_key}", client.query, kind.position_key)
539
+ )
540
+ if kind.relative:
541
+ out, back = {kind.target: kind.step}, {kind.target: -kind.step}
542
+ else:
543
+ out, back = {kind.target: start + kind.step}, {kind.target: start}
544
+ pairs = max(1, self._repeat(f"op:{name}") // 2)
545
+ answer: Any = None
546
+ for _ in range(pairs):
547
+ self._timed_call(f"op:{name}", lambda: client.call(name, **out))
548
+ answer = self._timed_call(f"op:{name}", lambda: client.call(name, **back))
549
+ return answer
471
550
 
472
551
  def test_session_survives_a_bad_result(self, client: Any) -> GateResult:
473
552
  """A call whose result JSON cannot carry must not end the session.