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,367 @@
1
+ """RunStore — query API for parquet test run data.
2
+
3
+ Mirrors EventStore's pattern: parquet files are the source of truth,
4
+ a DuckDB daemon indexes them, and RunStore provides a clean query API.
5
+ ParquetBackend keeps the write path; RunStore owns reads + ref management.
6
+
7
+ Construct once and reuse across notebook cells or long-running scripts.
8
+ The analytical daemon is a separate process with its own PID-ref and idle
9
+ timeout, so forgetting to call ``close()`` does not leak it. ``close()``
10
+ and ``with`` are optional.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import warnings
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pyarrow as pa
21
+ import pyarrow.flight as flight
22
+ import pyarrow.parquet as pq
23
+
24
+ from testerkit.data import runs_duckdb_manager
25
+ from testerkit.data._flight_query import FlightQueryClient, call_options
26
+ from testerkit.data._sql_helpers import sql_escape as _sql_escape
27
+ from testerkit.data.backends._row_helpers import _decode_io_maps
28
+ from testerkit.data.data_dir import resolve_data_dir
29
+ from testerkit.data.models import RunSummary
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class DataUnavailable(Exception):
35
+ """Raised when a run's parquet data is not locally accessible.
36
+
37
+ The run's metadata (outcome, started_at, measurement stats) remains
38
+ queryable from the index. Raw scalars and ref data require the file.
39
+ """
40
+
41
+ def __init__(self, file_path: str, *, status: str = "missing") -> None:
42
+ self.file_path = file_path
43
+ self.status = status
44
+ super().__init__(f"Run data {status}: {file_path}")
45
+
46
+
47
+ class RunStore:
48
+ """Query API for parquet test run data.
49
+
50
+ Uses a ref-counted in-memory DuckDB daemon for indexed queries — same
51
+ lifecycle pattern as EventStore. Queries go via Arrow Flight (gRPC).
52
+
53
+ Construct once and reuse across calls. ``close()`` / ``with`` are optional
54
+ — the daemon is a separate process that self-manages via PID-ref and idle
55
+ timeout, so forgetting to close does not leak it.
56
+ """
57
+
58
+ def __init__(self, *, _data_dir: Path | str | None = None) -> None:
59
+ data_dir = resolve_data_dir(_data_dir)
60
+
61
+ self._runs_dir = data_dir / "runs"
62
+ self._runs_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ # Start daemon and get gRPC location
65
+ self._location = runs_duckdb_manager.acquire(self._runs_dir)
66
+
67
+ # Flight query client (shared retry logic with EventStore)
68
+ self._flight = FlightQueryClient(
69
+ self._location,
70
+ "runs",
71
+ reacquire=lambda: runs_duckdb_manager.acquire(self._runs_dir),
72
+ label="RunStore",
73
+ )
74
+
75
+ def _flight_query(self, sql: str) -> list[dict[str, Any]]:
76
+ """Execute a SQL query via Flight and return list of dicts."""
77
+ return self._flight.query(sql)
78
+
79
+ # --- Query API ---
80
+
81
+ @staticmethod
82
+ def _id_prefix(run_id: str, length: int = 8) -> str:
83
+ """Return an 8-char (or shorter) prefix for LIKE matching against the index."""
84
+ return run_id[:length] if len(run_id) >= length else run_id
85
+
86
+ def list_runs(self, limit: int = 50) -> list[RunSummary]:
87
+ """List recent test runs, most recent first."""
88
+ rows = self._flight_query(f"""
89
+ SELECT file_path, run_id, session_id, uut_serial_number, station_id,
90
+ outcome, started_at, num_measurements,
91
+ test_phase, part_id, operator_id,
92
+ project_name
93
+ FROM runs
94
+ ORDER BY started_at DESC
95
+ LIMIT {limit}
96
+ """)
97
+
98
+ return [
99
+ RunSummary(
100
+ test_run_id=r["run_id"],
101
+ session_id=r.get("session_id"),
102
+ started_at=r.get("started_at"),
103
+ uut_serial_number=r.get("uut_serial_number"),
104
+ station_id=r.get("station_id"),
105
+ outcome=r.get("outcome"),
106
+ total_measurements=r.get("num_measurements", 0),
107
+ test_phase=r.get("test_phase"),
108
+ part_id=r.get("part_id"),
109
+ operator=r.get("operator_id"),
110
+ project_name=r.get("project_name"),
111
+ file_path=r.get("file_path"),
112
+ )
113
+ for r in rows
114
+ ]
115
+
116
+ def find_run_file(self, run_id: str) -> Path | None:
117
+ """Find the measurements parquet for a run_id (prefix match).
118
+
119
+ Returns ``None`` when the runs row has no measurements
120
+ parquet (measurement-less run — only setup / action /
121
+ skipped steps) or when the file is missing from disk.
122
+ """
123
+ prefix = self._id_prefix(run_id)
124
+ rows = self._flight_query(f"""
125
+ SELECT file_path FROM runs
126
+ WHERE run_id LIKE '{_sql_escape(prefix)}%'
127
+ LIMIT 1
128
+ """)
129
+
130
+ if not rows or rows[0].get("file_path") is None:
131
+ return None
132
+ p = Path(rows[0]["file_path"])
133
+ return p if p.exists() else None
134
+
135
+ def get_run(self, run_id: str) -> RunSummary | None:
136
+ """Get a run summary by ID (prefix match), runs-table-first.
137
+
138
+ The runs TABLE (or, for a not-yet-materialized run, the
139
+ in-flight overlay row — see ``runs`` view) carries every
140
+ run-level field needed to render the summary card, including
141
+ ``site_index`` / ``site_name`` (denormalized onto every row at
142
+ ingest, per ``_RUNS_PERSISTED_COLUMNS``). Only ``station_type``
143
+ isn't denormalized onto the runs table — we crack open the
144
+ measurements parquet just for that one, skipped silently when
145
+ ``file_path`` is NULL (a not-yet-materialized / in-flight run).
146
+ """
147
+ prefix = self._id_prefix(run_id)
148
+ rows = self._flight_query(f"""
149
+ SELECT * FROM runs
150
+ WHERE run_id LIKE '{_sql_escape(prefix)}%'
151
+ LIMIT 1
152
+ """)
153
+ if not rows:
154
+ return None
155
+ r = rows[0]
156
+ run_id_val = r.get("run_id")
157
+ if not run_id_val:
158
+ return None
159
+
160
+ summary = RunSummary(
161
+ test_run_id=run_id_val,
162
+ session_id=r.get("session_id"),
163
+ site_index=r.get("site_index"),
164
+ site_name=r.get("site_name"),
165
+ started_at=r.get("started_at"),
166
+ ended_at=r.get("ended_at"),
167
+ uut_serial_number=r.get("uut_serial_number"),
168
+ uut_part_number=r.get("uut_part_number"),
169
+ part_id=r.get("part_id"),
170
+ station_id=r.get("station_id"),
171
+ station_name=r.get("station_name"),
172
+ station_hostname=r.get("station_hostname"),
173
+ fixture_id=r.get("fixture_id"),
174
+ test_phase=r.get("test_phase"),
175
+ project_name=r.get("project_name"),
176
+ operator=r.get("operator_id"),
177
+ outcome=r.get("outcome"),
178
+ total_measurements=r.get("num_measurements", 0) or 0,
179
+ file_path=r.get("file_path"),
180
+ )
181
+
182
+ # station_type isn't in the runs table — sourced from the
183
+ # measurements parquet when available. Skipped silently for
184
+ # not-yet-materialized (in-flight) runs.
185
+ pq_path_str = r.get("file_path")
186
+ if pq_path_str:
187
+ pq_path = Path(pq_path_str)
188
+ if pq_path.exists():
189
+ try:
190
+ table = pq.read_table(pq_path)
191
+ if table.num_rows > 0:
192
+ first = table.to_pylist()[0]
193
+ summary.station_type = first.get("station_type")
194
+ except Exception as exc:
195
+ logger.debug("Failed to enrich run %s from parquet: %s", run_id, exc)
196
+ return summary
197
+
198
+ def get_measurements(self, run_id: str) -> list[dict[str, Any]]:
199
+ """Get all measurements for a specific test run.
200
+
201
+ Goes through the daemon's ``measurements`` view (a parquet glob
202
+ with ``union_by_name=true``) instead of reading the parquet file
203
+ directly — DuckDB does the multi-file scan in C++ with predicate
204
+ pushdown on ``run_id`` and avoids client-side parquet decoding.
205
+
206
+ Per-row ``inputs``/``outputs`` are derived at query time from the
207
+ ``inputs``/``outputs`` tables (projection-normalization, 0.3.1) —
208
+ LEFT JOINed and aggregated into a VARCHAR map per (role, vector
209
+ coordinate), same key the daemon's EAV joins have always used
210
+ (``run_id, step_index, step_path, vector_index, vector_outer_index,
211
+ vector_retry`` — no ``step_retry``: this is the measurement's own
212
+ FK, matching historical behavior exactly). ``None`` when a run has no
213
+ materialized rows for that (role, coordinate) — decodes to ``{}``.
214
+ """
215
+ prefix = self._id_prefix(run_id)
216
+ io_join = """
217
+ LEFT JOIN (
218
+ SELECT run_id, step_index, step_path, step_retry, vector_index,
219
+ vector_outer_index, vector_retry,
220
+ MAP(list(name), list(
221
+ CASE value_type
222
+ WHEN 'scalar:bool'
223
+ THEN CASE WHEN value_bool THEN 'true' ELSE 'false' END
224
+ WHEN 'scalar:int' THEN CAST(value_int AS VARCHAR)
225
+ WHEN 'scalar:float' THEN CAST(value_double AS VARCHAR)
226
+ WHEN 'scalar:datetime' THEN CAST(value_timestamp AS VARCHAR)
227
+ WHEN 'list' THEN value_json
228
+ WHEN 'dict' THEN value_json
229
+ ELSE value_text
230
+ END
231
+ )) AS {map_col}
232
+ FROM {table}
233
+ GROUP BY run_id, step_index, step_path, step_retry, vector_index,
234
+ vector_outer_index, vector_retry
235
+ ) AS {alias} ON {alias}.run_id = m.run_id
236
+ AND {alias}.step_index = m.step_index
237
+ AND {alias}.step_path IS NOT DISTINCT FROM m.step_path
238
+ AND {alias}.step_retry IS NOT DISTINCT FROM m.step_retry
239
+ AND {alias}.vector_index IS NOT DISTINCT FROM m.vector_index
240
+ AND {alias}.vector_outer_index IS NOT DISTINCT FROM m.vector_outer_index
241
+ AND {alias}.vector_retry IS NOT DISTINCT FROM m.vector_retry
242
+ """
243
+ try:
244
+ rows = self._flight_query(f"""
245
+ SELECT m.*, inputs_agg.inputs_map, outputs_agg.outputs_map
246
+ FROM measurements m
247
+ {io_join.format(alias="inputs_agg", table="inputs", map_col="inputs_map")}
248
+ {io_join.format(alias="outputs_agg", table="outputs", map_col="outputs_map")}
249
+ WHERE m.run_id LIKE '{_sql_escape(prefix)}%'
250
+ ORDER BY m.step_index, m.measurement_name
251
+ """)
252
+ except Exception as exc:
253
+ logger.debug("Failed to query measurements for %s: %s", run_id, exc)
254
+ return []
255
+
256
+ for row in rows:
257
+ # pop removes the raw maps from the dict before returning it
258
+ row["inputs"], row["outputs"] = _decode_io_maps(
259
+ row.pop("inputs_map", None), row.pop("outputs_map", None)
260
+ )
261
+ return rows
262
+
263
+ # --- Ref management (for materialize) ---
264
+
265
+ def get_steps(self, run_id: str) -> list[dict[str, Any]]:
266
+ """Get steps for a run from the daemon's ``steps`` view."""
267
+ prefix = self._id_prefix(run_id)
268
+ return self._flight_query(f"""
269
+ SELECT step_index, step_name, step_path, outcome,
270
+ CAST(started_at AT TIME ZONE 'UTC' AS VARCHAR) AS started_at,
271
+ CAST(ended_at AT TIME ZONE 'UTC' AS VARCHAR) AS ended_at,
272
+ duration_s, measurement_count, step_retry, markers
273
+ FROM steps
274
+ WHERE run_id LIKE '{_sql_escape(prefix)}%'
275
+ ORDER BY step_index, step_retry, vector_index
276
+ """)
277
+
278
+ def find_channel_refs(self, session_shorts: set[str]) -> list[dict[str, Any]]:
279
+ """Query measurement_refs WHERE session_short IN (...)."""
280
+ if not session_shorts:
281
+ return []
282
+
283
+ quoted = ", ".join(f"'{_sql_escape(s)}'" for s in session_shorts)
284
+ return self._flight_query(f"""
285
+ SELECT file_path, step_index, measurement_name, col_name,
286
+ role, row_idx, uri, channel_id, session_short, session_id
287
+ FROM measurement_refs
288
+ WHERE session_short IN ({quoted})
289
+ """)
290
+
291
+ def get_measurement(
292
+ self,
293
+ file_path: str | Path,
294
+ measurement_name: str,
295
+ *,
296
+ step_index: int | None = None,
297
+ ) -> list[dict[str, Any]]:
298
+ """Get rows for a specific measurement name, with predicate pushdown.
299
+
300
+ Uses pyarrow row-group filters so only matching row groups are read.
301
+ Raises DataUnavailable if the file is missing or marked non-ok in the index.
302
+ Returns [] if the parquet file exists but lacks a measurement_name column
303
+ (e.g. a file written by an older schema version).
304
+ """
305
+ pq_file = Path(file_path)
306
+ if not pq_file.exists():
307
+ status_rows = self._flight_query(
308
+ f"SELECT status FROM _ingested WHERE path = '{_sql_escape(str(pq_file))}'"
309
+ )
310
+ status = status_rows[0]["status"] if status_rows else "missing"
311
+ raise DataUnavailable(str(pq_file), status=status)
312
+
313
+ filters: list[tuple] = [("measurement_name", "=", measurement_name)]
314
+ if step_index is not None:
315
+ filters.append(("step_index", "=", step_index))
316
+
317
+ try:
318
+ table = pq.read_table(pq_file, filters=filters)
319
+ return table.to_pylist()
320
+ except Exception as exc:
321
+ logger.debug(
322
+ "Failed to read measurement %r from %s: %s", measurement_name, pq_file, exc
323
+ )
324
+ return []
325
+
326
+ @staticmethod
327
+ def ref_dir_for(file_path: Path) -> Path:
328
+ """Return _ref/ sidecar dir path for a parquet file."""
329
+ return file_path.parent / (file_path.stem + "_ref")
330
+
331
+ def notify_new_run(self, parquet_path: Path) -> None:
332
+ """Notify the daemon of a new unified per-run parquet via do_put."""
333
+ paths = [str(parquet_path)]
334
+ try:
335
+ client = self._flight.get_client()
336
+ descriptor = flight.FlightDescriptor.for_command(b"runs\0runs")
337
+ table = pa.table({"file_path": paths})
338
+ batches = table.to_batches()
339
+ writer, reader = client.do_put(descriptor, table.schema, options=call_options())
340
+ for batch in batches:
341
+ writer.write_batch(batch)
342
+ # Drain ACKs — each ACK confirms the server committed one batch,
343
+ # so by the time the last ACK arrives the daemon has fully ingested
344
+ # the file into all index tables including measurements_materialized.
345
+ for _ in batches:
346
+ reader.read()
347
+ writer.close()
348
+ except Exception:
349
+ logger.debug("Failed to notify runs daemon of new run %s", parquet_path, exc_info=True)
350
+ self._flight.reset()
351
+
352
+ def close(self) -> None:
353
+ """Close Flight client and release daemon ref."""
354
+ try:
355
+ self._flight.close()
356
+ except Exception as exc:
357
+ warnings.warn(f"FlightQueryClient close failed: {exc}", stacklevel=2)
358
+ try:
359
+ runs_duckdb_manager.release(self._runs_dir)
360
+ except Exception as exc:
361
+ warnings.warn(f"runs_duckdb_manager.release failed: {exc}", stacklevel=2)
362
+
363
+ def __enter__(self) -> RunStore:
364
+ return self
365
+
366
+ def __exit__(self, *_: object) -> None:
367
+ self.close()
@@ -0,0 +1,103 @@
1
+ """DuckDB run index daemon manager.
2
+
3
+ Subclasses ``DaemonManager`` for the runs DuckDB daemon.
4
+ Clients call ``acquire()`` / ``release()``.
5
+
6
+ Parquet files are the source of truth. The in-memory DuckDB index
7
+ is rebuilt from parquet on every daemon start.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import warnings
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from testerkit.data._daemon_lifecycle import DaemonManager, _installed_version, wait_for_location
17
+ from testerkit.data._flight_query import probe_sql
18
+
19
+
20
+ class RunsDuckDBManager(DaemonManager):
21
+ """Manages the DuckDB runs index daemon."""
22
+
23
+ _state_name = "_runs_duckdb.json"
24
+ _lock_name = "_runs_duckdb.lock"
25
+ _ready_name = "_runs_duckdb_ready"
26
+ _pid_name = "_runs_duckdb_pid"
27
+ _daemon_module = "testerkit.data._runs_duckdb_daemon"
28
+ _port_file = "_runs_duckdb_flight_port"
29
+
30
+ # Runs keys daemon reuse on the projection FINGERPRINT, not just the testerkit
31
+ # version (the base-class default). The fingerprint is a content-address of
32
+ # the read path (projection DDL + adapters + whitelist), so a daemon serves
33
+ # exactly one index shape + SQL. Keying reuse on it means a client never
34
+ # sends a daemon SQL for a different projection — the coexistence law of the
35
+ # index-epoch design (derived-index-versioning.md §11.1). ``_projection_
36
+ # fingerprint`` is imported lazily inside the methods to break the import
37
+ # cycle (the daemon module imports this manager).
38
+
39
+ def _daemon_identity(self) -> dict[str, Any]:
40
+ """Stamp the projection fingerprint (plus the version, for provenance)
41
+ into the state file, so ``_can_reuse`` can compare it. A projection
42
+ change — even within one testerkit version (a dev edit / branch switch,
43
+ invisible to the version ratchet) — yields a new fingerprint and thus a
44
+ fresh daemon on the matching epoch."""
45
+ from testerkit.data._runs_duckdb_daemon import _projection_fingerprint
46
+
47
+ return {"testerkit_version": _installed_version(), "fingerprint": _projection_fingerprint()}
48
+
49
+ def _can_reuse(self, running_state: dict[str, Any]) -> bool:
50
+ """Reuse only a daemon whose projection fingerprint matches ours exactly.
51
+ A different — or missing (a pre-fingerprint daemon) — fingerprint means it
52
+ serves a different-shaped index/SQL, so respawn rather than send it queries
53
+ it can't answer. Fingerprint equality subsumes the version ratchet: a
54
+ version bump that changes the projection changes the fingerprint (respawn);
55
+ one that doesn't keeps it (reuse)."""
56
+ from testerkit.data._runs_duckdb_daemon import _projection_fingerprint
57
+
58
+ return running_state.get("fingerprint") == _projection_fingerprint()
59
+
60
+
61
+ # Module-level convenience — RunStore uses these directly.
62
+
63
+
64
+ def acquire(runs_dir: Path) -> str:
65
+ """Acquire a reference to the runs DuckDB daemon, starting it if needed.
66
+
67
+ Returns the gRPC location string for Flight queries.
68
+
69
+ The location is written to state file by either
70
+ :meth:`DaemonManager.acquire` (fresh spawn — reads the port file via
71
+ ``_post_spawn_state``) or by the daemon itself
72
+ (:meth:`DaemonManager.update_state` after ``write_ready``). Under
73
+ load on slow CI runners we've seen reuse hit a state file the
74
+ daemon hasn't finished filling in yet — poll briefly so transient
75
+ "missing location" cases don't fail the test.
76
+
77
+ After getting the location, a lightweight Flight connectivity probe
78
+ is attempted. If the Flight server isn't responding (e.g. the daemon
79
+ process is alive but its Flight thread crashed), the daemon is killed
80
+ and respawned so callers always get a working connection.
81
+ """
82
+ mgr = RunsDuckDBManager(runs_dir)
83
+ mgr.acquire()
84
+ location = wait_for_location(mgr, runs_dir, "runs")
85
+
86
+ # Verify the Flight server is actually responding. The PID may be alive
87
+ # but the Flight thread may have crashed (e.g. port conflict, OOM).
88
+ # One cheap probe avoids leaving callers with a silent dead connection.
89
+ if not probe_sql(location, "runs"):
90
+ warnings.warn(
91
+ f"Runs daemon at {location} is not responding — killing and respawning.",
92
+ stacklevel=2,
93
+ )
94
+ mgr.force_restart()
95
+ mgr.acquire()
96
+ location = wait_for_location(mgr, runs_dir, "runs")
97
+
98
+ return location
99
+
100
+
101
+ def release(runs_dir: Path) -> None:
102
+ """Release our reference to the runs DuckDB daemon."""
103
+ RunsDuckDBManager(runs_dir).release()
@@ -0,0 +1,191 @@
1
+ """Read-time schema-version dispatch — the seam where a durable artifact's
2
+ stamp selects the adapter that projects it to the current shape.
3
+
4
+ Called exactly once per store, at that store's ``durable-artifact -> rows``
5
+ read boundary (§1/§4 of ``schema-versioning-migration.md``). The whole
6
+ versioning surface reduces to:
7
+
8
+ stamp --dispatch--> adapter(source_version -> current) --> current rows
9
+
10
+ Three outcomes when a file's stamp is read:
11
+
12
+ - **present and whitelisted** (in :data:`KNOWN_SCHEMA_VERSIONS`) -> return that
13
+ version's adapter (identity today: ``0.1`` == current, no transform).
14
+ - **present but unknown** (a future ``5.0``, or an abandoned version) -> refuse
15
+ with :class:`SchemaVersionRefused` ("unsupported schema version").
16
+ - **absent** (unstamped) -> refuse ("regenerate"). The current version is always
17
+ stamped at write, so absence can only mean a pre-stamp file.
18
+
19
+ Each store's read boundary catches :class:`SchemaVersionRefused` and routes it
20
+ into that store's existing skip/quarantine path, so one bad-version file is
21
+ isolated and the good ones still land.
22
+
23
+ The adapter registry ships **current-identity only** — no speculative
24
+ transforms. A real ``vN -> current`` adapter is registered here the same commit
25
+ its source version joins :data:`KNOWN_SCHEMA_VERSIONS`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ import threading
32
+ from collections.abc import Callable
33
+ from typing import Any
34
+
35
+ from testerkit.data.schema_versions import KNOWN_SCHEMA_VERSIONS, SchemaStore
36
+
37
+ _logger = logging.getLogger(__name__)
38
+
39
+ # An adapter is a pure transform from a source-version artifact to the current
40
+ # shape. For the Arrow stores (runs / events / channels) it is
41
+ # ``pa.Table -> pa.Table``; for the files sidecar it is
42
+ # ``FileArtifactMetadata -> FileArtifactMetadata``. Kept structurally typed
43
+ # (``Any -> Any``) because the registry spans both.
44
+ Adapter = Callable[[Any], Any]
45
+
46
+
47
+ def _schema_version_tuple(v: str) -> tuple[int, ...] | None:
48
+ """Parse a dotted schema version into a comparable int tuple; ``None`` if
49
+ unparseable. Named distinctly from ``_daemon_lifecycle._version_tuple``,
50
+ which *leniently* parses PACKAGE versions (``.dev``/``.rc`` suffixes); schema
51
+ versions are clean ``major.minor`` and parse strictly."""
52
+ try:
53
+ return tuple(int(part) for part in v.split("."))
54
+ except (ValueError, AttributeError):
55
+ return None
56
+
57
+
58
+ class SchemaVersionRefused(Exception):
59
+ """A durable artifact carries a schema version this store cannot read.
60
+
61
+ ``deferrable`` splits the two refusal classes that need different fates at
62
+ the read boundary (see #43):
63
+
64
+ - **deferrable=True** — the stamp is a version *newer* than anything this
65
+ daemon knows. The machine's singleton daemon trends newest, so a newer
66
+ daemon WILL read it — the file must stay re-attemptable (never permanently
67
+ ledgered), else it goes invisible forever after the daemon is upgraded.
68
+ - **deferrable=False** — absent (unstamped) or an unparseable / older-unknown
69
+ stamp. No future daemon will read it: permanent skip (regenerate).
70
+ """
71
+
72
+ def __init__(
73
+ self, store: SchemaStore, version: str | None, reason: str, *, deferrable: bool
74
+ ) -> None:
75
+ self.store = store
76
+ self.version = version
77
+ self.deferrable = deferrable
78
+ super().__init__(f"[{store.value}] schema version {version!r}: {reason}")
79
+
80
+
81
+ # ── Adapter registry ────────────────────────────────────────────────────────
82
+ # One transform per (store, source_version) -> current. Empty today (every
83
+ # known version IS current, so dispatch returns identity). ``register_adapter``
84
+ # is how a future epoch's transform is added; ``_identity`` is the current no-op.
85
+
86
+
87
+ def _identity(rows: Any) -> Any:
88
+ """Identity adapter (the current-version no-op). Adapter contract for future
89
+ authors: an adapter MUST NOT mutate its input in place — return the value.
90
+ Returning the input unchanged is correct for this no-op (Arrow tables are
91
+ immutable anyway; the migrate sink calls ``.replace_schema_metadata()`` on the
92
+ result, which copies). A real transform returns a new table / copied model."""
93
+ return rows
94
+
95
+
96
+ _ADAPTERS: dict[SchemaStore, dict[str, Adapter]] = {store: {} for store in SchemaStore}
97
+
98
+
99
+ def register_adapter(store: SchemaStore, source_version: str, adapter: Adapter) -> None:
100
+ """Register a ``source_version -> current`` transform for *store*, and make
101
+ that version readable in the same step.
102
+
103
+ Registering an adapter also adds ``source_version`` to
104
+ ``KNOWN_SCHEMA_VERSIONS[store]`` — otherwise the adapter would be dead code:
105
+ :func:`dispatch` refuses an unknown version *before* it ever reaches the
106
+ adapter. Self-wiring removes that two-edit footgun.
107
+ """
108
+ _ADAPTERS[store][source_version] = adapter
109
+ KNOWN_SCHEMA_VERSIONS[store] = KNOWN_SCHEMA_VERSIONS[store] | {source_version}
110
+
111
+
112
+ def dispatch(store: SchemaStore, version: str | None) -> Adapter:
113
+ """Whitelist-dispatch *version* for *store*; return its adapter or refuse.
114
+
115
+ Returns the registered adapter, or :func:`_identity` when the version is
116
+ known but current-shaped (the current-version case). Raises
117
+ :class:`SchemaVersionRefused` for an unknown or absent version.
118
+ """
119
+ if version is None:
120
+ raise SchemaVersionRefused(
121
+ store,
122
+ None,
123
+ "unstamped artifact — unsupported by design; regenerate",
124
+ deferrable=False,
125
+ )
126
+ if version not in KNOWN_SCHEMA_VERSIONS[store]:
127
+ known = ", ".join(sorted(KNOWN_SCHEMA_VERSIONS[store]))
128
+ # Deferrable iff strictly NEWER than every known version — a newer daemon
129
+ # will read it. Older / unparseable-unknown is pre-baseline or garbage: permanent.
130
+ vt = _schema_version_tuple(version)
131
+ known_tuples = [
132
+ t for t in (_schema_version_tuple(k) for k in KNOWN_SCHEMA_VERSIONS[store]) if t
133
+ ]
134
+ deferrable = bool(vt and known_tuples and vt > max(known_tuples))
135
+ raise SchemaVersionRefused(
136
+ store,
137
+ version,
138
+ f"unsupported schema version (known: {known})",
139
+ deferrable=deferrable,
140
+ )
141
+ return _ADAPTERS[store].get(version, _identity)
142
+
143
+
144
+ # ── Refusal reporting ────────────────────────────────────────────────────────
145
+ # Process-local dedup so the presence-only stores (channels / files), which
146
+ # re-read an un-ledgered refusal every scan, don't re-warn each time. Not
147
+ # backend-coupled — plain stdlib logging over a set of already-warned paths.
148
+ # Capped so a pathological flood of distinct bad-version files can't grow it
149
+ # without bound; clearing at the cap just lets each path warn once more.
150
+ _MAX_WARNED_PATHS = 4096
151
+ _warned_permanent_refusals: set[str] = set()
152
+ # A daemon may report refusals from more than one ingest thread; the lock keeps
153
+ # the check-and-add atomic so a path warns exactly once (else two threads racing
154
+ # the same path each emit the warning).
155
+ _warned_lock = threading.Lock()
156
+
157
+
158
+ def report_schema_refusal(exc: SchemaVersionRefused, path: str) -> None:
159
+ """Surface a refusal uniformly across all four store boundaries: a
160
+ *deferrable* (newer) refusal is a transient debug note (a newer daemon will
161
+ ingest it); a *permanent* (absent / unstamped) refusal is warned once per path
162
+ so the operator knows the file needs regenerating.
163
+ """
164
+ if exc.deferrable:
165
+ _logger.debug("Deferring newer-version artifact %s: %s", path, exc)
166
+ return
167
+ with _warned_lock:
168
+ if path in _warned_permanent_refusals:
169
+ return
170
+ if len(_warned_permanent_refusals) >= _MAX_WARNED_PATHS:
171
+ _warned_permanent_refusals.clear()
172
+ _warned_permanent_refusals.add(path)
173
+ # Log outside the lock (I/O): only the winning thread reaches here per path.
174
+ _logger.warning("Skipping unsupported (unstamped/unknown) schema in %s: %s", path, exc)
175
+
176
+
177
+ # ── Stamp extraction ────────────────────────────────────────────────────────
178
+
179
+
180
+ def stamp_from_arrow_metadata(
181
+ metadata: dict[bytes, bytes] | None, key: bytes = b"schema_version"
182
+ ) -> str | None:
183
+ """Read a schema-version stamp from Arrow/parquet file-level metadata.
184
+
185
+ Returns ``None`` if the metadata is absent or lacks *key* — which
186
+ :func:`dispatch` treats as an unstamped artifact.
187
+ """
188
+ if not metadata:
189
+ return None
190
+ raw = metadata.get(key)
191
+ return raw.decode() if raw is not None else None