testerkit 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (521) hide show
  1. testerkit/__init__.py +68 -0
  2. testerkit/_docs/_assets/operator-ui/channels/table.png +0 -0
  3. testerkit/_docs/_assets/operator-ui/dashboard/runs.png +0 -0
  4. testerkit/_docs/_assets/operator-ui/dashboard/stations.png +0 -0
  5. testerkit/_docs/_assets/operator-ui/events/filters.png +0 -0
  6. testerkit/_docs/_assets/operator-ui/events/table.png +0 -0
  7. testerkit/_docs/_assets/operator-ui/explore/chart.png +0 -0
  8. testerkit/_docs/_assets/operator-ui/explore/filters.png +0 -0
  9. testerkit/_docs/_assets/operator-ui/explore/plot-controls.png +0 -0
  10. testerkit/_docs/_assets/operator-ui/files/table.png +0 -0
  11. testerkit/_docs/_assets/operator-ui/launch/form.png +0 -0
  12. testerkit/_docs/_assets/operator-ui/metrics/filters.png +0 -0
  13. testerkit/_docs/_assets/operator-ui/metrics/pareto.png +0 -0
  14. testerkit/_docs/_assets/operator-ui/metrics/ppk.png +0 -0
  15. testerkit/_docs/_assets/operator-ui/metrics/yield.png +0 -0
  16. testerkit/_docs/_assets/operator-ui/results/detail-header.png +0 -0
  17. testerkit/_docs/_assets/operator-ui/results/detail-measurements.png +0 -0
  18. testerkit/_docs/_assets/operator-ui/results/detail-overview.png +0 -0
  19. testerkit/_docs/_assets/operator-ui/results/detail-steps.png +0 -0
  20. testerkit/_docs/_assets/operator-ui/results/stats.png +0 -0
  21. testerkit/_docs/_assets/operator-ui/results/table.png +0 -0
  22. testerkit/_docs/_assets/operator-ui/tour/designer.png +0 -0
  23. testerkit/_docs/_assets/operator-ui/tour/docs.png +0 -0
  24. testerkit/_docs/_assets/operator-ui/tour/fixtures.png +0 -0
  25. testerkit/_docs/_assets/operator-ui/tour/instruments.png +0 -0
  26. testerkit/_docs/_assets/operator-ui/tour/parts.png +0 -0
  27. testerkit/_docs/_assets/operator-ui/tour/profiles.png +0 -0
  28. testerkit/_docs/_assets/operator-ui/tour/stations.png +0 -0
  29. testerkit/_docs/_assets/operator-ui/tour/tests.png +0 -0
  30. testerkit/_docs/_assets/operator-ui/tour/uuts.png +0 -0
  31. testerkit/_docs/concepts/configuration/capabilities.md +447 -0
  32. testerkit/_docs/concepts/configuration/fixtures.md +335 -0
  33. testerkit/_docs/concepts/configuration/index.md +14 -0
  34. testerkit/_docs/concepts/configuration/parts.md +276 -0
  35. testerkit/_docs/concepts/configuration/stations.md +214 -0
  36. testerkit/_docs/concepts/data/data-stores.md +199 -0
  37. testerkit/_docs/concepts/data/event-log.md +185 -0
  38. testerkit/_docs/concepts/data/event-sourcing.md +76 -0
  39. testerkit/_docs/concepts/data/flight-streaming.md +58 -0
  40. testerkit/_docs/concepts/data/index.md +17 -0
  41. testerkit/_docs/concepts/data/sessions.md +78 -0
  42. testerkit/_docs/concepts/data/three-verbs.md +260 -0
  43. testerkit/_docs/concepts/execution/index.md +13 -0
  44. testerkit/_docs/concepts/execution/outcomes.md +199 -0
  45. testerkit/_docs/concepts/execution/step-hierarchy.md +170 -0
  46. testerkit/_docs/concepts/execution/step-manifest.md +115 -0
  47. testerkit/_docs/concepts/index.md +40 -0
  48. testerkit/_docs/concepts/overview/ai-integration.md +57 -0
  49. testerkit/_docs/concepts/overview/architecture.md +357 -0
  50. testerkit/_docs/concepts/overview/index.md +14 -0
  51. testerkit/_docs/concepts/overview/platform-vs-framework.md +147 -0
  52. testerkit/_docs/concepts/overview/pytest.md +53 -0
  53. testerkit/_docs/concepts/overview/tiers.md +46 -0
  54. testerkit/_docs/how-to/catalog/datasheet-to-test.md +169 -0
  55. testerkit/_docs/how-to/catalog/index.md +11 -0
  56. testerkit/_docs/how-to/configuration/configuring-stations.md +364 -0
  57. testerkit/_docs/how-to/configuration/custom-drivers.md +484 -0
  58. testerkit/_docs/how-to/configuration/index.md +13 -0
  59. testerkit/_docs/how-to/configuration/mock-mode.md +342 -0
  60. testerkit/_docs/how-to/data/benchmarking.md +120 -0
  61. testerkit/_docs/how-to/data/capture-an-artifact.md +84 -0
  62. testerkit/_docs/how-to/data/capture-waveform.md +110 -0
  63. testerkit/_docs/how-to/data/choosing-a-channel-verb.md +108 -0
  64. testerkit/_docs/how-to/data/compare-runs.md +120 -0
  65. testerkit/_docs/how-to/data/export-results.md +104 -0
  66. testerkit/_docs/how-to/data/find-flaky-tests.md +108 -0
  67. testerkit/_docs/how-to/data/grafana-dashboards.md +148 -0
  68. testerkit/_docs/how-to/data/index.md +23 -0
  69. testerkit/_docs/how-to/data/mcp-debug-failures.md +125 -0
  70. testerkit/_docs/how-to/data/mcp-query-runs.md +120 -0
  71. testerkit/_docs/how-to/data/querying-channels.md +117 -0
  72. testerkit/_docs/how-to/data/querying-events.md +85 -0
  73. testerkit/_docs/how-to/data/stream-live-channel.md +134 -0
  74. testerkit/_docs/how-to/execution/custom-operator-ui.md +136 -0
  75. testerkit/_docs/how-to/execution/index.md +21 -0
  76. testerkit/_docs/how-to/execution/limits.md +193 -0
  77. testerkit/_docs/how-to/execution/managing-sessions.md +100 -0
  78. testerkit/_docs/how-to/execution/multi-uut-testing.md +211 -0
  79. testerkit/_docs/how-to/execution/operator-prompts.md +151 -0
  80. testerkit/_docs/how-to/execution/profiles.md +304 -0
  81. testerkit/_docs/how-to/execution/spec-driven-testing.md +169 -0
  82. testerkit/_docs/how-to/execution/test-context.md +212 -0
  83. testerkit/_docs/how-to/execution/traceability.md +252 -0
  84. testerkit/_docs/how-to/execution/vector-expansion.md +394 -0
  85. testerkit/_docs/how-to/execution/writing-tests.md +363 -0
  86. testerkit/_docs/how-to/index.md +59 -0
  87. testerkit/_docs/how-to/overview/index.md +11 -0
  88. testerkit/_docs/how-to/overview/mcp-integration.md +168 -0
  89. testerkit/_docs/how-to/overview/operator-ui-tour.md +234 -0
  90. testerkit/_docs/integration/data/grafana.md +162 -0
  91. testerkit/_docs/integration/data/index.md +15 -0
  92. testerkit/_docs/integration/data/lakehouse-import.md +226 -0
  93. testerkit/_docs/integration/data/logging.md +131 -0
  94. testerkit/_docs/integration/data/results-api.md +51 -0
  95. testerkit/_docs/integration/index.md +20 -0
  96. testerkit/_docs/integration/runtime/harness.md +281 -0
  97. testerkit/_docs/integration/runtime/index.md +13 -0
  98. testerkit/_docs/integration/runtime/instruments.md +284 -0
  99. testerkit/_docs/integration/runtime/pytest-existing.md +315 -0
  100. testerkit/_docs/reference/catalog/cookbook.md +389 -0
  101. testerkit/_docs/reference/catalog/index.md +11 -0
  102. testerkit/_docs/reference/catalog/schema.md +375 -0
  103. testerkit/_docs/reference/cli.md +660 -0
  104. testerkit/_docs/reference/configuration.md +406 -0
  105. testerkit/_docs/reference/data/channels-schema.md +145 -0
  106. testerkit/_docs/reference/data/event-types.md +554 -0
  107. testerkit/_docs/reference/data/events-schema.md +120 -0
  108. testerkit/_docs/reference/data/files-schema.md +141 -0
  109. testerkit/_docs/reference/data/index.md +18 -0
  110. testerkit/_docs/reference/data/models.md +2229 -0
  111. testerkit/_docs/reference/data/outputs.md +82 -0
  112. testerkit/_docs/reference/data/parquet-schema.md +529 -0
  113. testerkit/_docs/reference/data/performance-limits.md +224 -0
  114. testerkit/_docs/reference/data/query-api.md +247 -0
  115. testerkit/_docs/reference/index.md +69 -0
  116. testerkit/_docs/reference/operator-ui/channels/detail.md +82 -0
  117. testerkit/_docs/reference/operator-ui/channels/list.md +70 -0
  118. testerkit/_docs/reference/operator-ui/dashboard.md +87 -0
  119. testerkit/_docs/reference/operator-ui/designer.md +167 -0
  120. testerkit/_docs/reference/operator-ui/events.md +89 -0
  121. testerkit/_docs/reference/operator-ui/files.md +74 -0
  122. testerkit/_docs/reference/operator-ui/fixtures.md +100 -0
  123. testerkit/_docs/reference/operator-ui/instruments.md +126 -0
  124. testerkit/_docs/reference/operator-ui/launch.md +79 -0
  125. testerkit/_docs/reference/operator-ui/live.md +102 -0
  126. testerkit/_docs/reference/operator-ui/measurements.md +159 -0
  127. testerkit/_docs/reference/operator-ui/metrics.md +217 -0
  128. testerkit/_docs/reference/operator-ui/parts.md +98 -0
  129. testerkit/_docs/reference/operator-ui/profiles.md +103 -0
  130. testerkit/_docs/reference/operator-ui/results/detail.md +214 -0
  131. testerkit/_docs/reference/operator-ui/results/list.md +106 -0
  132. testerkit/_docs/reference/operator-ui/stations.md +98 -0
  133. testerkit/_docs/reference/operator-ui/tests.md +173 -0
  134. testerkit/_docs/reference/operator-ui/uuts.md +67 -0
  135. testerkit/_docs/reference/overview/index.md +11 -0
  136. testerkit/_docs/reference/overview/pytest-native.md +142 -0
  137. testerkit/_docs/reference/overview/skills.md +177 -0
  138. testerkit/_docs/reference/pytest/fixtures.md +342 -0
  139. testerkit/_docs/reference/pytest/index.md +12 -0
  140. testerkit/_docs/reference/pytest/markers.md +257 -0
  141. testerkit/_docs/reference/runtime/api.md +246 -0
  142. testerkit/_docs/reference/runtime/client.md +280 -0
  143. testerkit/_docs/reference/runtime/connect.md +140 -0
  144. testerkit/_docs/reference/runtime/index.md +13 -0
  145. testerkit/_docs/tutorial/01-first-test.md +113 -0
  146. testerkit/_docs/tutorial/02-mock-instruments.md +88 -0
  147. testerkit/_docs/tutorial/03-fixtures.md +169 -0
  148. testerkit/_docs/tutorial/04-limits.md +139 -0
  149. testerkit/_docs/tutorial/05-configuration.md +194 -0
  150. testerkit/_docs/tutorial/06-specifications.md +273 -0
  151. testerkit/_docs/tutorial/07-real-instruments.md +245 -0
  152. testerkit/_docs/tutorial/08-capabilities.md +223 -0
  153. testerkit/_docs/tutorial/09-production.md +374 -0
  154. testerkit/_docs/tutorial/10-live-monitoring.md +156 -0
  155. testerkit/_docs/tutorial/11-waveforms-and-evidence.md +169 -0
  156. testerkit/_docs/tutorial/12-continuous-monitoring.md +145 -0
  157. testerkit/_docs/tutorial/13-parallel-testing.md +233 -0
  158. testerkit/_docs/tutorial/index.md +61 -0
  159. testerkit/_docs/tutorial/quickstart.md +282 -0
  160. testerkit/analysis/__init__.py +9 -0
  161. testerkit/analysis/_common.py +24 -0
  162. testerkit/analysis/measurement_facets.py +524 -0
  163. testerkit/analysis/measurements_query.py +1458 -0
  164. testerkit/analysis/metrics.py +389 -0
  165. testerkit/analysis/runs_query.py +434 -0
  166. testerkit/analysis/steps_query.py +413 -0
  167. testerkit/api/__init__.py +6 -0
  168. testerkit/api/_mime.py +71 -0
  169. testerkit/api/app.py +1171 -0
  170. testerkit/api/dialogs/__init__.py +36 -0
  171. testerkit/api/dialogs/manager.py +617 -0
  172. testerkit/api/dialogs/models.py +79 -0
  173. testerkit/api/models.py +80 -0
  174. testerkit/api/responses.py +205 -0
  175. testerkit/api/runner.py +222 -0
  176. testerkit/api/schemas.py +311 -0
  177. testerkit/benchmark/__init__.py +34 -0
  178. testerkit/benchmark/concurrency.py +248 -0
  179. testerkit/benchmark/core.py +232 -0
  180. testerkit/benchmark/runner.py +628 -0
  181. testerkit/benchmark/scenario.py +313 -0
  182. testerkit/benchmark/system.py +191 -0
  183. testerkit/benchmark/workloads.py +507 -0
  184. testerkit/catalog/__init__.py +6 -0
  185. testerkit/catalog/generic/generic_dmm.yaml +61 -0
  186. testerkit/catalog/generic/generic_eload.yaml +31 -0
  187. testerkit/catalog/generic/generic_fgen.yaml +56 -0
  188. testerkit/catalog/generic/generic_oscilloscope.yaml +45 -0
  189. testerkit/catalog/generic/generic_psu.yaml +77 -0
  190. testerkit/catalog/generic/generic_smu.yaml +73 -0
  191. testerkit/channels.py +538 -0
  192. testerkit/cli/__init__.py +32 -0
  193. testerkit/cli/_common.py +113 -0
  194. testerkit/cli/_time.py +179 -0
  195. testerkit/cli/benchmark_cmd.py +56 -0
  196. testerkit/cli/catalog_cmd.py +39 -0
  197. testerkit/cli/daemon.py +157 -0
  198. testerkit/cli/data_cmd.py +1040 -0
  199. testerkit/cli/discover_cmd.py +94 -0
  200. testerkit/cli/docs_cmd.py +100 -0
  201. testerkit/cli/instrument.py +172 -0
  202. testerkit/cli/mcp_cmd.py +37 -0
  203. testerkit/cli/metrics.py +366 -0
  204. testerkit/cli/project.py +312 -0
  205. testerkit/cli/root.py +14 -0
  206. testerkit/cli/runs.py +412 -0
  207. testerkit/cli/schema_cmd.py +87 -0
  208. testerkit/cli/serve_cmd.py +50 -0
  209. testerkit/cli/setup_cmd.py +604 -0
  210. testerkit/cli/station.py +294 -0
  211. testerkit/cli/validate.py +112 -0
  212. testerkit/client.py +463 -0
  213. testerkit/connect.py +635 -0
  214. testerkit/data/__init__.py +8 -0
  215. testerkit/data/_accumulator_pool.py +455 -0
  216. testerkit/data/_atomic.py +59 -0
  217. testerkit/data/_collection_indices.py +67 -0
  218. testerkit/data/_daemon_lifecycle.py +535 -0
  219. testerkit/data/_duckdb_daemon.py +695 -0
  220. testerkit/data/_duckdb_flight_server.py +813 -0
  221. testerkit/data/_event_filters.py +15 -0
  222. testerkit/data/_event_reader.py +101 -0
  223. testerkit/data/_flight_errors.py +199 -0
  224. testerkit/data/_flight_query.py +202 -0
  225. testerkit/data/_flight_retry.py +148 -0
  226. testerkit/data/_flight_subscribe.py +67 -0
  227. testerkit/data/_index_epoch.py +342 -0
  228. testerkit/data/_ipc_writer.py +185 -0
  229. testerkit/data/_json_safe.py +63 -0
  230. testerkit/data/_process.py +18 -0
  231. testerkit/data/_push_relay.py +124 -0
  232. testerkit/data/_runs_duckdb_daemon.py +2704 -0
  233. testerkit/data/_session_reaper.py +110 -0
  234. testerkit/data/_sql_helpers.py +30 -0
  235. testerkit/data/_store.py +24 -0
  236. testerkit/data/backends/__init__.py +7 -0
  237. testerkit/data/backends/_event_accumulator.py +957 -0
  238. testerkit/data/backends/_row_helpers.py +1172 -0
  239. testerkit/data/backends/parquet.py +1143 -0
  240. testerkit/data/backends/protocol.py +35 -0
  241. testerkit/data/channels/__init__.py +8 -0
  242. testerkit/data/channels/_flight_daemon.py +75 -0
  243. testerkit/data/channels/client.py +242 -0
  244. testerkit/data/channels/flight_manager.py +88 -0
  245. testerkit/data/channels/index.py +664 -0
  246. testerkit/data/channels/models.py +418 -0
  247. testerkit/data/channels/server.py +162 -0
  248. testerkit/data/channels/store.py +1481 -0
  249. testerkit/data/data_dir.py +61 -0
  250. testerkit/data/duckdb_manager.py +79 -0
  251. testerkit/data/event_log.py +384 -0
  252. testerkit/data/event_store.py +768 -0
  253. testerkit/data/events.py +1025 -0
  254. testerkit/data/exporters/__init__.py +6 -0
  255. testerkit/data/exporters/_helpers.py +40 -0
  256. testerkit/data/exporters/csv_exporter.py +168 -0
  257. testerkit/data/exporters/hdf5.py +234 -0
  258. testerkit/data/exporters/json_exporter.py +229 -0
  259. testerkit/data/exporters/mdf4.py +201 -0
  260. testerkit/data/exporters/stdf.py +255 -0
  261. testerkit/data/exporters/tdms.py +292 -0
  262. testerkit/data/files/__init__.py +71 -0
  263. testerkit/data/files/_backend.py +207 -0
  264. testerkit/data/files/_catalog_daemon.py +84 -0
  265. testerkit/data/files/catalog.py +218 -0
  266. testerkit/data/files/catalog_manager.py +325 -0
  267. testerkit/data/files/models.py +60 -0
  268. testerkit/data/files/serializers.py +414 -0
  269. testerkit/data/files/store.py +463 -0
  270. testerkit/data/files/streaming.py +783 -0
  271. testerkit/data/models.py +604 -0
  272. testerkit/data/ref.py +152 -0
  273. testerkit/data/retention.py +297 -0
  274. testerkit/data/run_store.py +367 -0
  275. testerkit/data/runs_duckdb_manager.py +103 -0
  276. testerkit/data/schema_dispatch.py +191 -0
  277. testerkit/data/schema_migrate.py +76 -0
  278. testerkit/data/schema_versions.py +85 -0
  279. testerkit/data/schemas.py +288 -0
  280. testerkit/data/subscribers/__init__.py +48 -0
  281. testerkit/data/subscribers/_base.py +20 -0
  282. testerkit/data/subscribers/_output_file.py +25 -0
  283. testerkit/data/subscribers/replay.py +55 -0
  284. testerkit/environment.py +84 -0
  285. testerkit/execution/__init__.py +11 -0
  286. testerkit/execution/_git.py +190 -0
  287. testerkit/execution/_state.py +740 -0
  288. testerkit/execution/accessors.py +54 -0
  289. testerkit/execution/audit.py +51 -0
  290. testerkit/execution/cascade.py +85 -0
  291. testerkit/execution/connections.py +491 -0
  292. testerkit/execution/harness.py +1931 -0
  293. testerkit/execution/instrument_events.py +42 -0
  294. testerkit/execution/limits.py +154 -0
  295. testerkit/execution/metadata.py +102 -0
  296. testerkit/execution/mocks.py +63 -0
  297. testerkit/execution/profiles.py +616 -0
  298. testerkit/execution/run_scope.py +1331 -0
  299. testerkit/execution/session_scope.py +196 -0
  300. testerkit/execution/sidecar.py +280 -0
  301. testerkit/execution/site_runner.py +705 -0
  302. testerkit/execution/sites.py +183 -0
  303. testerkit/execution/sync.py +294 -0
  304. testerkit/execution/uut_provider.py +231 -0
  305. testerkit/execution/vectors.py +205 -0
  306. testerkit/execution/verify.py +396 -0
  307. testerkit/expand.py +75 -0
  308. testerkit/files.py +235 -0
  309. testerkit/fixtures/__init__.py +6 -0
  310. testerkit/fixtures/manager.py +357 -0
  311. testerkit/grafana/__init__.py +1 -0
  312. testerkit/grafana/bootstrap.py +61 -0
  313. testerkit/grafana/cli.py +314 -0
  314. testerkit/grafana/dashboards/asset_utilization.json +92 -0
  315. testerkit/grafana/dashboards/channel_explorer.json +77 -0
  316. testerkit/grafana/dashboards/event_log.json +92 -0
  317. testerkit/grafana/dashboards/failure_pareto.json +104 -0
  318. testerkit/grafana/dashboards/measurement_distribution.json +97 -0
  319. testerkit/grafana/dashboards/measurement_trend.json +161 -0
  320. testerkit/grafana/dashboards/station_comparison.json +95 -0
  321. testerkit/grafana/dashboards/test_duration.json +104 -0
  322. testerkit/grafana/dashboards/unit_traceability.json +73 -0
  323. testerkit/grafana/dashboards/yield_overview.json +189 -0
  324. testerkit/grafana/provisioning/dashboards.yaml.j2 +12 -0
  325. testerkit/grafana/provisioning/datasources.yaml.j2 +17 -0
  326. testerkit/grafana/server.py +282 -0
  327. testerkit/init.py +724 -0
  328. testerkit/instruments/__init__.py +10 -0
  329. testerkit/instruments/base.py +82 -0
  330. testerkit/instruments/discovery/__init__.py +62 -0
  331. testerkit/instruments/discovery/_base.py +174 -0
  332. testerkit/instruments/discovery/lxi.py +152 -0
  333. testerkit/instruments/discovery/ni.py +95 -0
  334. testerkit/instruments/discovery/serial.py +73 -0
  335. testerkit/instruments/discovery/visa.py +98 -0
  336. testerkit/instruments/lifecycle.py +213 -0
  337. testerkit/instruments/loader.py +79 -0
  338. testerkit/instruments/locks.py +178 -0
  339. testerkit/instruments/mocks.py +230 -0
  340. testerkit/instruments/observer.py +270 -0
  341. testerkit/instruments/observers/__init__.py +36 -0
  342. testerkit/instruments/observers/_base.py +68 -0
  343. testerkit/instruments/observers/daqmx.py +70 -0
  344. testerkit/instruments/observers/descriptor.py +69 -0
  345. testerkit/instruments/observers/generic.py +92 -0
  346. testerkit/instruments/observers/lantz.py +31 -0
  347. testerkit/instruments/observers/modbus.py +96 -0
  348. testerkit/instruments/observers/motion.py +68 -0
  349. testerkit/instruments/observers/ni_modular.py +73 -0
  350. testerkit/instruments/observers/ophyd.py +80 -0
  351. testerkit/instruments/observers/pymeasure.py +120 -0
  352. testerkit/instruments/observers/qcodes.py +66 -0
  353. testerkit/instruments/observers/scpi.py +88 -0
  354. testerkit/instruments/observers/tektronix.py +57 -0
  355. testerkit/instruments/observers/visa.py +103 -0
  356. testerkit/instruments/pool.py +459 -0
  357. testerkit/instruments/proxy.py +59 -0
  358. testerkit/instruments/route_manager.py +329 -0
  359. testerkit/instruments/routed_proxy.py +88 -0
  360. testerkit/instruments/server.py +508 -0
  361. testerkit/instruments/switch.py +43 -0
  362. testerkit/instruments/visa.py +294 -0
  363. testerkit/matching/__init__.py +6 -0
  364. testerkit/matching/service.py +969 -0
  365. testerkit/mcp/__init__.py +6 -0
  366. testerkit/mcp/server.py +681 -0
  367. testerkit/mcp/tools.py +1868 -0
  368. testerkit/models/__init__.py +18 -0
  369. testerkit/models/capability.py +598 -0
  370. testerkit/models/catalog.py +76 -0
  371. testerkit/models/data_options.py +153 -0
  372. testerkit/models/enums.py +334 -0
  373. testerkit/models/instrument.py +152 -0
  374. testerkit/models/instrument_asset.py +25 -0
  375. testerkit/models/part.py +271 -0
  376. testerkit/models/part_manifest.py +96 -0
  377. testerkit/models/project.py +114 -0
  378. testerkit/models/station.py +140 -0
  379. testerkit/models/test_config.py +748 -0
  380. testerkit/ontology/__init__.py +39 -0
  381. testerkit/ontology/schema.py +188 -0
  382. testerkit/ontology/testerkit.yaml +1420 -0
  383. testerkit/parts/__init__.py +8 -0
  384. testerkit/parts/context.py +181 -0
  385. testerkit/parts/folder.py +251 -0
  386. testerkit/parts/loader.py +27 -0
  387. testerkit/prompts/__init__.py +17 -0
  388. testerkit/prompts/core.py +133 -0
  389. testerkit/pytest_plugin/__init__.py +1287 -0
  390. testerkit/pytest_plugin/autouse.py +369 -0
  391. testerkit/pytest_plugin/helpers.py +354 -0
  392. testerkit/pytest_plugin/hooks.py +1982 -0
  393. testerkit/pytest_plugin/markers.py +203 -0
  394. testerkit/pytest_plugin/retry.py +37 -0
  395. testerkit/pytest_plugin/sweeps.py +117 -0
  396. testerkit/queries.py +78 -0
  397. testerkit/reports/__init__.py +6 -0
  398. testerkit/reports/core.py +492 -0
  399. testerkit/reports/datasheet.py +831 -0
  400. testerkit/reports/templates/datasheet.html +383 -0
  401. testerkit/reports/templates/default.html +153 -0
  402. testerkit/sbom.py +142 -0
  403. testerkit/schema_export.py +101 -0
  404. testerkit/signals.py +63 -0
  405. testerkit/skills/README.md +58 -0
  406. testerkit/skills/templates/project-instructions.md +125 -0
  407. testerkit/skills/testerkit-analysis/SKILL.md +87 -0
  408. testerkit/skills/testerkit-capture/SKILL.md +114 -0
  409. testerkit/skills/testerkit-data/SKILL.md +138 -0
  410. testerkit/skills/testerkit-datasheets/SKILL.md +100 -0
  411. testerkit/skills/testerkit-datasheets/agents/scaffold-writer.md +115 -0
  412. testerkit/skills/testerkit-datasheets/agents/section-extractor.md +98 -0
  413. testerkit/skills/testerkit-datasheets/agents/section-reviewer.md +153 -0
  414. testerkit/skills/testerkit-datasheets/agents/section-splitter.md +89 -0
  415. testerkit/skills/testerkit-datasheets/agents/section-writer.md +226 -0
  416. testerkit/skills/testerkit-datasheets/references/catalog-pipeline.md +271 -0
  417. testerkit/skills/testerkit-datasheets/references/process-queue.md +42 -0
  418. testerkit/skills/testerkit-datasheets/references/scaffold.md +156 -0
  419. testerkit/skills/testerkit-datasheets/references/test-pipeline.md +312 -0
  420. testerkit/skills/testerkit-debug/SKILL.md +127 -0
  421. testerkit/skills/testerkit-interactive/SKILL.md +188 -0
  422. testerkit/skills/testerkit-interactive/references/live-ui-patterns.md +102 -0
  423. testerkit/skills/testerkit-mocks/SKILL.md +128 -0
  424. testerkit/skills/testerkit-parts/SKILL.md +126 -0
  425. testerkit/skills/testerkit-profiles/SKILL.md +139 -0
  426. testerkit/skills/testerkit-sites/SKILL.md +140 -0
  427. testerkit/skills/testerkit-stations/SKILL.md +146 -0
  428. testerkit/skills/testerkit-tests/SKILL.md +136 -0
  429. testerkit/store.py +1639 -0
  430. testerkit/ui/__init__.py +71 -0
  431. testerkit/ui/_asgi.py +162 -0
  432. testerkit/ui/app.py +32 -0
  433. testerkit/ui/components/__init__.py +13 -0
  434. testerkit/ui/components/artifact_viewer.py +250 -0
  435. testerkit/ui/components/channel_values.py +159 -0
  436. testerkit/ui/components/event_timeline.py +222 -0
  437. testerkit/ui/components/execution_gantt.py +210 -0
  438. testerkit/ui/components/file_streams.py +147 -0
  439. testerkit/ui/components/instrument_activity.py +146 -0
  440. testerkit/ui/components/session_table.py +118 -0
  441. testerkit/ui/pages/__init__.py +25 -0
  442. testerkit/ui/pages/channels/__init__.py +4 -0
  443. testerkit/ui/pages/channels/detail.py +932 -0
  444. testerkit/ui/pages/channels/list.py +515 -0
  445. testerkit/ui/pages/dashboard.py +236 -0
  446. testerkit/ui/pages/designer/__init__.py +5 -0
  447. testerkit/ui/pages/designer/graph.py +635 -0
  448. testerkit/ui/pages/designer/matching.py +663 -0
  449. testerkit/ui/pages/designer/page.py +593 -0
  450. testerkit/ui/pages/designer/properties.py +591 -0
  451. testerkit/ui/pages/designer/state.py +468 -0
  452. testerkit/ui/pages/docs/__init__.py +6 -0
  453. testerkit/ui/pages/docs/index.py +95 -0
  454. testerkit/ui/pages/docs/page.py +698 -0
  455. testerkit/ui/pages/events/__init__.py +3 -0
  456. testerkit/ui/pages/events/list.py +263 -0
  457. testerkit/ui/pages/explore.py +1108 -0
  458. testerkit/ui/pages/files/__init__.py +4 -0
  459. testerkit/ui/pages/files/detail.py +426 -0
  460. testerkit/ui/pages/files/list.py +447 -0
  461. testerkit/ui/pages/fixtures/__init__.py +8 -0
  462. testerkit/ui/pages/fixtures/detail.py +280 -0
  463. testerkit/ui/pages/fixtures/edit.py +370 -0
  464. testerkit/ui/pages/fixtures/list.py +180 -0
  465. testerkit/ui/pages/fixtures/new.py +195 -0
  466. testerkit/ui/pages/instruments/__init__.py +4 -0
  467. testerkit/ui/pages/instruments/detail.py +320 -0
  468. testerkit/ui/pages/instruments/edit.py +482 -0
  469. testerkit/ui/pages/instruments/list.py +249 -0
  470. testerkit/ui/pages/instruments/new.py +200 -0
  471. testerkit/ui/pages/launch.py +261 -0
  472. testerkit/ui/pages/live.py +124 -0
  473. testerkit/ui/pages/metrics_page.py +1229 -0
  474. testerkit/ui/pages/parts/__init__.py +11 -0
  475. testerkit/ui/pages/parts/detail.py +202 -0
  476. testerkit/ui/pages/parts/edit.py +310 -0
  477. testerkit/ui/pages/parts/list.py +160 -0
  478. testerkit/ui/pages/parts/new.py +170 -0
  479. testerkit/ui/pages/parts/requirements.py +153 -0
  480. testerkit/ui/pages/parts/stations.py +199 -0
  481. testerkit/ui/pages/profiles/__init__.py +4 -0
  482. testerkit/ui/pages/profiles/detail.py +113 -0
  483. testerkit/ui/pages/profiles/list.py +86 -0
  484. testerkit/ui/pages/results/__init__.py +4 -0
  485. testerkit/ui/pages/results/detail.py +631 -0
  486. testerkit/ui/pages/results/list.py +230 -0
  487. testerkit/ui/pages/stations/__init__.py +4 -0
  488. testerkit/ui/pages/stations/detail.py +256 -0
  489. testerkit/ui/pages/stations/edit.py +273 -0
  490. testerkit/ui/pages/stations/list.py +180 -0
  491. testerkit/ui/pages/stations/new.py +311 -0
  492. testerkit/ui/pages/tests/__init__.py +4 -0
  493. testerkit/ui/pages/tests/detail.py +152 -0
  494. testerkit/ui/pages/tests/list.py +194 -0
  495. testerkit/ui/pages/uuts/__init__.py +4 -0
  496. testerkit/ui/pages/uuts/list.py +97 -0
  497. testerkit/ui/shared/__init__.py +8 -0
  498. testerkit/ui/shared/components.py +1826 -0
  499. testerkit/ui/shared/dialogs.py +105 -0
  500. testerkit/ui/shared/event_binding.py +194 -0
  501. testerkit/ui/shared/layout.py +314 -0
  502. testerkit/ui/shared/services.py +1719 -0
  503. testerkit/ui/shared/timestamps.py +21 -0
  504. testerkit/ui/static/branding/favicon-16.png +0 -0
  505. testerkit/ui/static/branding/favicon-32.png +0 -0
  506. testerkit/ui/static/branding/favicon-48.png +0 -0
  507. testerkit/ui/static/branding/testerkit-icon-256.png +0 -0
  508. testerkit/ui/static/branding/testerkit-mark.svg +1 -0
  509. testerkit/ui/static/branding/testerkit-wordmark.svg +1 -0
  510. testerkit/ui/static/global.css +465 -0
  511. testerkit/utils/__init__.py +17 -0
  512. testerkit/utils/enum_meta.py +800 -0
  513. testerkit/utils/paths.py +75 -0
  514. testerkit/utils/ranges.py +257 -0
  515. testerkit/validation.py +82 -0
  516. testerkit/verbs.py +142 -0
  517. testerkit-0.4.0.dist-info/METADATA +250 -0
  518. testerkit-0.4.0.dist-info/RECORD +521 -0
  519. testerkit-0.4.0.dist-info/WHEEL +4 -0
  520. testerkit-0.4.0.dist-info/entry_points.txt +5 -0
  521. testerkit-0.4.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,71 @@
1
+ """Custom-UI-builder surface for TesterKit.
2
+
3
+ If you're building a custom NiceGUI page that sits alongside the
4
+ built-in operator pages — a project-specific dashboard, a custom
5
+ fixture-bringup panel, a measurement-comparison view — reach for
6
+ the helpers re-exported here instead of the deep
7
+ ``testerkit.ui.shared.*`` paths.
8
+
9
+ Two groups of helpers:
10
+
11
+ **Layout / table primitives** — match the look-and-feel of the
12
+ built-in pages so your custom panel doesn't visually drift::
13
+
14
+ from testerkit.ui import page_layout, page_header, data_table, format_datetime
15
+
16
+ @ui.page("/my-bringup")
17
+ def my_panel():
18
+ with page_layout():
19
+ page_header("Bringup")
20
+ data_table(columns=[...], rows=[...], row_key="id")
21
+
22
+ **Live event bindings** — subscribe to the event store / channel
23
+ store so your page updates push-style as new samples arrive::
24
+
25
+ from testerkit.ui import subscribe, channel_data, bind_channel_store
26
+
27
+ @ui.page("/live-temp")
28
+ def temp_panel():
29
+ data = channel_data("oven.temp_c")
30
+ ui.label().bind_text_from(data, "latest", lambda v: f"{v:.1f} °C")
31
+
32
+ The deep ``testerkit.ui.shared.components`` and
33
+ ``testerkit.ui.shared.event_binding`` paths still work — they're the
34
+ contributor form. User code should use this module.
35
+
36
+ Operator pages themselves (``/results``, ``/metrics``, etc.) are
37
+ registered via ``testerkit.ui.pages.*`` on package import and don't
38
+ need to be touched by custom-UI builders.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from testerkit.ui.shared.components import (
44
+ data_table,
45
+ format_datetime,
46
+ info_field,
47
+ page_header,
48
+ page_layout,
49
+ push_url_state,
50
+ )
51
+ from testerkit.ui.shared.event_binding import (
52
+ bind_channel_store,
53
+ )
54
+ from testerkit.ui.shared.event_binding import (
55
+ ui_channel_data as channel_data,
56
+ )
57
+ from testerkit.ui.shared.event_binding import (
58
+ ui_subscribe as subscribe,
59
+ )
60
+
61
+ __all__ = [
62
+ "bind_channel_store",
63
+ "channel_data",
64
+ "data_table",
65
+ "format_datetime",
66
+ "info_field",
67
+ "page_header",
68
+ "page_layout",
69
+ "push_url_state",
70
+ "subscribe",
71
+ ]
testerkit/ui/_asgi.py ADDED
@@ -0,0 +1,162 @@
1
+ """ASGI app entry point for uvicorn auto-reload support.
2
+
3
+ When ``testerkit serve --reload`` is used, uvicorn watches for file changes and
4
+ restarts the worker process. On each restart the worker re-imports this module
5
+ which:
6
+
7
+ 1. Imports ``nicegui`` (creating a fresh ``core.app``).
8
+ 2. Imports all UI pages (``@ui.page`` decorators register routes on ``core.app``).
9
+ 3. Adds the FastAPI API router.
10
+ 4. Calls ``add_run_config()`` so the NiceGUI lifespan check passes.
11
+ 5. Adds standard middleware that ``ui.run()`` normally applies.
12
+
13
+ The exported ``app`` is the NiceGUI ASGI application ready for uvicorn.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+
22
+
23
+ def _log(msg: str) -> None:
24
+ sys.stderr.write(msg + "\n")
25
+ sys.stderr.flush()
26
+
27
+
28
+ _start = time.perf_counter()
29
+ _log(f"[ASGI] Starting reload at {time.strftime('%H:%M:%S')}")
30
+
31
+ from fastapi.middleware.gzip import GZipMiddleware # noqa: E402
32
+
33
+ _log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - fastapi imported")
34
+
35
+ from nicegui import core # noqa: E402
36
+
37
+ _log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - nicegui.core imported")
38
+
39
+ from nicegui.middlewares import RedirectWithPrefixMiddleware, SetCacheControlMiddleware # noqa: E402, I001
40
+
41
+ _log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - middlewares imported")
42
+
43
+ # Register UI pages on core.app (side-effect imports)
44
+ import testerkit.ui.app # noqa: F401, E402 # pyright: ignore[reportUnusedImport]
45
+
46
+ _log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - testerkit.ui.app imported")
47
+
48
+ from testerkit.api.app import create_api_router # noqa: E402
49
+
50
+ _log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - api router imported")
51
+
52
+ # Add API routes
53
+ core.app.include_router(create_api_router())
54
+
55
+ # Configure NiceGUI the same way ui.run() would
56
+ if not core.app.config.has_run_config:
57
+ core.app.config.add_run_config(
58
+ reload=True,
59
+ title="TesterKit",
60
+ viewport="width=device-width, initial-scale=1",
61
+ favicon=Path(__file__).resolve().parent / "static" / "branding" / "favicon-32.png",
62
+ dark=False,
63
+ language="en-US",
64
+ binding_refresh_interval=0.1,
65
+ reconnect_timeout=3.0,
66
+ message_history_length=1000,
67
+ tailwind=True,
68
+ prod_js=True,
69
+ show_welcome_message=True,
70
+ )
71
+
72
+ # Middleware that ui.run() normally adds
73
+ core.app.add_middleware(GZipMiddleware)
74
+ core.app.add_middleware(RedirectWithPrefixMiddleware)
75
+ core.app.add_middleware(SetCacheControlMiddleware)
76
+
77
+ app = core.app
78
+
79
+
80
+ def _install_global_exception_handler() -> None:
81
+ """Toast-on-error so a buggy page never takes the server down.
82
+
83
+ NicegUI dispatches event handler exceptions through
84
+ ``app.on_exception``; without a handler they bubble to the
85
+ ASGI worker as 500s and (occasionally) escape to the
86
+ terminal as tracebacks. Either way, a single bad lambda in
87
+ one page should not break navigation to other pages.
88
+
89
+ The handler:
90
+
91
+ * Logs the full traceback to stderr (operators / devs see it).
92
+ * Posts a small ``ui.notify`` "negative" toast to the affected
93
+ browser session — same UX as NiceGUI's built-in
94
+ "Connection lost" banner, just for application errors.
95
+
96
+ The toast itself is best-effort: ``ui.notify`` requires an
97
+ active client context. If the exception fires outside a page
98
+ handler (e.g. background thread, timer callback), the notify
99
+ silently no-ops and we just log.
100
+ """
101
+ import logging
102
+ import traceback
103
+
104
+ from nicegui import app as nicegui_app
105
+ from nicegui import ui
106
+
107
+ logger = logging.getLogger("testerkit.ui")
108
+
109
+ def _on_exception(exc: Exception | None = None) -> None:
110
+ # ``exc`` may be None depending on dispatcher; pull from
111
+ # ``sys.exc_info`` as a fallback so we always have *something*
112
+ # to log.
113
+ if exc is None:
114
+ import sys
115
+
116
+ _, exc_val, _ = sys.exc_info()
117
+ exc = exc_val if isinstance(exc_val, Exception) else None
118
+ if exc is not None:
119
+ logger.error(
120
+ "UI handler raised %s: %s\n%s",
121
+ type(exc).__name__,
122
+ exc,
123
+ "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
124
+ )
125
+ try:
126
+ label = type(exc).__name__ if exc is not None else "Error"
127
+ ui.notify(
128
+ f"Page error: {label} — see server log for details",
129
+ type="negative",
130
+ position="bottom-right",
131
+ timeout=5000,
132
+ )
133
+ except Exception: # noqa: BLE001 — toast is best-effort outside client ctx
134
+ pass
135
+
136
+ nicegui_app.on_exception(_on_exception)
137
+
138
+
139
+ _install_global_exception_handler()
140
+
141
+
142
+ def _hold_serve_level_daemon_refs() -> None:
143
+ """Delegate to :func:`testerkit.api.app.warm_data_daemons` — the same call the
144
+ default ``create_app()`` entrypoint makes — so ``testerkit serve --reload`` and
145
+ plain ``testerkit serve`` acquire the daemons and establish the channels
146
+ Flight→UI bridge identically (#59-3). See that function for the rationale.
147
+ """
148
+ from testerkit.api.app import warm_data_daemons
149
+
150
+ try:
151
+ warm_data_daemons()
152
+ except Exception as exc: # noqa: BLE001 — best-effort eager acquire
153
+ _log(f"[ASGI] warm_data_daemons failed: {exc}")
154
+
155
+
156
+ if __name__ != "__mp_main__":
157
+ # Only hold daemon refs in the supervisor process, not in every
158
+ # reload worker. Workers are spawned fresh on each code change;
159
+ # acquiring daemons in each worker would re-ingest all parquets
160
+ # on every reload cycle.
161
+ _hold_serve_level_daemon_refs()
162
+ _log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - READY")
testerkit/ui/app.py ADDED
@@ -0,0 +1,32 @@
1
+ """NiceGUI-based operator UI.
2
+
3
+ This module initializes the NiceGUI app and imports all page modules
4
+ which register their routes via @ui.page decorators.
5
+ """
6
+
7
+ import importlib.resources
8
+ from pathlib import Path
9
+
10
+ from nicegui import app
11
+
12
+ # Serve static files
13
+ _static_dir = Path(__file__).parent / "static"
14
+ app.add_static_files("/static", _static_dir)
15
+
16
+ # Docs assets (cropped operator-UI screenshots etc.) are referenced from
17
+ # markdown like ``![alt](../../_assets/...)`` which the browser resolves
18
+ # to ``/docs/_assets/...``. Mounting the directory here means those
19
+ # requests are served as PNGs instead of falling through to the docs
20
+ # catch-all route (which would render an HTML page-not-found body and
21
+ # break image loading). Match the wheel/editable resolution that
22
+ # docs/page.py uses for ``DOCS_DIR``.
23
+ _pkg_root = Path(str(importlib.resources.files("testerkit")))
24
+ _bundled_docs = _pkg_root / "_docs"
25
+ _docs_root = _bundled_docs if _bundled_docs.exists() else _pkg_root.parent.parent / "docs"
26
+ _docs_assets_dir = _docs_root / "_assets"
27
+ if _docs_assets_dir.exists():
28
+ app.add_static_files("/docs/_assets", _docs_assets_dir)
29
+
30
+ # Import pages module which imports all page submodules and registers routes
31
+ # This must happen after the app is initialized
32
+ from testerkit.ui import pages # noqa: F401, E402
@@ -0,0 +1,13 @@
1
+ """Reusable UI components for TesterKit live pages."""
2
+
3
+ from testerkit.ui.components.channel_values import create_channel_values_panel
4
+ from testerkit.ui.components.event_timeline import create_event_timeline
5
+ from testerkit.ui.components.instrument_activity import create_instrument_activity
6
+ from testerkit.ui.components.session_table import create_session_table
7
+
8
+ __all__ = [
9
+ "create_channel_values_panel",
10
+ "create_event_timeline",
11
+ "create_instrument_activity",
12
+ "create_session_table",
13
+ ]
@@ -0,0 +1,250 @@
1
+ """View buttons + dialogs for measurement-output ref artifacts.
2
+
3
+ Walks each measurement's ``outputs`` dict; for any value that is a
4
+ ``file://_ref/...`` or ``channel://...`` URI, renders a "View ..."
5
+ button. Clicking opens a NiceGUI dialog with an extension-driven
6
+ viewer:
7
+
8
+ * ``.npz`` (Waveform) — Python-side fetch of the JSON payload,
9
+ rendered as an ECharts line plot.
10
+ * image / video / PDF / SVG / HTML / text / JSON — embed the API
11
+ URL directly in ``<img>`` / ``<video>`` / ``<iframe>``. The
12
+ ``/api/runs/{id}/ref`` endpoint serves the right ``Content-Type``
13
+ (image/png, video/mp4, application/pdf, text/plain, …) so the
14
+ browser renders inline without us doing any conversion.
15
+ * anything else — a download link.
16
+
17
+ The endpoint already deduplicates the heavy lifting; this module is
18
+ intentionally thin glue.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import urllib.parse
25
+ from typing import Any
26
+
27
+ from nicegui import ui
28
+
29
+ from testerkit.ui.shared.services import load_artifact_ref
30
+
31
+ # Map from URI extension to ("button label", "viewer kind").
32
+ #
33
+ # `.bin` is the catch-all the write path uses for any ``bytes`` payload
34
+ # whose original MIME type is unknown (operator screenshots, binary
35
+ # captures, …). The endpoint sniffs magic bytes server-side and serves
36
+ # the right ``Content-Type``; the UI just iframes the URL and lets the
37
+ # browser pick a renderer (``image/*`` → image, ``video/*`` → video,
38
+ # ``application/pdf`` → PDF reader, ``text/*`` → text). Same trick for
39
+ # explicit known extensions where the format is unambiguous.
40
+ _VIEWER_BY_EXT: dict[str, tuple[str, str]] = {
41
+ ".npz": ("View Waveform", "waveform"),
42
+ ".png": ("View Image", "image"),
43
+ ".jpg": ("View Image", "image"),
44
+ ".jpeg": ("View Image", "image"),
45
+ ".gif": ("View Image", "image"),
46
+ ".webp": ("View Image", "image"),
47
+ ".svg": ("View Image", "image"),
48
+ ".mp4": ("View Video", "video"),
49
+ ".webm": ("View Video", "video"),
50
+ ".pdf": ("View PDF", "pdf"),
51
+ ".txt": ("View Text", "text"),
52
+ ".log": ("View Text", "text"),
53
+ ".html": ("View HTML", "html"),
54
+ ".htm": ("View HTML", "html"),
55
+ ".json": ("View Data", "json"),
56
+ # ``.bin`` covers raw bytes that the server sniffs into the right
57
+ # Content-Type at request time. We don't know the precise type until
58
+ # the user clicks, so use a generic label and a universal iframe.
59
+ ".bin": ("View Artifact", "iframe"),
60
+ }
61
+
62
+
63
+ def list_artifacts(measurement: dict[str, Any]) -> list[tuple[str, str]]:
64
+ """Return ``[(name, uri)]`` for every ref-typed output on a measurement row.
65
+
66
+ Reads from the ``outputs`` dict (bare names) produced by
67
+ ``RunStore.get_measurements``.
68
+ """
69
+ refs: list[tuple[str, str]] = []
70
+ for name, value in (measurement.get("outputs") or {}).items():
71
+ if isinstance(value, str) and value.startswith(("file://", "channel://")):
72
+ refs.append((name, value))
73
+ return refs
74
+
75
+
76
+ def _viewer_for_uri(uri: str) -> tuple[str, str]:
77
+ """Return ``(label, kind)`` for *uri* based on its filename extension."""
78
+ lowered = uri.lower().split("?", 1)[0]
79
+ for ext, label_kind in _VIEWER_BY_EXT.items():
80
+ if lowered.endswith(ext):
81
+ return label_kind
82
+ return "Download", "download"
83
+
84
+
85
+ def _api_url(run_id: str, uri: str) -> str:
86
+ return f"/api/runs/{run_id}/ref?uri={urllib.parse.quote(uri, safe='')}"
87
+
88
+
89
+ def render_artifact_buttons(run_id: str, measurement: dict[str, Any]) -> None:
90
+ """Render one View / Download button per artifact on a measurement row.
91
+
92
+ Caller decides where in the layout to drop these (typically a row
93
+ under the measurements table or inline alongside the value).
94
+ """
95
+ artifacts = list_artifacts(measurement)
96
+ if not artifacts:
97
+ return
98
+
99
+ step = measurement.get("step_name", "")
100
+ name = measurement.get("measurement_name", "")
101
+ label_prefix = f"{step}.{name}" if step else name
102
+
103
+ with ui.row().classes("items-center gap-2 flex-wrap"):
104
+ ui.label(f"{label_prefix}:").classes("text-sm font-medium text-slate-700")
105
+ for output_key, uri in artifacts:
106
+ label, kind = _viewer_for_uri(uri)
107
+ api_url = _api_url(run_id, uri)
108
+ button_text = f"{label} ({output_key})"
109
+ if kind == "download":
110
+ ui.link(button_text, api_url, new_tab=True).classes(
111
+ "text-blue-600 hover:underline text-sm"
112
+ )
113
+ continue
114
+ ui.button(
115
+ button_text,
116
+ on_click=_make_open_dialog(run_id, output_key, uri, api_url, kind),
117
+ ).props("dense flat color=primary")
118
+
119
+
120
+ def _make_open_dialog(run_id: str, output_key: str, uri: str, api_url: str, kind: str):
121
+ """Closure factory binding loop-iteration values to the click handler."""
122
+ return lambda: _open_dialog(run_id, output_key, uri, api_url, kind)
123
+
124
+
125
+ def _open_dialog(run_id: str, output_key: str, uri: str, api_url: str, kind: str) -> None:
126
+ """Pop a dialog containing the appropriate viewer for *kind*."""
127
+ with ui.dialog() as dialog, ui.card().classes("p-4 w-[min(900px,90vw)]"):
128
+ ui.label(output_key).classes("text-lg font-semibold mb-2")
129
+ _render_viewer_body(run_id, uri, api_url, kind)
130
+ with ui.row().classes("w-full justify-end mt-2"):
131
+ ui.button("Close", on_click=dialog.close).props("flat")
132
+ dialog.open()
133
+
134
+
135
+ def _render_viewer_body(run_id: str, uri: str, api_url: str, kind: str) -> None:
136
+ """Pick the viewer body for *kind*.
137
+
138
+ Browser-rendered viewers (image / video / pdf / iframe) embed the
139
+ HTTP API URL directly — the browser fetches and renders. JSON-shaped
140
+ payloads (waveform / text / dict) skip the HTTP round-trip and call
141
+ ``load_ref`` directly: the UI runs in the same Python process as
142
+ the API, so we don't need to go through the network.
143
+ """
144
+ if kind == "waveform":
145
+ _render_waveform(run_id, uri)
146
+ elif kind == "image":
147
+ ui.image(api_url).classes("w-full")
148
+ elif kind == "video":
149
+ ui.video(api_url, controls=True).classes("w-full")
150
+ elif kind == "pdf":
151
+ _iframe(api_url, sandbox=False, border=False)
152
+ elif kind == "html":
153
+ _iframe(api_url, sandbox=True, border=True)
154
+ elif kind == "text":
155
+ _render_text_or_json(run_id, uri, pretty=False)
156
+ elif kind == "json":
157
+ _render_text_or_json(run_id, uri, pretty=True)
158
+ elif kind == "iframe":
159
+ # Universal: server's magic-byte sniff sets Content-Type; the
160
+ # browser picks a renderer (image / video / pdf / text / download).
161
+ # No sandbox — Chrome blocks the PDF plugin in sandboxed iframes,
162
+ # and the content comes from the user's own results dir (same-origin,
163
+ # no untrusted scripts).
164
+ _iframe(api_url, sandbox=False, border=True)
165
+ else:
166
+ ui.link("Download", api_url, new_tab=True)
167
+
168
+
169
+ def _iframe(api_url: str, *, sandbox: bool, border: bool) -> None:
170
+ sandbox_attr = "sandbox " if sandbox else ""
171
+ border_style = "1px solid #ccc" if border else "0"
172
+ ui.html(
173
+ f'<iframe src="{api_url}" {sandbox_attr}'
174
+ f'style="width:100%;height:70vh;border:{border_style}"></iframe>',
175
+ sanitize=False,
176
+ )
177
+
178
+
179
+ def _render_waveform(run_id: str, uri: str) -> None:
180
+ """Materialize the Waveform in-process and render an ECharts line plot."""
181
+ try:
182
+ wfm = load_artifact_ref(run_id, uri)
183
+ except (FileNotFoundError, OSError, ValueError) as exc:
184
+ ui.label(f"Failed to load waveform: {exc}").classes("text-red-600")
185
+ return
186
+
187
+ if not hasattr(wfm, "Y"):
188
+ ui.label(f"Expected a Waveform, got {type(wfm).__name__}").classes("text-red-600")
189
+ return
190
+
191
+ Y = list(wfm.Y)
192
+ t0 = float(getattr(wfm, "t0", 0.0))
193
+ dt = float(getattr(wfm, "dt", 1.0)) or 1.0
194
+ x_axis = [t0 + i * dt for i in range(len(Y))]
195
+ attrs = getattr(wfm, "attrs", {}) or {}
196
+ unit = attrs.get("unit")
197
+ y_label = f"value ({unit})" if unit else "value"
198
+
199
+ ui.echart(
200
+ {
201
+ "tooltip": {"trigger": "axis"},
202
+ "xAxis": {
203
+ "type": "category",
204
+ "data": [f"{v:.4g}" for v in x_axis],
205
+ "name": "time (s)",
206
+ "nameLocation": "middle",
207
+ "nameGap": 30,
208
+ },
209
+ "yAxis": {"type": "value", "name": y_label},
210
+ "series": [
211
+ {
212
+ "type": "line",
213
+ "data": Y,
214
+ "showSymbol": False,
215
+ "smooth": False,
216
+ }
217
+ ],
218
+ "grid": {"left": 60, "right": 30, "top": 30, "bottom": 50},
219
+ }
220
+ ).classes("w-full h-96")
221
+
222
+
223
+ def _render_text_or_json(run_id: str, uri: str, *, pretty: bool) -> None:
224
+ """Materialize text/JSON in-process and embed in a ``<pre>`` block."""
225
+ try:
226
+ payload = load_artifact_ref(run_id, uri)
227
+ except (FileNotFoundError, OSError, ValueError) as exc:
228
+ ui.label(f"Failed to load: {exc}").classes("text-red-600")
229
+ return
230
+
231
+ if pretty and isinstance(payload, dict):
232
+ content = json.dumps(payload, indent=2, default=str)
233
+ elif isinstance(payload, bytes):
234
+ try:
235
+ content = payload.decode("utf-8")
236
+ except UnicodeDecodeError:
237
+ content = repr(payload)
238
+ elif isinstance(payload, dict):
239
+ content = json.dumps(payload, default=str)
240
+ else:
241
+ content = str(payload)
242
+
243
+ # NiceGUI's ui.html escapes nothing; build a safe pre block manually.
244
+ import html as _html
245
+
246
+ ui.html(
247
+ f'<pre class="text-xs whitespace-pre-wrap break-all" '
248
+ f'style="max-height:60vh;overflow:auto">{_html.escape(content)}</pre>',
249
+ sanitize=False,
250
+ )
@@ -0,0 +1,159 @@
1
+ """Live channel values table — Position 2 reshape.
2
+
3
+ Subscribes to :class:`~testerkit.data.events.ChannelStarted` for discovery
4
+ (one row per channel that ever wrote in this run) and to the per-channel
5
+ ChannelStore Flight signal for live sample values.
6
+
7
+ Follows the live-UI rule (``docs/_internal/explorations/live-ui-pattern.md``):
8
+ the sample callback writes only a plain per-row holder; a single
9
+ ``ui.timer`` is the sole renderer. Discovery (adding a row) and close
10
+ (marking a row) are structural and run on the UI loop via
11
+ :func:`ui_subscribe`. So nothing off the UI loop touches an element, and
12
+ the per-sample path never mutates UI directly.
13
+
14
+ This is the canonical operator view: a stable list of channels with their
15
+ latest reading, no per-sample event flood.
16
+
17
+ Push-based: no polling for data — the timer only paints what already arrived.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from uuid import UUID
25
+
26
+ from nicegui import ui
27
+
28
+ from testerkit.data.channels.models import ChannelSample
29
+ from testerkit.data.event_store import EventStore
30
+ from testerkit.ui.shared.event_binding import ui_channel_data, ui_subscribe
31
+ from testerkit.ui.shared.timestamps import format_time_short
32
+
33
+
34
+ @dataclass
35
+ class _ChannelRow:
36
+ """Per-channel row: label handles + the latest reading the timer paints."""
37
+
38
+ val_lbl: ui.label
39
+ unit_lbl: ui.label
40
+ ts_lbl: ui.label
41
+ value: object = None
42
+ unit: str = ""
43
+ ts: str = ""
44
+ closed: bool = False
45
+ dirty: bool = True
46
+
47
+
48
+ def create_channel_values_panel(
49
+ store: EventStore,
50
+ *,
51
+ run_id: UUID | None = None,
52
+ ) -> tuple[ui.column, Callable[[], None]]:
53
+ """Auto-discover channels and show a live-values table.
54
+
55
+ ``run_id`` filters the ``ChannelStarted`` discovery subscription to the
56
+ matching run — operators on ``/live/{run_id}`` only see channels from
57
+ the run they're looking at. ``None`` shows everything.
58
+
59
+ Live sample values come from :func:`ui_channel_data` (wired by the
60
+ application's :func:`bind_channel_store` at startup). The sample
61
+ callback only records the reading; a ``ui.timer`` paints it.
62
+
63
+ Returns ``(container, unsubscribe)`` so the caller can stop discovery
64
+ updates on page teardown.
65
+ """
66
+ rows: dict[str, _ChannelRow] = {}
67
+
68
+ container = ui.column().classes("w-full gap-2")
69
+
70
+ with container:
71
+ placeholder = ui.label("No channels yet").classes("text-sm text-slate-400 italic")
72
+ # Table header (hidden until first data)
73
+ header = ui.row().classes("w-full px-3 py-1 border-b border-slate-200 hidden")
74
+ with header:
75
+ ui.label("Channel").classes("w-1/3 text-xs font-semibold text-slate-500")
76
+ ui.label("Value").classes("w-1/4 text-xs font-semibold text-slate-500")
77
+ ui.label("Units").classes("w-1/6 text-xs font-semibold text-slate-500")
78
+ ui.label("Last Update").classes("w-1/4 text-xs font-semibold text-slate-500")
79
+ rows_container = ui.column().classes("w-full gap-0")
80
+
81
+ placeholder_removed = False
82
+
83
+ def _format_value(value: object) -> str:
84
+ if isinstance(value, (int, float)):
85
+ return f"{value:.4g}"
86
+ if isinstance(value, list) and value and isinstance(value[0], (int, float)):
87
+ # Array sample — show length, not the whole array
88
+ return f"[{len(value)} samples]"
89
+ return "—" if value is None else str(value)
90
+
91
+ def _on_channel_started(evt: dict) -> None:
92
+ """Discovery: add a row + a sample subscription that fills its holder."""
93
+ nonlocal placeholder_removed
94
+
95
+ ch_id = evt.get("channel_id")
96
+ if not ch_id or ch_id in rows:
97
+ return
98
+
99
+ if not placeholder_removed:
100
+ placeholder_removed = True
101
+ placeholder.delete()
102
+ header.classes(remove="hidden")
103
+
104
+ with rows_container:
105
+ with ui.row().classes("w-full px-3 py-1.5 border-b border-slate-100 items-center"):
106
+ ui.label(ch_id).classes("w-1/3 text-sm font-mono text-slate-700")
107
+ val_lbl = ui.label("—").classes("w-1/4 text-sm font-mono font-semibold")
108
+ unit_lbl = ui.label(evt.get("unit") or "").classes("w-1/6 text-xs text-slate-500")
109
+ ts_lbl = ui.label("").classes("w-1/4 text-xs text-slate-400")
110
+
111
+ row = _ChannelRow(val_lbl, unit_lbl, ts_lbl, unit=evt.get("unit") or "")
112
+ rows[ch_id] = row
113
+
114
+ # Sample callback: record the reading only — no UI mutation here.
115
+ def _on_sample(sample: ChannelSample, _row: _ChannelRow = row) -> None:
116
+ _row.value = sample.value
117
+ if sample.unit:
118
+ _row.unit = sample.unit
119
+ _row.ts = format_time_short(sample.received_at.isoformat())
120
+ _row.dirty = True
121
+
122
+ ui_channel_data(ch_id).subscribe(_on_sample)
123
+
124
+ def _on_channel_closed(evt: dict) -> None:
125
+ """Lifecycle close — mark the row so the timer italicizes it."""
126
+ ch_id = evt.get("channel_id")
127
+ row = rows.get(ch_id) if ch_id else None
128
+ if row is not None:
129
+ row.closed = True
130
+ row.dirty = True
131
+
132
+ def _render() -> None:
133
+ """Sole renderer: paint the rows whose holders changed."""
134
+ for row in rows.values():
135
+ if not row.dirty:
136
+ continue
137
+ row.dirty = False
138
+ row.val_lbl.set_text(_format_value(row.value))
139
+ row.unit_lbl.set_text(row.unit)
140
+ if row.closed:
141
+ row.ts_lbl.classes(add="italic")
142
+ row.ts_lbl.set_text(f"closed · {row.ts}" if row.ts else "closed")
143
+ else:
144
+ row.ts_lbl.set_text(row.ts)
145
+
146
+ ui.timer(0.25, _render)
147
+
148
+ unsub_started = ui_subscribe(
149
+ store, _on_channel_started, event_type="channel.started", run_id=run_id
150
+ )
151
+ unsub_closed = ui_subscribe(
152
+ store, _on_channel_closed, event_type="channel.ended", run_id=run_id
153
+ )
154
+
155
+ def unsubscribe() -> None:
156
+ unsub_started()
157
+ unsub_closed()
158
+
159
+ return container, unsubscribe