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
testerkit/api/app.py ADDED
@@ -0,0 +1,1171 @@
1
+ """FastAPI + NiceGUI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Iterator
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from uuid import UUID
10
+
11
+ from fastapi import APIRouter, HTTPException, Request
12
+ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
13
+ from fastapi.openapi.utils import get_openapi
14
+ from fastapi.responses import HTMLResponse, ORJSONResponse, Response, StreamingResponse
15
+ from pydantic import BaseModel
16
+
17
+ from testerkit import __version__ as _testerkit_version_str
18
+ from testerkit.api._mime import sniff_mime
19
+ from testerkit.api.dialogs.models import Dialog, DialogResponse
20
+ from testerkit.api.models import (
21
+ DialogCreate,
22
+ DialogRespondRequest,
23
+ LaunchRequest,
24
+ RunStatus,
25
+ SaveRequest,
26
+ )
27
+ from testerkit.api.responses import (
28
+ ActiveRunsResponse,
29
+ DialogCreateResponse,
30
+ DialogRespondAck,
31
+ DialogsListResponse,
32
+ GenericObjectResponse,
33
+ InstrumentAssetsResponse,
34
+ InstrumentTypesResponse,
35
+ MatchAllResponse,
36
+ MatchSingleResponse,
37
+ MeasurementsListResponse,
38
+ MetricsResponse,
39
+ PartRequirementsResponse,
40
+ PartsListResponse,
41
+ RunLaunchResponse,
42
+ RunsListResponse,
43
+ StationCapabilitiesResponse,
44
+ StationsListResponse,
45
+ StepsListResponse,
46
+ StepsTreeResponse,
47
+ )
48
+ from testerkit.api.schemas import CapabilitySummary, RequirementSummary, RunView, load_run_view
49
+ from testerkit.data.backends.parquet import ParquetBackend, is_file_reference, load_ref
50
+ from testerkit.data.data_dir import resolve_data_dir
51
+ from testerkit.data.models import Waveform
52
+ from testerkit.models.catalog import InstrumentCatalogEntry
53
+ from testerkit.models.instrument_asset import InstrumentAssetFile
54
+ from testerkit.models.part import Part
55
+ from testerkit.models.station import StationConfig
56
+
57
+ # Chunk size for streaming a file artifact body out of the blob backend —
58
+ # big enough to amortize per-read overhead, small enough to bound memory.
59
+ _FILE_STREAM_CHUNK = 1 << 20 # 1 MiB
60
+
61
+
62
+ def warm_data_daemons() -> None:
63
+ """Eagerly acquire the data daemons and bridge live channel data into the UI.
64
+
65
+ Both ``testerkit serve`` (via :func:`create_app`) and ``testerkit serve
66
+ --reload`` (via ``testerkit.ui._asgi``) call this once at startup so the
67
+ two entrypoints establish an identical set of daemon refs and the
68
+ same cross-process channels bridge. Historically only the
69
+ ``--reload`` path did this (``_hold_serve_level_daemon_refs`` in
70
+ ``_asgi.py``); the default ``testerkit serve`` path went through
71
+ ``create_app()`` alone and never bridged the channels daemon's
72
+ Flight stream into NiceGUI's ``ui_channel_data`` signals, so
73
+ cross-process channel samples (e.g. a producer subprocess streaming
74
+ to a station's channels) never reached the channel-detail page
75
+ (#59-3).
76
+
77
+ Acquiring here at module-load time (rather than lazily on first
78
+ query) spawns all four daemons (runs, events, channels, files)
79
+ eagerly and registers an ``atexit`` + SIGTERM handler that releases
80
+ them on shutdown. While ``serve`` is alive, refs stay > 0, so
81
+ per-page idle-shutdown never fires for the duration of the UI
82
+ session — a first visit to any page feels the same as any other.
83
+
84
+ Idempotent: ``bind_flight_location`` no-ops on a location it has
85
+ already bound, and each daemon's ``acquire()`` is itself
86
+ ref-counted. Safe to call more than once (e.g. if both entrypoints
87
+ end up calling it, or from tests).
88
+ """
89
+ import logging
90
+
91
+ from testerkit.data import runs_duckdb_manager as _runs_mgr
92
+ from testerkit.data.channels import flight_manager as _channels_mgr
93
+ from testerkit.data.event_store import EventStore
94
+ from testerkit.data.files import catalog_manager as _files_mgr
95
+
96
+ logger = logging.getLogger("testerkit.api.app")
97
+
98
+ results = Path(resolve_data_dir())
99
+ runs_dir = results / "runs"
100
+ events_dir = results / "events"
101
+ channels_dir = results / "channels"
102
+ files_dir = results / "files"
103
+ runs_dir.mkdir(parents=True, exist_ok=True)
104
+ events_dir.mkdir(parents=True, exist_ok=True)
105
+ channels_dir.mkdir(parents=True, exist_ok=True)
106
+ files_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ try:
109
+ _runs_mgr.acquire(runs_dir)
110
+ except Exception: # noqa: BLE001 — best-effort eager acquire
111
+ logger.exception("runs daemon eager acquire failed")
112
+ try:
113
+ # Warm the events daemon through the EventStore seam (not the raw
114
+ # duckdb_manager): get_shared() spawns the daemon AND caches the
115
+ # thread-flat shared instance the API's read paths reuse.
116
+ EventStore.get_shared(results)
117
+ except Exception: # noqa: BLE001
118
+ logger.exception("events daemon eager acquire failed")
119
+ channels_location: str | None = None
120
+ try:
121
+ channels_location = _channels_mgr.acquire(channels_dir)
122
+ except Exception: # noqa: BLE001
123
+ logger.exception("channels daemon eager acquire failed")
124
+ try:
125
+ _files_mgr.acquire(files_dir)
126
+ except Exception: # noqa: BLE001
127
+ logger.exception("files catalog daemon eager acquire failed")
128
+
129
+ # Bridge the channels daemon's Flight server into NiceGUI Event
130
+ # signals so live-channel pages (channel detail chart, /live
131
+ # channel-values panel) receive samples push-style. Without this
132
+ # the per-channel ``ui_channel_data(ch_id)`` signal never fires
133
+ # from cross-process activity.
134
+ if channels_location:
135
+ try:
136
+ from testerkit.ui.shared.event_binding import bind_flight_location
137
+
138
+ bind_flight_location(channels_location)
139
+ except Exception: # noqa: BLE001
140
+ logger.exception("channels Flight bridge failed")
141
+
142
+
143
+ def _serialize_ref(result: object) -> Response | dict:
144
+ """Pick a wire format for a materialized ``load_ref`` return value.
145
+
146
+ Browser-renderable bytes pass through with a magic-byte-sniffed
147
+ ``Content-Type``; typed values (``Waveform``, Pydantic, ``dict``,
148
+ ``ndarray``, ``pa.Table``) come back as JSON. Anything else is a
149
+ 415 — we have nothing useful to give the client.
150
+ """
151
+ if isinstance(result, bytes):
152
+ return Response(content=result, media_type=sniff_mime(result[:64]))
153
+ if isinstance(result, Waveform):
154
+ return result.model_dump()
155
+ if isinstance(result, BaseModel):
156
+ return result.model_dump()
157
+ if isinstance(result, dict):
158
+ return result
159
+
160
+ type_name = type(result).__name__
161
+ if type_name == "ndarray": # numpy import is heavy; duck-type instead
162
+ return {
163
+ "shape": list(getattr(result, "shape", ())),
164
+ "dtype": str(getattr(result, "dtype", "")),
165
+ "data": result.tolist(), # type: ignore[attr-defined]
166
+ }
167
+ if type_name == "Table": # pyarrow.Table — same reasoning
168
+ return {"data": result.to_pylist()} # type: ignore[attr-defined]
169
+
170
+ raise HTTPException(
171
+ status_code=415,
172
+ detail=f"Cannot serialize ref payload of type {type_name!r}",
173
+ )
174
+
175
+
176
+ def _parse_uuid(value: str) -> UUID:
177
+ """Parse a UUID string, raising HTTPException on malformed input.
178
+
179
+ 400 (malformed) is distinct from 404 (well-formed but unknown id) —
180
+ callers raise 404 themselves after a successful parse.
181
+ """
182
+ try:
183
+ return UUID(value)
184
+ except ValueError as exc:
185
+ raise HTTPException(status_code=400, detail="Invalid UUID format") from exc
186
+
187
+
188
+ def _create_dialog_from_request(request: DialogCreate):
189
+ """Factory: build the appropriate Dialog subclass from an API request."""
190
+ from testerkit.api.dialogs.models import ChoiceDialog, ConfirmDialog, InputDialog
191
+
192
+ if request.type == "choice":
193
+ return ChoiceDialog(
194
+ title=request.title,
195
+ message=request.message,
196
+ run_id=request.run_id,
197
+ step_name=request.step_name,
198
+ timeout_seconds=request.timeout_seconds,
199
+ choices=request.choices or [],
200
+ allow_multiple=request.allow_multiple,
201
+ )
202
+ elif request.type == "input":
203
+ return InputDialog(
204
+ title=request.title,
205
+ message=request.message,
206
+ run_id=request.run_id,
207
+ step_name=request.step_name,
208
+ timeout_seconds=request.timeout_seconds,
209
+ placeholder=request.placeholder,
210
+ default_value=request.default_value,
211
+ )
212
+ else: # confirm is default
213
+ return ConfirmDialog(
214
+ title=request.title,
215
+ message=request.message,
216
+ run_id=request.run_id,
217
+ step_name=request.step_name,
218
+ timeout_seconds=request.timeout_seconds,
219
+ confirm_label=request.confirm_label,
220
+ cancel_label=request.cancel_label,
221
+ )
222
+
223
+
224
+ def create_api_router() -> APIRouter:
225
+ """Create the JSON API router."""
226
+ router = APIRouter(prefix="/api", tags=["api"])
227
+
228
+ from testerkit.store import load_project_config
229
+
230
+ project = load_project_config()
231
+ data_dir: Path | None = Path(project.data_dir) if project.data_dir else None
232
+ backend = ParquetBackend(data_dir=data_dir)
233
+
234
+ # -------------------------------------------------------------------------
235
+ # API docs — Swagger UI / ReDoc / OpenAPI JSON
236
+ #
237
+ # FastAPI's default `/docs`, `/redoc`, `/openapi.json` routes collide
238
+ # with NiceGUI's `@ui.page("/docs")` Diátaxis browser. Mount Swagger
239
+ # UI and ReDoc under the `/api/` prefix so the live API explorer is
240
+ # reachable without shadowing the in-app docs browser.
241
+ # -------------------------------------------------------------------------
242
+
243
+ @router.get("/openapi.json", include_in_schema=False)
244
+ def openapi_json(request: Request) -> dict[str, Any]:
245
+ """OpenAPI 3.0 schema for the TesterKit HTTP API."""
246
+ return get_openapi(
247
+ title="TesterKit HTTP API",
248
+ version=_testerkit_version_str,
249
+ description=(
250
+ "JSON API for runs, steps, measurements, sessions, dialogs, "
251
+ "channels, parts, stations, instruments, metrics, and the "
252
+ "MCP-parity tool surface."
253
+ ),
254
+ routes=request.app.routes,
255
+ )
256
+
257
+ @router.get("/docs", include_in_schema=False, response_class=HTMLResponse)
258
+ def swagger_ui() -> HTMLResponse:
259
+ """Swagger UI live API explorer (mounted under `/api/` to avoid
260
+ colliding with NiceGUI's `/docs` Diátaxis browser).
261
+ """
262
+ return get_swagger_ui_html(
263
+ openapi_url="/api/openapi.json",
264
+ title="TesterKit HTTP API — Swagger UI",
265
+ )
266
+
267
+ @router.get("/redoc", include_in_schema=False, response_class=HTMLResponse)
268
+ def redoc_ui() -> HTMLResponse:
269
+ """ReDoc rendering of the OpenAPI schema."""
270
+ return get_redoc_html(
271
+ openapi_url="/api/openapi.json",
272
+ title="TesterKit HTTP API — ReDoc",
273
+ )
274
+
275
+ def _measurements_query():
276
+ from testerkit.analysis.measurements_query import MeasurementsQuery
277
+
278
+ return MeasurementsQuery(_data_dir=data_dir)
279
+
280
+ def _steps_query():
281
+ from testerkit.analysis.steps_query import StepsQuery
282
+
283
+ return StepsQuery(_data_dir=data_dir)
284
+
285
+ # -------------------------------------------------------------------------
286
+ # Runs
287
+ # -------------------------------------------------------------------------
288
+
289
+ @router.get("/runs", response_model=RunsListResponse, response_class=ORJSONResponse)
290
+ def list_runs(limit: int = 50):
291
+ """List recent test runs."""
292
+ from testerkit.analysis.runs_query import RunsQuery
293
+
294
+ q = RunsQuery(_data_dir=data_dir)
295
+ try:
296
+ rows = q.list_recent(limit=limit, include_incomplete=True)
297
+ finally:
298
+ q.close()
299
+ return {"runs": [r.model_dump(exclude={"file_path"}) for r in rows]}
300
+
301
+ @router.get("/runs/{run_id}", response_model=RunView, response_class=ORJSONResponse)
302
+ def get_run(run_id: str):
303
+ """Get a specific test run with steps, instruments, and measurements."""
304
+ view = load_run_view(run_id, data_dir=data_dir)
305
+ if view is None:
306
+ raise HTTPException(status_code=404, detail="Run not found")
307
+ return view
308
+
309
+ @router.get(
310
+ "/runs/{run_id}/measurements",
311
+ response_model=MeasurementsListResponse,
312
+ response_class=ORJSONResponse,
313
+ )
314
+ def get_measurements(run_id: str):
315
+ """Get measurements for a test run."""
316
+ if backend.get_run(run_id) is None:
317
+ raise HTTPException(status_code=404, detail="Run not found")
318
+ measurements = backend.get_measurements(run_id)
319
+ return {"measurements": measurements}
320
+
321
+ @router.get(
322
+ "/runs/{run_id}/steps",
323
+ response_model=StepsListResponse,
324
+ response_class=ORJSONResponse,
325
+ )
326
+ def get_steps(run_id: str):
327
+ """List steps for a run, ordered by step_index."""
328
+ q = _steps_query()
329
+ try:
330
+ rows = q.list_for_run(run_id)
331
+ finally:
332
+ q.close()
333
+ if not rows:
334
+ raise HTTPException(status_code=404, detail="Run not found")
335
+ return {"steps": [r.model_dump(mode="json") for r in rows]}
336
+
337
+ @router.get(
338
+ "/runs/{run_id}/steps/tree",
339
+ response_model=StepsTreeResponse,
340
+ response_class=ORJSONResponse,
341
+ )
342
+ def get_steps_tree(run_id: str):
343
+ """Hierarchical step tree built from ``step_path``."""
344
+ q = _steps_query()
345
+ try:
346
+ tree = q.tree_for_run(run_id)
347
+ finally:
348
+ q.close()
349
+ if not tree:
350
+ raise HTTPException(status_code=404, detail="Run not found")
351
+ return {"tree": [n.model_dump(mode="json") for n in tree]}
352
+
353
+ @router.get("/runs/{run_id}/ref")
354
+ def get_ref(run_id: str, uri: str):
355
+ """Materialize a measurement-output ref URI to its underlying data.
356
+
357
+ Clients pass the URI from any ``out_*`` column verbatim. Three
358
+ URI shapes are recognized (item 1d dual-path):
359
+
360
+ * ``file://{date}/{session_id}/{filename}`` — FileStore canonical
361
+ (post-1d)
362
+ * ``file://_ref/{filename}`` — legacy per-parquet sidecar
363
+ * ``channel://scope.ch1?session=...`` — live channel reference
364
+
365
+ The endpoint resolves the run's parquet path, calls
366
+ :func:`testerkit.data.backends.parquet.load_ref`, then dispatches
367
+ on the materialized type:
368
+
369
+ * ``Waveform`` / ``BaseModel`` / ``dict`` / ``pyarrow.Table`` →
370
+ JSON.
371
+ * ``numpy.ndarray`` → JSON ``{shape, dtype, data}``.
372
+ * ``bytes`` → raw response with magic-byte-sniffed Content-Type
373
+ so browsers render images / video / PDF / text inline.
374
+ * Anything else (e.g. arbitrary pickled object) → 415.
375
+ """
376
+ run = backend.get_run(run_id)
377
+ if run is None:
378
+ raise HTTPException(status_code=404, detail="Run not found")
379
+ if not run.file_path:
380
+ raise HTTPException(status_code=500, detail="Run has no parquet path")
381
+ parquet_path = Path(run.file_path)
382
+
383
+ parent = data_dir if data_dir else resolve_data_dir()
384
+ try:
385
+ if uri.startswith("channel://"):
386
+ # Resolve channel refs through the daemon's warm index,
387
+ # not an ephemeral globbing store (req 2).
388
+ from testerkit.data.channels.client import channel_query_client
389
+
390
+ with channel_query_client(parent / "channels") as client:
391
+ result = load_ref(uri, parquet_path=parquet_path, channel_store=client)
392
+ else:
393
+ result = load_ref(uri, parquet_path=parquet_path, channel_store=None)
394
+ except Exception as exc: # noqa: BLE001 — surface load failures uniformly
395
+ raise HTTPException(status_code=502, detail=f"Failed to load ref: {exc}") from exc
396
+
397
+ # load_ref returns the URI string unchanged when the underlying
398
+ # file is missing or the scheme isn't a recognized ref.
399
+ if isinstance(result, str) and is_file_reference(uri) and result == uri:
400
+ raise HTTPException(status_code=404, detail=f"Ref payload not found: {uri}")
401
+
402
+ return _serialize_ref(result)
403
+
404
+ @router.get(
405
+ "/files/catalog",
406
+ response_model=GenericObjectResponse,
407
+ response_class=ORJSONResponse,
408
+ )
409
+ def list_files(
410
+ uri: str | None = None,
411
+ session_id: str | None = None,
412
+ run_id: str | None = None,
413
+ limit: int = 50,
414
+ ):
415
+ """List FileStore artifacts from the catalog (MCP-parity with ``testerkit_files``)."""
416
+ from testerkit.mcp.tools import files_query
417
+
418
+ return files_query(
419
+ uri=uri,
420
+ session_id=session_id,
421
+ run_id=run_id,
422
+ limit=limit,
423
+ data_dir=data_dir,
424
+ )
425
+
426
+ @router.get("/files")
427
+ def get_file(uri: str, request: Request) -> Response:
428
+ """Serve a FileStore artifact directly by ``file://`` URI.
429
+
430
+ Companion to :func:`get_ref` for the case where there's no
431
+ materialized run yet — live streams, in-progress captures, any
432
+ FileStore artifact reachable by URI alone.
433
+
434
+ ``uri`` must be ``file://{date}/{session_id}/{filename}``; the endpoint
435
+ serves the artifact through the FileStore — which reads it from the
436
+ blob backend (local disk or a remote object store), never touching
437
+ the filesystem here. The body is **streamed** from the backend in
438
+ chunks (never buffered whole), with a magic-byte-sniffed
439
+ ``Content-Type``. Honors HTTP ``Range`` (``206 Partial Content``)
440
+ so a consumer can range-read a still-growing stream artifact
441
+ without re-fetching the whole file.
442
+ """
443
+ from testerkit.data.files import get_filestore
444
+
445
+ if not uri.startswith("file://"):
446
+ raise HTTPException(status_code=400, detail=f"Not a file:// URI: {uri!r}")
447
+ store = get_filestore()
448
+ file_size = store.size(uri)
449
+ if file_size is None:
450
+ raise HTTPException(status_code=404, detail=f"Not found: {uri}")
451
+
452
+ content_type = sniff_mime(store.read_range(uri, offset=0, length=64) or b"")
453
+
454
+ range_header = request.headers.get("range")
455
+ if not range_header:
456
+ handle = store.open_input(uri)
457
+ if handle is None:
458
+ raise HTTPException(status_code=404, detail=f"Not found: {uri}")
459
+
460
+ def _body() -> Iterator[bytes]:
461
+ try:
462
+ while chunk := handle.read(_FILE_STREAM_CHUNK):
463
+ yield chunk
464
+ finally:
465
+ handle.close()
466
+
467
+ return StreamingResponse(
468
+ _body(),
469
+ media_type=content_type,
470
+ headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)},
471
+ )
472
+
473
+ # Parse a single ``bytes=start-end`` range (open-ended ends ok).
474
+ try:
475
+ unit, _, spec = range_header.partition("=")
476
+ start_s, _, end_s = spec.partition("-")
477
+ if unit.strip() != "bytes":
478
+ raise ValueError(f"unsupported range unit: {unit!r}")
479
+ start = int(start_s) if start_s else 0
480
+ end = int(end_s) if end_s else file_size - 1
481
+ except ValueError as exc:
482
+ raise HTTPException(
483
+ status_code=400, detail=f"Bad Range: {range_header!r} ({exc})"
484
+ ) from exc
485
+
486
+ if start < 0 or start >= file_size or start > end:
487
+ raise HTTPException(
488
+ status_code=416,
489
+ detail="Requested Range Not Satisfiable",
490
+ headers={"Content-Range": f"bytes */{file_size}"},
491
+ )
492
+ end = min(end, file_size - 1)
493
+ chunk = store.read_range(uri, offset=start, length=end - start + 1) or b""
494
+ return Response(
495
+ content=chunk,
496
+ status_code=206,
497
+ media_type=content_type,
498
+ headers={
499
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
500
+ "Accept-Ranges": "bytes",
501
+ "Content-Length": str(len(chunk)),
502
+ },
503
+ )
504
+
505
+ @router.post("/runs", response_model=RunLaunchResponse)
506
+ async def start_run(request: LaunchRequest):
507
+ """Start a new test run."""
508
+ from testerkit.api.runner import get_runner
509
+
510
+ runner = get_runner()
511
+ run_id = await runner.start(request)
512
+ return {"run_id": run_id, "status": "running"}
513
+
514
+ @router.get("/runs/{run_id}/status", response_model=RunStatus)
515
+ def get_run_status(run_id: str):
516
+ """Get status of a running test."""
517
+ from testerkit.api.runner import get_runner
518
+
519
+ runner = get_runner()
520
+ status = runner.get_status(run_id)
521
+ if not status:
522
+ raise HTTPException(status_code=404, detail="Run not found")
523
+ return status
524
+
525
+ @router.get("/active", response_model=ActiveRunsResponse)
526
+ def list_active_runs():
527
+ """List currently running tests."""
528
+ from testerkit.api.runner import get_runner
529
+
530
+ active = get_runner().list_active()
531
+ return {
532
+ "active_runs": [run.model_dump() for run in active],
533
+ "count": len(active),
534
+ }
535
+
536
+ # -------------------------------------------------------------------------
537
+ # Dialogs
538
+ # -------------------------------------------------------------------------
539
+
540
+ @router.get("/dialogs", response_model=DialogsListResponse)
541
+ def list_dialogs(run_id: str | None = None):
542
+ """List pending dialogs."""
543
+ from testerkit.api.dialogs import get_dialog_manager
544
+
545
+ manager = get_dialog_manager()
546
+ dialogs = manager.get_pending_dialogs(run_id)
547
+ return {"dialogs": [d.model_dump(mode="json") for d in dialogs]}
548
+
549
+ @router.post("/dialogs", response_model=DialogCreateResponse)
550
+ def create_dialog(request: DialogCreate):
551
+ """Create a pending dialog (from test subprocess)."""
552
+ from testerkit.api.dialogs import get_dialog_manager
553
+
554
+ manager = get_dialog_manager()
555
+ dialog = _create_dialog_from_request(request)
556
+ manager.register_dialog(dialog)
557
+ return {"dialog_id": str(dialog.id), "status": "pending"}
558
+
559
+ @router.get("/dialogs/{dialog_id}", response_model=Dialog)
560
+ def get_dialog(dialog_id: str):
561
+ """Get a specific pending dialog."""
562
+ from testerkit.api.dialogs import get_dialog_manager
563
+
564
+ uuid = _parse_uuid(dialog_id)
565
+ manager = get_dialog_manager()
566
+ for dialog in manager.get_pending_dialogs():
567
+ if dialog.id == uuid:
568
+ return dialog.model_dump(mode="json")
569
+ raise HTTPException(status_code=404, detail="Dialog not found")
570
+
571
+ @router.get("/dialogs/{dialog_id}/wait", response_model=DialogResponse)
572
+ async def wait_for_response(dialog_id: str, timeout: float = 300):
573
+ """Long-poll waiting for dialog response.
574
+
575
+ Blocks until the dialog is responded to or timeout.
576
+ Used by test subprocesses to wait for operator input.
577
+ """
578
+ from testerkit.api.dialogs import DialogResponse, get_dialog_manager
579
+
580
+ uuid = _parse_uuid(dialog_id)
581
+ manager = get_dialog_manager()
582
+
583
+ # Check if dialog exists
584
+ dialog = next((d for d in manager.get_pending_dialogs() if d.id == uuid), None)
585
+
586
+ if not dialog:
587
+ # Maybe already responded
588
+ response = manager.get_response(uuid)
589
+ if response:
590
+ return response.model_dump(mode="json")
591
+ raise HTTPException(status_code=404, detail="Dialog not found")
592
+
593
+ # Wait for response with polling
594
+ poll_interval = 0.5
595
+ elapsed = 0.0
596
+ while elapsed < timeout:
597
+ response = manager.get_response(uuid)
598
+ if response:
599
+ return response.model_dump(mode="json")
600
+ await asyncio.sleep(poll_interval)
601
+ elapsed += poll_interval
602
+
603
+ return DialogResponse(dialog_id=uuid, timed_out=True).model_dump(mode="json")
604
+
605
+ @router.post("/dialogs/{dialog_id}/respond", response_model=DialogRespondAck)
606
+ def respond_to_dialog(dialog_id: str, request: DialogRespondRequest):
607
+ """Respond to a pending dialog."""
608
+ from testerkit.api.dialogs import DialogResponse, get_dialog_manager
609
+
610
+ uuid = _parse_uuid(dialog_id)
611
+ manager = get_dialog_manager()
612
+
613
+ response = DialogResponse(
614
+ dialog_id=uuid,
615
+ confirmed=request.confirmed,
616
+ choice=request.choice,
617
+ choices=request.choices,
618
+ value=request.value,
619
+ cancelled=request.cancelled,
620
+ )
621
+
622
+ if manager.respond(uuid, response):
623
+ return {"status": "ok"}
624
+ raise HTTPException(status_code=404, detail="Dialog not found")
625
+
626
+ # -------------------------------------------------------------------------
627
+ # Events & Sessions
628
+ # -------------------------------------------------------------------------
629
+
630
+ @router.get("/events", response_model=GenericObjectResponse, response_class=ORJSONResponse)
631
+ def list_events(
632
+ session_id: str | None = None,
633
+ type: str | None = None,
634
+ role: str | None = None,
635
+ since: str | None = None,
636
+ limit: int = 100,
637
+ ):
638
+ """Query events from the event store."""
639
+ from testerkit.mcp.tools import events_query
640
+
641
+ return events_query(
642
+ session_id,
643
+ type,
644
+ role,
645
+ since,
646
+ limit,
647
+ data_dir=data_dir,
648
+ )
649
+
650
+ @router.get("/sessions", response_model=GenericObjectResponse, response_class=ORJSONResponse)
651
+ def list_sessions():
652
+ """List known sessions."""
653
+ from testerkit.mcp.tools import sessions_query
654
+
655
+ return sessions_query(data_dir=data_dir)
656
+
657
+ @router.get(
658
+ "/sessions/{session_id}",
659
+ response_model=GenericObjectResponse,
660
+ response_class=ORJSONResponse,
661
+ )
662
+ def get_session(session_id: str):
663
+ """Get events for a specific session."""
664
+ from testerkit.mcp.tools import session_detail_query
665
+
666
+ result = session_detail_query(session_id, data_dir=data_dir)
667
+ if result is None:
668
+ raise HTTPException(status_code=404, detail="Session not found")
669
+ return result
670
+
671
+ # -------------------------------------------------------------------------
672
+ # Channels
673
+ # -------------------------------------------------------------------------
674
+
675
+ @router.get("/channels", response_model=GenericObjectResponse, response_class=ORJSONResponse)
676
+ def list_channels():
677
+ """List known channels from the channel registry."""
678
+ from testerkit.mcp.tools import channels_list_query
679
+
680
+ return channels_list_query(data_dir=data_dir)
681
+
682
+ @router.get(
683
+ "/channels/_recent",
684
+ response_model=GenericObjectResponse,
685
+ response_class=ORJSONResponse,
686
+ )
687
+ def list_channels_recent(last_n: int = 50):
688
+ """Channel registry + recent samples per channel.
689
+
690
+ Used by the operator UI to render sparkline cells and live-
691
+ updated latest values. ``last_n`` caps the per-channel sample
692
+ count returned (default 50 — enough for a sparkline trace).
693
+ """
694
+ from testerkit.mcp.tools import channels_recent_query
695
+
696
+ return channels_recent_query(last_n=last_n, data_dir=data_dir)
697
+
698
+ @router.get(
699
+ "/channels/{channel_id}",
700
+ response_model=GenericObjectResponse,
701
+ response_class=ORJSONResponse,
702
+ )
703
+ def get_channel_data(
704
+ channel_id: str,
705
+ session_id: str | None = None,
706
+ since: str | None = None,
707
+ until: str | None = None,
708
+ last_n: int | None = None,
709
+ max_points: int | None = None,
710
+ ):
711
+ """Query channel data."""
712
+ from testerkit.mcp.tools import channels_query
713
+
714
+ return channels_query(
715
+ channel_id,
716
+ session_id=session_id,
717
+ since=since,
718
+ until=until,
719
+ last_n=last_n,
720
+ max_points=max_points,
721
+ data_dir=data_dir,
722
+ )
723
+
724
+ # -------------------------------------------------------------------------
725
+ # Parts & Stations
726
+ # -------------------------------------------------------------------------
727
+
728
+ @router.get("/parts", response_model=PartsListResponse)
729
+ def list_parts():
730
+ """List all available part specifications."""
731
+ from testerkit.matching.service import list_parts_summary
732
+
733
+ parts = list_parts_summary()
734
+ return {"parts": parts}
735
+
736
+ @router.get("/parts/{part_id}", response_model=Part)
737
+ def get_part(part_id: str):
738
+ """Get a part specification by ID."""
739
+ from testerkit.store import get_part as store_get_part
740
+
741
+ part = store_get_part(part_id)
742
+ if not part:
743
+ raise HTTPException(status_code=404, detail=f"Part '{part_id}' not found")
744
+ return part.model_dump()
745
+
746
+ @router.get(
747
+ "/parts/{part_id}/requirements",
748
+ response_model=PartRequirementsResponse,
749
+ )
750
+ def get_part_requirements(part_id: str):
751
+ """Get required capabilities for a part."""
752
+ from testerkit.matching.service import get_required_capabilities
753
+ from testerkit.store import get_part as store_get_part
754
+
755
+ part = store_get_part(part_id)
756
+ if not part:
757
+ raise HTTPException(status_code=404, detail=f"Part '{part_id}' not found")
758
+ reqs = get_required_capabilities(part)
759
+ return {
760
+ "part_id": part_id,
761
+ "requirements": [
762
+ RequirementSummary(
763
+ function=r.function.value,
764
+ direction=r.direction.value,
765
+ characteristic_name=r.characteristic_name,
766
+ ).model_dump()
767
+ for r in reqs
768
+ ],
769
+ }
770
+
771
+ @router.get("/stations", response_model=StationsListResponse)
772
+ def list_all_stations():
773
+ """List all available test stations."""
774
+ from testerkit.store import list_stations
775
+
776
+ stations = list_stations()
777
+ return {"stations": [s.model_dump() for s in stations]}
778
+
779
+ @router.get("/stations/{station_id}", response_model=StationConfig)
780
+ def get_station(station_id: str):
781
+ """Get a station configuration by ID."""
782
+ from testerkit.store import get_station as store_get_station
783
+
784
+ config = store_get_station(station_id)
785
+ if not config:
786
+ raise HTTPException(status_code=404, detail=f"Station '{station_id}' not found")
787
+ return config.model_dump()
788
+
789
+ @router.get(
790
+ "/stations/{station_id}/capabilities",
791
+ response_model=StationCapabilitiesResponse,
792
+ )
793
+ def get_station_capabilities(station_id: str):
794
+ """Get capabilities provided by a station."""
795
+ from testerkit.matching.service import (
796
+ get_station_capabilities as service_get_capabilities,
797
+ )
798
+ from testerkit.store import get_station
799
+
800
+ config = get_station(station_id)
801
+ if not config:
802
+ raise HTTPException(status_code=404, detail=f"Station '{station_id}' not found")
803
+
804
+ capabilities = service_get_capabilities(config)
805
+ return {
806
+ "station_id": station_id,
807
+ "capabilities": [
808
+ CapabilitySummary(
809
+ function=cap.function.value,
810
+ direction=cap.direction.value,
811
+ instrument_type=cap.instrument_type,
812
+ instrument_name=cap.instrument_name,
813
+ channel=cap.channel,
814
+ ).model_dump()
815
+ for cap in capabilities
816
+ ],
817
+ }
818
+
819
+ @router.get("/match", response_model=MatchSingleResponse | MatchAllResponse)
820
+ def match_capabilities(part_id: str, station_id: str | None = None):
821
+ """Match part requirements to station capabilities.
822
+
823
+ If station_id is provided, returns detailed match for that station.
824
+ Otherwise, returns all stations with their compatibility status.
825
+ """
826
+ from testerkit.matching.service import (
827
+ find_all_station_matches,
828
+ find_compatible_stations,
829
+ )
830
+ from testerkit.store import get_part as store_get_part
831
+ from testerkit.store import get_station as store_get_station
832
+
833
+ part = store_get_part(part_id)
834
+ if not part:
835
+ raise HTTPException(status_code=404, detail=f"Part '{part_id}' not found")
836
+
837
+ if station_id:
838
+ # Validate station exists, then find its match result
839
+ config = store_get_station(station_id)
840
+ if not config:
841
+ raise HTTPException(status_code=404, detail=f"Station '{station_id}' not found")
842
+ matches = find_compatible_stations(part)
843
+ match = next((m for m in matches if m.station_id == station_id), None)
844
+ return {
845
+ "part_id": part_id,
846
+ "station_id": station_id,
847
+ "compatible": match.compatible if match else False,
848
+ }
849
+ else:
850
+ result = find_all_station_matches(part)
851
+ return {"part_id": part_id, "stations": result}
852
+
853
+ # -------------------------------------------------------------------------
854
+ # Instruments & Catalog
855
+ # -------------------------------------------------------------------------
856
+
857
+ @router.get("/instruments/types", response_model=InstrumentTypesResponse)
858
+ def list_instrument_types():
859
+ """List distinct instrument ``type`` values present in the catalog."""
860
+ from testerkit.store import find_catalog_dirs, load_catalog_from_directory
861
+
862
+ types = {
863
+ entry.type
864
+ for cat_dir in find_catalog_dirs()
865
+ for entry in load_catalog_from_directory(cat_dir).values()
866
+ if entry.type
867
+ }
868
+ return {"instrument_types": sorted(types)}
869
+
870
+ @router.get("/instruments/catalog/{entry_id}", response_model=InstrumentCatalogEntry)
871
+ def get_catalog_entry(entry_id: str):
872
+ """Get a catalog entry by type or ID."""
873
+ from testerkit.store import get_catalog_entry as store_get_catalog_entry
874
+
875
+ result = store_get_catalog_entry(entry_id)
876
+ if not result:
877
+ raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
878
+ return result.model_dump()
879
+
880
+ @router.get("/instruments/assets", response_model=InstrumentAssetsResponse)
881
+ def list_instrument_assets():
882
+ """List instrument asset files (physical devices you own)."""
883
+ from testerkit.store import list_instrument_assets
884
+
885
+ assets = list_instrument_assets()
886
+ return {"assets": [a.model_dump() for a in assets], "count": len(assets)}
887
+
888
+ @router.get("/instruments/assets/{asset_id}", response_model=InstrumentAssetFile)
889
+ def get_instrument_asset(asset_id: str):
890
+ """Get an instrument asset by ID."""
891
+ from testerkit.store import get_instrument_asset
892
+
893
+ result = get_instrument_asset(asset_id)
894
+ if not result:
895
+ raise HTTPException(status_code=404, detail=f"Instrument asset '{asset_id}' not found")
896
+ return result.model_dump()
897
+
898
+ # -------------------------------------------------------------------------
899
+ # Manufacturing-test analytics
900
+ # -------------------------------------------------------------------------
901
+
902
+ @router.get("/metrics/summary", response_model=MetricsResponse, response_class=ORJSONResponse)
903
+ def metrics_summary(
904
+ part: str | None = None,
905
+ station: str | None = None,
906
+ phase: str | None = None,
907
+ since: str | None = None,
908
+ until: str | None = None,
909
+ period: str = "day",
910
+ ):
911
+ """Yield summary — FPY, final yield, RTY, DPMO, DPPM per (part, station, phase, period)."""
912
+ with _measurements_query() as q:
913
+ return {
914
+ "data": [
915
+ r.model_dump()
916
+ for r in q.yield_summary(
917
+ part=part,
918
+ station=station,
919
+ phase=phase,
920
+ since=since,
921
+ until=until,
922
+ period=period,
923
+ )
924
+ ]
925
+ }
926
+
927
+ @router.get("/metrics/pareto", response_model=MetricsResponse, response_class=ORJSONResponse)
928
+ def metrics_pareto(
929
+ part: str | None = None,
930
+ station: str | None = None,
931
+ phase: str | None = None,
932
+ since: str | None = None,
933
+ until: str | None = None,
934
+ top_n: int = 10,
935
+ ):
936
+ """Top failure modes (DuckDB SQL)."""
937
+ with _measurements_query() as q:
938
+ return {
939
+ "data": [
940
+ r.model_dump()
941
+ for r in q.pareto(
942
+ part=part,
943
+ station=station,
944
+ phase=phase,
945
+ since=since,
946
+ until=until,
947
+ top_n=top_n,
948
+ )
949
+ ]
950
+ }
951
+
952
+ @router.get("/metrics/ppk", response_model=MetricsResponse, response_class=ORJSONResponse)
953
+ def metrics_ppk(
954
+ part: str | None = None,
955
+ station: str | None = None,
956
+ phase: str | None = None,
957
+ since: str | None = None,
958
+ until: str | None = None,
959
+ min_samples: int = 10,
960
+ ):
961
+ """Process performance (DuckDB SQL)."""
962
+ with _measurements_query() as q:
963
+ return {
964
+ "data": [
965
+ r.model_dump()
966
+ for r in q.ppk(
967
+ part=part,
968
+ station=station,
969
+ phase=phase,
970
+ since=since,
971
+ until=until,
972
+ min_samples=min_samples,
973
+ )
974
+ ]
975
+ }
976
+
977
+ @router.get("/metrics/trend", response_model=MetricsResponse, response_class=ORJSONResponse)
978
+ def metrics_trend(
979
+ part: str | None = None,
980
+ station: str | None = None,
981
+ phase: str | None = None,
982
+ since: str | None = None,
983
+ until: str | None = None,
984
+ period: str = "day",
985
+ ):
986
+ """Yield trend (DuckDB SQL)."""
987
+ with _measurements_query() as q:
988
+ return {
989
+ "data": [
990
+ r.model_dump()
991
+ for r in q.trend(
992
+ part=part,
993
+ station=station,
994
+ phase=phase,
995
+ since=since,
996
+ until=until,
997
+ period=period,
998
+ )
999
+ ]
1000
+ }
1001
+
1002
+ @router.get("/metrics/retest", response_model=MetricsResponse, response_class=ORJSONResponse)
1003
+ def metrics_retest(
1004
+ part: str | None = None,
1005
+ station: str | None = None,
1006
+ phase: str | None = None,
1007
+ since: str | None = None,
1008
+ until: str | None = None,
1009
+ period: str = "day",
1010
+ ):
1011
+ """Retest rates (DuckDB SQL)."""
1012
+ with _measurements_query() as q:
1013
+ return {
1014
+ "data": [
1015
+ r.model_dump()
1016
+ for r in q.retest(
1017
+ part=part,
1018
+ station=station,
1019
+ phase=phase,
1020
+ since=since,
1021
+ until=until,
1022
+ period=period,
1023
+ )
1024
+ ]
1025
+ }
1026
+
1027
+ @router.get("/metrics/time-loss", response_model=MetricsResponse, response_class=ORJSONResponse)
1028
+ def metrics_time_loss(
1029
+ part: str | None = None,
1030
+ station: str | None = None,
1031
+ phase: str | None = None,
1032
+ since: str | None = None,
1033
+ until: str | None = None,
1034
+ period: str = "day",
1035
+ ):
1036
+ """Time lost to failures/errors (DuckDB SQL)."""
1037
+ with _measurements_query() as q:
1038
+ return {
1039
+ "data": [
1040
+ r.model_dump()
1041
+ for r in q.time_loss(
1042
+ part=part,
1043
+ station=station,
1044
+ phase=phase,
1045
+ since=since,
1046
+ until=until,
1047
+ period=period,
1048
+ )
1049
+ ]
1050
+ }
1051
+
1052
+ # -------------------------------------------------------------------------
1053
+ # MCP parity endpoints (testerkit_discover, testerkit_open, testerkit_schema, save)
1054
+ # -------------------------------------------------------------------------
1055
+
1056
+ @router.get("/discover", response_model=GenericObjectResponse)
1057
+ def discover_instruments(protocols: list[str] | None = None):
1058
+ """Scan for connected instruments across all protocols.
1059
+
1060
+ HTTP equivalent of the testerkit_discover MCP tool.
1061
+ """
1062
+ from testerkit.mcp.tools import discover_tool
1063
+
1064
+ return discover_tool(protocols)
1065
+
1066
+ @router.get("/open", response_model=GenericObjectResponse)
1067
+ def open_entity(type: str, id: str, base_url: str = "http://localhost:8000"):
1068
+ """Get URL to view/edit an entity in the browser UI.
1069
+
1070
+ HTTP equivalent of the testerkit_open MCP tool.
1071
+ """
1072
+ from testerkit.mcp.tools import open_tool
1073
+
1074
+ return open_tool(type, id, base_url)
1075
+
1076
+ @router.get("/schema/{yaml_type}", response_model=GenericObjectResponse)
1077
+ def get_yaml_schema(yaml_type: str):
1078
+ """Get JSON Schema for a TesterKit YAML file type.
1079
+
1080
+ HTTP equivalent of the testerkit_schema MCP tool.
1081
+ """
1082
+ from testerkit.mcp.tools import schema_tool
1083
+
1084
+ return schema_tool(yaml_type)
1085
+
1086
+ @router.post("/save/{entity_type}/{entity_id}", response_model=GenericObjectResponse)
1087
+ def save_entity(entity_type: str, entity_id: str, request: SaveRequest):
1088
+ """Create or update an entity (station, part, sequence, fixture, etc.).
1089
+
1090
+ HTTP equivalent of testerkit_project(action='save', ...) MCP tool action.
1091
+ Returns validation errors if content does not match the schema.
1092
+ """
1093
+ from testerkit.mcp.tools import testerkit_tool
1094
+
1095
+ result = testerkit_tool(
1096
+ action="save",
1097
+ type=entity_type,
1098
+ id=entity_id,
1099
+ content=request.content,
1100
+ project=request.project,
1101
+ )
1102
+ if isinstance(result, dict) and result.get("success") is False:
1103
+ raise HTTPException(
1104
+ status_code=422,
1105
+ detail=result.get("error") or result.get("errors") or str(result),
1106
+ )
1107
+ return result
1108
+
1109
+ @router.get("/read", response_model=GenericObjectResponse)
1110
+ def read_file(path: str, project: str | None = None):
1111
+ """Read a project file or template.
1112
+
1113
+ HTTP equivalent of testerkit_project(action='read', ...) MCP tool action.
1114
+ Use path='template:test' to get the test file template.
1115
+ """
1116
+ from testerkit.mcp.tools import testerkit_tool
1117
+
1118
+ result = testerkit_tool(action="read", path=path, project=project)
1119
+ if isinstance(result, dict) and "error" in result:
1120
+ raise HTTPException(status_code=400, detail=result["error"])
1121
+ return result
1122
+
1123
+ @router.get("/enum/{abbrev}", response_model=GenericObjectResponse)
1124
+ def lookup_enum(abbrev: str):
1125
+ """Resolve a datasheet abbreviation to its MeasurementFunction enum value(s).
1126
+
1127
+ HTTP equivalent of testerkit_project(action='lookup_enum', id=abbrev) MCP tool action.
1128
+ Example: GET /enum/FRES → resistance_4w
1129
+ """
1130
+ from testerkit.mcp.tools import testerkit_tool
1131
+
1132
+ return testerkit_tool(action="lookup_enum", id=abbrev)
1133
+
1134
+ @router.get("/enum-reference", response_model=GenericObjectResponse)
1135
+ def enum_reference():
1136
+ """Get the full abbreviation-to-enum reference table as markdown.
1137
+
1138
+ HTTP equivalent of testerkit_project(action='enum_reference') MCP tool action.
1139
+ """
1140
+ from testerkit.mcp.tools import testerkit_tool
1141
+
1142
+ return testerkit_tool(action="enum_reference")
1143
+
1144
+ return router
1145
+
1146
+
1147
+ def create_app():
1148
+ """Create the combined FastAPI + NiceGUI application."""
1149
+ from nicegui import app
1150
+
1151
+ # Import UI pages (registers routes)
1152
+ import testerkit.ui.app # noqa: F401
1153
+ from testerkit.api.dialogs import register_as_prompt_handler
1154
+
1155
+ # Add API routes
1156
+ api_router = create_api_router()
1157
+ app.include_router(api_router)
1158
+
1159
+ # Bridge ``testerkit.prompts.ask`` → dialog UI so any test code running
1160
+ # in-process routes through the operator UI instead of TTY /
1161
+ # auto-confirm. Test subprocesses with ``TESTERKIT_SERVER_URL`` set
1162
+ # install their own bridge in HTTP mode (see pytest plugin).
1163
+ register_as_prompt_handler(server_url=None)
1164
+
1165
+ # Eagerly acquire the data daemons and bridge live channel data —
1166
+ # same call ``testerkit.ui._asgi`` makes for the ``--reload`` path, so
1167
+ # both entrypoints behave identically (#59-3).
1168
+ warm_data_daemons()
1169
+
1170
+ # Diagnostic thread count logger — tracks the slow-leak pattern
1171
+ return app