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,768 @@
1
+ """Storage-agnostic event API.
2
+
3
+ Callers use ``emit()``, ``events()``, ``on_event()`` — never see paths,
4
+ files, or SQL. Storage details (Arrow IPC files, DuckDB queries)
5
+ are internal.
6
+
7
+ Dual-write pattern: emit() writes Arrow IPC file (crash safety) and
8
+ pushes to in-memory DuckDB daemon via Flight (immediate queryability).
9
+
10
+ Usage::
11
+
12
+ from testerkit.data.event_store import EventStore
13
+
14
+ store = EventStore() # resolves storage location automatically
15
+ store.emit(some_event) # write + notify in-process subscribers
16
+ store.events(role="dmm") # query
17
+ unsub = store.on_event(cb) # catch-up subscription
18
+ unsub() # stop receiving
19
+ store.close()
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json as json_mod
25
+ import logging
26
+ import threading
27
+ import warnings
28
+ import weakref
29
+ from collections import OrderedDict
30
+ from collections.abc import Callable
31
+ from datetime import datetime
32
+ from pathlib import Path
33
+ from typing import Any
34
+ from uuid import UUID
35
+
36
+ import pyarrow as pa
37
+ import pyarrow.flight as flight
38
+
39
+ from testerkit.data import duckdb_manager
40
+ from testerkit.data._duckdb_flight_server import FlightPutStream
41
+ from testerkit.data._event_filters import event_matches_role
42
+ from testerkit.data._flight_query import FlightQueryClient
43
+ from testerkit.data._sql_helpers import sql_escape as _sql_escape
44
+ from testerkit.data.data_dir import resolve_data_dir
45
+ from testerkit.data.event_log import EventLog
46
+ from testerkit.data.events import EventBase
47
+ from testerkit.data.models import ensure_utc
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ def _parse_event_row(row: dict[str, Any]) -> dict[str, Any]:
53
+ """Return the parsed event dict from a DB row, falling back to the raw row.
54
+
55
+ ``writer_key`` and ``event_offset`` are column-level (the writer stamps
56
+ them at append; they are not inside the JSON payload), so merge them onto
57
+ the parsed event. Consumers order by ``(writer_key, event_offset)`` for
58
+ emit order — the order survives the do_put/ingest insert race.
59
+ """
60
+ json_str = row.get("json")
61
+ if json_str:
62
+ try:
63
+ parsed = json_mod.loads(json_str)
64
+ for col in ("writer_key", "event_offset"):
65
+ if col in row:
66
+ parsed[col] = row[col]
67
+ return parsed
68
+ except (json_mod.JSONDecodeError, TypeError):
69
+ pass
70
+ return row
71
+
72
+
73
+ def _parse_timestamp(ts: object) -> datetime | None:
74
+ """Parse a timestamp value to a UTC-aware datetime. Returns None if unparseable."""
75
+ if isinstance(ts, datetime):
76
+ return ensure_utc(ts)
77
+ if isinstance(ts, str):
78
+ try:
79
+ return ensure_utc(datetime.fromisoformat(ts))
80
+ except ValueError:
81
+ return None
82
+ return None
83
+
84
+
85
+ def _is_before(evt: dict, cutoff: datetime) -> bool:
86
+ """Return True if the event's timestamp is before ``cutoff``."""
87
+ ts = evt.get("received_at") or evt.get("occurred_at")
88
+ if ts is None:
89
+ return False
90
+ parsed = _parse_timestamp(ts)
91
+ return parsed is not None and parsed < cutoff
92
+
93
+
94
+ def _finalize_event_store(put_stream, watcher_stop, event_logs):
95
+ """Best-effort teardown for an EventStore that was never close()d.
96
+ Bound via weakref.finalize to the resource objects, never to self."""
97
+ watcher_stop.set() # the daemon watcher thread exits on its next poll
98
+ for log in list(event_logs.values()):
99
+ try:
100
+ log.close()
101
+ except Exception:
102
+ pass
103
+ event_logs.clear()
104
+ try:
105
+ put_stream.close()
106
+ except Exception:
107
+ pass
108
+
109
+
110
+ class _Subscription:
111
+ """Internal subscription record."""
112
+
113
+ __slots__ = ("callback", "event_type", "role", "run_id", "session_id", "since")
114
+
115
+ def __init__(
116
+ self,
117
+ callback: Callable[[dict], None],
118
+ *,
119
+ event_type: str | None = None,
120
+ role: str | None = None,
121
+ session_id: UUID | None = None,
122
+ run_id: UUID | None = None,
123
+ since: datetime | None = None,
124
+ ) -> None:
125
+ self.callback = callback
126
+ self.event_type = event_type
127
+ self.role = role
128
+ self.session_id = session_id
129
+ self.run_id = run_id
130
+ self.since = since
131
+
132
+ def matches(self, evt: dict) -> bool:
133
+ if self.event_type and evt.get("event_type") != self.event_type:
134
+ return False
135
+ if self.role and not event_matches_role(evt, self.role):
136
+ return False
137
+ if self.session_id and evt.get("session_id") != str(self.session_id):
138
+ return False
139
+ if self.run_id and evt.get("run_id") != str(self.run_id):
140
+ return False
141
+ if self.since and _is_before(evt, self.since):
142
+ return False
143
+ return True
144
+
145
+
146
+ class EventStore:
147
+ """Storage-agnostic event API. Callers never see paths, files, or SQL.
148
+
149
+ Construct once and reuse. ``close()`` / ``with`` are optional — the
150
+ daemon is a separate process that self-manages via PID-ref and idle
151
+ timeout. Use ``get_shared()`` to share the watcher thread across callers
152
+ in the same process.
153
+ """
154
+
155
+ _shared: dict[Path, EventStore] = {}
156
+
157
+ @classmethod
158
+ def get_shared(cls, data_dir: Path | None = None) -> EventStore:
159
+ """Return a process-wide shared instance for ``data_dir``.
160
+
161
+ Multiple callers for the same directory share one watcher thread
162
+ instead of each spawning their own. UI page handlers should use
163
+ this instead of ``EventStore(...)`` so the thread count stays flat.
164
+ """
165
+ key = resolve_data_dir(data_dir)
166
+ if key not in cls._shared:
167
+ cls._shared[key] = cls(_data_dir=key)
168
+ return cls._shared[key]
169
+
170
+ def __init__(self, *, _data_dir: Path | str | None = None) -> None:
171
+ self._data_dir = resolve_data_dir(_data_dir)
172
+ self._events_dir = self._data_dir / "events"
173
+ self._events_dir.mkdir(parents=True, exist_ok=True)
174
+
175
+ # Start daemon and get gRPC location for Flight queries
176
+ self._location = duckdb_manager.acquire(self._events_dir)
177
+
178
+ # Capture as a local so the reacquire lambdas close over a Path, not
179
+ # self — breaking the reference cycle that would prevent GC from
180
+ # collecting this EventStore when the user forgets to call close().
181
+ _events_dir = self._events_dir
182
+
183
+ # Flight query client (shared retry logic with RunStore)
184
+ self._flight = FlightQueryClient(
185
+ self._location,
186
+ "events",
187
+ reacquire=lambda: duckdb_manager.acquire(_events_dir),
188
+ label="EventStore",
189
+ )
190
+
191
+ # Persistent do_put stream (for writes). Same reacquire as the query
192
+ # client so the write path self-heals across a daemon restart: the
193
+ # stream reacquires a fresh daemon and resends un-acked batches
194
+ # (idempotent — the events insert is ON CONFLICT (id) DO NOTHING).
195
+ self._put_stream = FlightPutStream(
196
+ self._location,
197
+ "events",
198
+ "events",
199
+ reacquire=lambda: duckdb_manager.acquire(_events_dir),
200
+ )
201
+
202
+ # Internal writer per session (created lazily via get_event_log)
203
+ self._event_logs: dict[UUID, EventLog] = {}
204
+
205
+ # In-process subscriptions
206
+ self._subscriptions: list[_Subscription] = []
207
+ self._lock = threading.RLock()
208
+
209
+ # Track event IDs delivered in-process to avoid duplicate delivery
210
+ # from the cross-process watcher. Bounded LRU (FIFO eviction) so
211
+ # long-running orchestrators don't leak memory; the watcher receives a
212
+ # held-open push stream from an ``event_number`` cursor, so old IDs are
213
+ # never re-delivered and don't need to be retained beyond the window.
214
+ self._delivered_ids: OrderedDict[str, None] = OrderedDict()
215
+ self._delivered_ids_max = 10000
216
+
217
+ # Cross-process watcher
218
+ self._watcher_thread: threading.Thread | None = None
219
+ self._watcher_stop = threading.Event()
220
+
221
+ # weakref.finalize fires on GC OR interpreter exit, so the lazy notebook
222
+ # path (never close()) is covered without a separate atexit.
223
+ self._finalizer = weakref.finalize(
224
+ self, _finalize_event_store, self._put_stream, self._watcher_stop, self._event_logs
225
+ )
226
+
227
+ def _flight_query(self, sql: str) -> list[dict[str, Any]]:
228
+ """Execute a SQL query via Flight and return list of dicts.
229
+
230
+ Retry policy lives in :class:`FlightQueryClient` —
231
+ TRANSIENT errors retry with exponential backoff, PERMANENT
232
+ errors (Binder, Catalog, syntax) raise immediately as
233
+ :class:`FlightPermanentError`.
234
+ """
235
+ return self._flight.query(sql)
236
+
237
+ def _flight_put(self, batch: pa.RecordBatch) -> None:
238
+ """Push an Arrow batch to the daemon via persistent do_put stream.
239
+
240
+ Does not block for server confirmation — call ``_drain_puts()``
241
+ before querying for read-after-write consistency.
242
+ """
243
+ try:
244
+ self._put_stream.write(batch)
245
+ except Exception as exc:
246
+ # Non-fatal: data is in IPC file, daemon will rebuild on restart
247
+ warnings.warn(f"Flight put failed (non-fatal): {exc}", stacklevel=2)
248
+
249
+ # -- Write path ----------------------------------------------------------
250
+
251
+ def _dispatch_to_subscribers(self, evt_dict: dict) -> None:
252
+ """Dispatch an event dict to matching subscribers (caller holds lock)."""
253
+ for sub in self._subscriptions:
254
+ if sub.matches(evt_dict):
255
+ try:
256
+ sub.callback(evt_dict)
257
+ except Exception as exc:
258
+ warnings.warn(
259
+ f"Event subscriber failed: {exc}",
260
+ stacklevel=2,
261
+ )
262
+
263
+ def _notify_subscribers(self, event: EventBase) -> None:
264
+ """Notify in-process subscribers about a new event."""
265
+ evt_dict = event.model_dump(mode="json")
266
+ event_id = str(event.id)
267
+ with self._lock:
268
+ self._delivered_ids[event_id] = None
269
+ while len(self._delivered_ids) > self._delivered_ids_max:
270
+ self._delivered_ids.popitem(last=False)
271
+ self._dispatch_to_subscribers(evt_dict)
272
+
273
+ def get_event_log(self, session_id: UUID) -> EventLog:
274
+ """Get or create an EventLog for a session.
275
+
276
+ The returned EventLog is wired to notify this store's subscribers
277
+ on every emit, bridging per-session writes to store-level subscriptions.
278
+ """
279
+ if session_id not in self._event_logs:
280
+ self._event_logs[session_id] = EventLog(
281
+ self._events_dir,
282
+ session_id,
283
+ on_emit=self._notify_subscribers,
284
+ on_flush=self._flight_put,
285
+ )
286
+ return self._event_logs[session_id]
287
+
288
+ def emit(self, event: EventBase, *, session_id: UUID | None = None) -> None:
289
+ """Buffer event for batched IPC write, push to DuckDB on flush.
290
+
291
+ Dual-write: Arrow IPC file for crash safety, Flight do_put for
292
+ queryability. Both writes are batched at flush threshold — same
293
+ pattern as ChannelStore.
294
+ """
295
+ sid = session_id or getattr(event, "session_id", None)
296
+ if sid is None:
297
+ raise ValueError("Event must have session_id or pass session_id to emit()")
298
+
299
+ log = self.get_event_log(sid)
300
+ log.emit(event)
301
+
302
+ # Note: _notify_subscribers is called by EventLog.on_emit callback,
303
+ # and _flight_put is called by EventLog.on_flush callback.
304
+
305
+ def flush(self) -> None:
306
+ """Flush all buffered events to IPC files and Flight.
307
+
308
+ Call this after emitting events that must be visible to other
309
+ processes immediately (e.g., sync events). Drains the
310
+ persistent do_put stream too so the events daemon has acked
311
+ every batch before flush returns — without that drain, IPC is
312
+ on disk but the events daemon's table may not yet contain the
313
+ rows that subsequent cross-process queries / subscribers
314
+ depend on.
315
+ """
316
+ for log in list(self._event_logs.values()):
317
+ log.flush()
318
+ try:
319
+ self._put_stream.drain()
320
+ except Exception as exc: # noqa: BLE001 — drain is best-effort; data is already in IPC
321
+ logger.debug("put-stream drain failed (non-fatal): %s", exc)
322
+
323
+ # -- Read path -----------------------------------------------------------
324
+
325
+ def events(
326
+ self,
327
+ *,
328
+ session_id: UUID | None = None,
329
+ run_id: UUID | None = None,
330
+ event_type: str | None = None,
331
+ role: str | None = None,
332
+ since: datetime | None = None,
333
+ until: str | None = None,
334
+ until_event_number: int | None = None,
335
+ limit: int | None = None,
336
+ ) -> list[dict]:
337
+ """Query events from the DuckDB index via Flight.
338
+
339
+ ``limit`` pushes the row cap into the SQL so the daemon
340
+ returns at most ``limit`` rows instead of streaming the
341
+ full event log over Flight. Critical for projects with
342
+ large IPC histories — without it, even a "show me the
343
+ latest 100 events" page pulls millions of rows.
344
+
345
+ ``limit`` is applied to the **most recent** rows (the SQL
346
+ sorts ``received_at DESC`` under the limit, then re-sorts
347
+ ASC for the caller). ``None`` returns all matching rows.
348
+ """
349
+ # Flush any buffered events to IPC + Flight before querying
350
+ # (on_flush callback pushes batches to Flight automatically)
351
+ for log in list(self._event_logs.values()):
352
+ log.flush()
353
+ try:
354
+ self._put_stream.drain()
355
+ except Exception as exc: # noqa: BLE001
356
+ logger.debug("put-stream drain failed (non-fatal): %s", exc)
357
+ # Build SQL via f-string — safe because inputs are typed:
358
+ # session_id is UUID (validated by caller), event_type is a known
359
+ # enum string, since is a datetime. sql_escape guards against quotes.
360
+ # Flight do_get does not support parameterized queries.
361
+ conditions: list[str] = []
362
+ if session_id:
363
+ conditions.append(f"session_id = '{_sql_escape(str(session_id))}'")
364
+ if run_id:
365
+ conditions.append(f"run_id = '{_sql_escape(str(run_id))}'")
366
+ if event_type:
367
+ conditions.append(f"event_type = '{_sql_escape(event_type)}'")
368
+ if since:
369
+ conditions.append(f"received_at >= '{_sql_escape(since.isoformat())}'")
370
+ if until:
371
+ conditions.append(f"received_at <= '{_sql_escape(until)}'")
372
+ if until_event_number is not None:
373
+ conditions.append(f"event_number <= {int(until_event_number)}")
374
+ if role:
375
+ # Mirrors :func:`event_matches_role` in SQL: ``role``,
376
+ # ``instrument_role``, and the ``channel_id`` prefix all
377
+ # qualify. All three columns are promoted typed columns
378
+ # so the daemon plans an index/columnar scan on each
379
+ # branch — no per-row Python parse.
380
+ r = _sql_escape(role)
381
+ conditions.append(
382
+ f"(role = '{r}' OR instrument_role = '{r}' OR channel_id LIKE '{r}.%')"
383
+ )
384
+ where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
385
+
386
+ # Narrow projection — the typed payload columns exist purely
387
+ # to push down WHERE filters; their values are duplicates of
388
+ # fields inside ``json``, and parsing JSON reconstitutes the
389
+ # full event. ``SELECT *`` would ship every typed column
390
+ # back over Flight (mostly NULL) and inflate row size by 22
391
+ # extra strings. Envelope-only keeps the network payload
392
+ # constant regardless of how many typed columns we add later.
393
+ projection = (
394
+ "id, event_type, event_number, occurred_at, received_at, "
395
+ "session_id, run_id, writer_key, event_offset, json"
396
+ )
397
+
398
+ # Latest-N pushdown: SQL sorts received_at DESC under the
399
+ # LIMIT (cheap with the index on received_at), then re-sorts
400
+ # ASC in the outer SELECT so the caller still sees
401
+ # chronological order. Without this, "show me the last 100
402
+ # events" pulls every event over Flight.
403
+ if limit is not None and limit > 0:
404
+ sql = f"""
405
+ SELECT * FROM (
406
+ SELECT {projection}
407
+ FROM events
408
+ {where}
409
+ ORDER BY received_at DESC
410
+ LIMIT {int(limit)}
411
+ )
412
+ ORDER BY received_at ASC
413
+ """
414
+ else:
415
+ sql = f"""
416
+ SELECT {projection}
417
+ FROM events
418
+ {where}
419
+ ORDER BY received_at ASC
420
+ """
421
+ rows = self._flight_query(sql)
422
+
423
+ return [_parse_event_row(row) for row in rows]
424
+
425
+ def sessions(self) -> list[dict]:
426
+ """List known sessions with metadata from SessionStarted events."""
427
+ return self.events(event_type="session.started")
428
+
429
+ def events_for_unmaterialized_runs(
430
+ self,
431
+ *,
432
+ since: datetime | None = None,
433
+ until: str | None = None,
434
+ until_event_number: int | None = None,
435
+ ) -> list[dict]:
436
+ """Return events for runs that have ``RunStarted`` but no ``RunMaterialized``.
437
+
438
+ The dual of "runs whose derived view is durable in a query-optimized
439
+ backend." A run is either:
440
+
441
+ * **Materialized** — a ``RunMaterialized`` event exists. The run is
442
+ durably stored somewhere (today: parquet ingested into the runs
443
+ daemon's ``runs_materialized`` table; future: Postgres, Snowflake,
444
+ etc.). The materializer pool no longer needs to track it.
445
+ * **Unmaterialized** — no ``RunMaterialized`` yet. The run's events
446
+ are persisted in the EventStore but no derived view exists. The
447
+ materializer pool tracks it via per-run accumulator state.
448
+
449
+ Called by the runs daemon on attach to replay the unmaterialized set
450
+ into its in-memory accumulator pool. The replay set is naturally
451
+ bounded by the rate at which runs are materialized; in production
452
+ steady state, it's the count of currently-in-flight runs plus a
453
+ small tail of just-finished runs awaiting materialization.
454
+
455
+ ``since`` further bounds the result to events received after that
456
+ timestamp.
457
+ """
458
+ for log in list(self._event_logs.values()):
459
+ log.flush()
460
+ try:
461
+ self._put_stream.drain()
462
+ except Exception as exc: # noqa: BLE001
463
+ logger.debug("put-stream drain failed (non-fatal): %s", exc)
464
+ clauses: list[str] = []
465
+ if since:
466
+ clauses.append(f"received_at >= '{_sql_escape(since.isoformat())}'")
467
+ if until:
468
+ clauses.append(f"received_at <= '{_sql_escape(until)}'")
469
+ if until_event_number is not None:
470
+ clauses.append(f"event_number <= {int(until_event_number)}")
471
+ where_extra = (" AND " + " AND ".join(clauses)) if clauses else ""
472
+ # Envelope-only projection — see ``events()`` for the rationale.
473
+ sql = f"""
474
+ SELECT id, event_type, event_number, occurred_at, received_at,
475
+ session_id, run_id, writer_key, event_offset, json
476
+ FROM events
477
+ WHERE run_id IS NOT NULL
478
+ AND run_id IN (
479
+ SELECT DISTINCT run_id FROM events WHERE event_type = 'run.started'
480
+ EXCEPT
481
+ SELECT DISTINCT run_id FROM events WHERE event_type = 'run.materialized'
482
+ ){where_extra}
483
+ ORDER BY writer_key, event_offset
484
+ """
485
+ rows = self._flight_query(sql)
486
+ return [_parse_event_row(row) for row in rows]
487
+
488
+ # -- Watch path ----------------------------------------------------------
489
+
490
+ def on_event(
491
+ self,
492
+ callback: Callable[[dict], None],
493
+ *,
494
+ event_type: str | None = None,
495
+ role: str | None = None,
496
+ session_id: UUID | None = None,
497
+ run_id: UUID | None = None,
498
+ since: datetime | None = None,
499
+ replay: str = "matching",
500
+ ) -> Callable[[], None]:
501
+ """Catch-up subscription.
502
+
503
+ Replays past events on attach, then pushes new ones as they arrive.
504
+
505
+ ``replay`` controls the catch-up strategy:
506
+
507
+ * ``"matching"`` (default) — replay every event matching the
508
+ ``event_type`` / ``role`` / ``session_id`` / ``since`` filters.
509
+ * ``"unmaterialized_runs"`` — replay events for runs with
510
+ ``RunStarted`` but no ``RunMaterialized``. The materializer's
511
+ replay set: every run still tracked in-memory because its
512
+ derived view hasn't been written yet. See
513
+ :meth:`events_for_unmaterialized_runs`.
514
+ * ``"none"`` — skip replay entirely; deliver only future events.
515
+
516
+ In-process: instant dispatch on ``emit()``.
517
+ Cross-process: a held-open push stream delivers new events as they land.
518
+
519
+ Returns an unsubscribe callable.
520
+ """
521
+ # Capture the watcher's cursor BEFORE running replay. The cursor
522
+ # is a monotonic ``event_number`` (insert-order sequence stamped
523
+ # by the events daemon's ``nextval('event_seq')`` under the
524
+ # same lock as the INSERT). Replay covers
525
+ # ``event_number <= snapshot``; the watcher receives
526
+ # ``event_number > snapshot`` live. ``event_number`` is bulletproof
527
+ # against the wall-clock race ``received_at`` had — two
528
+ # put-hook batches under the same lock can finish out of order
529
+ # vs. their transaction-start timestamps, but ``nextval()``
530
+ # advances under the lock and is strictly monotonic with
531
+ # commit order.
532
+ try:
533
+ cursor_rows = self._flight_query("SELECT MAX(event_number) AS m FROM events")
534
+ cursor_event_number = (
535
+ int(cursor_rows[0]["m"]) if cursor_rows and cursor_rows[0].get("m") else 0
536
+ )
537
+ except Exception as exc: # noqa: BLE001
538
+ logger.debug("Cursor snapshot before replay failed: %s", exc)
539
+ cursor_event_number = 0
540
+
541
+ # Register the subscription and start the watcher BEFORE running
542
+ # replay. New events arriving from the cursor onwards flow through
543
+ # the watcher into the callback immediately. Historical (pre-cursor)
544
+ # events flow through replay in a background thread.
545
+ #
546
+ # Why async replay: when the events DB has accumulated many
547
+ # unmaterialized runs (e.g., a long-running dev environment with
548
+ # historical test cruft), synchronous replay can take seconds-to-
549
+ # minutes before the watcher gets started. New events emitted
550
+ # during that window aren't visible to the subscriber until
551
+ # replay completes — which can cause test timeouts and operator
552
+ # UI staleness on cold daemon start. Decoupling lets the live
553
+ # path keep up with real-time events while the materializer
554
+ # catches up on backlog in the background.
555
+ #
556
+ # ``_delivered_ids`` deduplicates between the two paths: each
557
+ # dispatched event id is stamped, and both paths check before
558
+ # delivering, so each event reaches the callback exactly once
559
+ # regardless of which path saw it first.
560
+ sub = _Subscription(
561
+ callback,
562
+ event_type=event_type,
563
+ role=role,
564
+ session_id=session_id,
565
+ run_id=run_id,
566
+ since=since,
567
+ )
568
+ with self._lock:
569
+ self._subscriptions.append(sub)
570
+
571
+ self._ensure_watcher(initial_cursor=cursor_event_number)
572
+
573
+ if replay != "none":
574
+ # Replay covers event_number <= cursor (inclusive); watcher
575
+ # covers event_number > cursor. No boundary overlap (replay
576
+ # is ``<=``, watcher is ``>``) — ``_delivered_ids`` still
577
+ # dedups any in-process emit that raced replay.
578
+ replay_until_event_number = cursor_event_number
579
+
580
+ def _replay_in_background() -> None:
581
+ if replay == "unmaterialized_runs":
582
+ existing = self.events_for_unmaterialized_runs(
583
+ until_event_number=replay_until_event_number,
584
+ )
585
+ else:
586
+ existing = self.events(
587
+ session_id=session_id,
588
+ run_id=run_id,
589
+ event_type=event_type,
590
+ role=role,
591
+ since=since,
592
+ until_event_number=replay_until_event_number,
593
+ )
594
+ # Dispatch each event to the new subscriber's callback,
595
+ # then stamp ``_delivered_ids`` so the watcher dedups
596
+ # against the boundary on the first pushed batch. Mirrors the
597
+ # sync-replay behaviour, just in a background thread so
598
+ # the watcher can serve real-time events concurrently.
599
+ for evt in existing:
600
+ try:
601
+ callback(evt)
602
+ evt_id = str(evt.get("id") or "")
603
+ if evt_id:
604
+ with self._lock:
605
+ self._delivered_ids[evt_id] = None
606
+ while len(self._delivered_ids) > self._delivered_ids_max:
607
+ self._delivered_ids.popitem(last=False)
608
+ except Exception as exc: # noqa: BLE001
609
+ warnings.warn(
610
+ f"Event subscriber failed during replay: {exc}",
611
+ stacklevel=2,
612
+ )
613
+
614
+ threading.Thread(
615
+ target=_replay_in_background,
616
+ daemon=True,
617
+ name=f"event-replay-{replay}",
618
+ ).start()
619
+
620
+ def unsubscribe() -> None:
621
+ with self._lock:
622
+ try:
623
+ self._subscriptions.remove(sub)
624
+ except ValueError:
625
+ pass
626
+
627
+ return unsubscribe
628
+
629
+ def _ensure_watcher(self, *, initial_cursor: int = 0) -> None:
630
+ """Start the cross-process file watcher thread if not already running.
631
+
632
+ ``initial_cursor`` seeds ``_watch_loop`` to a known
633
+ ``event_number`` from the caller — the monotonic insert-order
634
+ sequence stamped by the events daemon under the put-hook lock.
635
+ The watcher streams ``WHERE event_number > initial_cursor`` so
636
+ any event with a higher position is picked up. Position values
637
+ are strictly monotonic with commit order, so there's no
638
+ boundary race against wall-clock ordering.
639
+ """
640
+ if self._watcher_thread is not None and self._watcher_thread.is_alive():
641
+ return
642
+
643
+ self._watcher_stop.clear()
644
+ self._watcher_thread = threading.Thread(
645
+ target=self._watch_loop,
646
+ args=(initial_cursor,),
647
+ daemon=True,
648
+ name="testerkit-event-watcher",
649
+ )
650
+ self._watcher_thread.start()
651
+
652
+ def _watch_loop(self, initial_cursor: int = 0) -> None:
653
+ """Receive cross-process events via a held-open push stream (no poll).
654
+
655
+ Opens a ``__SUBSCRIBE__`` do_get on the events daemon: the server
656
+ replays every row past the cursor from the warm index, then pushes
657
+ each new row as it lands — the blocking iterator is woken by the
658
+ server, so there is no 500ms floor.
659
+
660
+ Each delivered row carries ``event_number``; the highest seen is
661
+ the reconnection cursor. If the daemon dies or restarts, the
662
+ reader re-acquires the daemon location and re-subscribes from that
663
+ cursor — the server's replay fills the downtime gap, so delivery
664
+ is lossless across restarts (overlap deduped by ``_delivered_ids``).
665
+
666
+ ``initial_cursor`` is the ``on_event`` snapshot: history at or
667
+ below it is delivered by the background replay there; this stream
668
+ carries everything strictly past it.
669
+ """
670
+ # Ticket: ``events\0__SUBSCRIBE__\0cursor=<n>`` — see DuckDBFlightServer.
671
+ last_event_number: int = initial_cursor
672
+ location = self._location
673
+ backoff = 0.1
674
+ while not self._watcher_stop.is_set():
675
+ client: flight.FlightClient | None = None
676
+ try:
677
+ conn = flight.connect(location)
678
+ client = conn # retained for the finally-block close
679
+ ticket = flight.Ticket(
680
+ f"events\0__SUBSCRIBE__\0cursor={int(last_event_number)}".encode()
681
+ )
682
+ reader = conn.do_get(ticket)
683
+ backoff = 0.1 # connected — reset backoff
684
+ for chunk in reader:
685
+ if self._watcher_stop.is_set():
686
+ break
687
+ for row in chunk.data.to_pylist():
688
+ last_event_number = self._deliver_watched_row(row, last_event_number)
689
+ except Exception as exc: # noqa: BLE001 — reconnect on any stream error
690
+ if self._watcher_stop.is_set():
691
+ break
692
+ logger.debug("Event subscription dropped (will reconnect): %s", exc)
693
+ try:
694
+ location = duckdb_manager.acquire(self._events_dir)
695
+ except Exception as racq: # noqa: BLE001 — keep old location, retry
696
+ logger.debug("Re-acquire events daemon failed: %s", racq)
697
+ self._watcher_stop.wait(timeout=backoff)
698
+ backoff = min(backoff * 2, 5.0)
699
+ finally:
700
+ if client is not None:
701
+ try:
702
+ client.close()
703
+ except Exception: # noqa: BLE001 — best-effort close
704
+ pass
705
+
706
+ def _deliver_watched_row(self, row: dict, last_event_number: int) -> int:
707
+ """Dispatch one pushed/replayed event row; return the advanced cursor.
708
+
709
+ Deduped against in-process delivery (``_notify_subscribers``) and
710
+ replay by event id, so each event reaches subscribers once.
711
+ """
712
+ evt = _parse_event_row(row)
713
+ event_id = str(evt.get("id") or row.get("id", ""))
714
+ try:
715
+ with self._lock:
716
+ if event_id not in self._delivered_ids:
717
+ self._delivered_ids[event_id] = None
718
+ while len(self._delivered_ids) > self._delivered_ids_max:
719
+ self._delivered_ids.popitem(last=False)
720
+ self._dispatch_to_subscribers(evt)
721
+ except Exception as exc: # noqa: BLE001 — never let one bad dispatch kill the watcher
722
+ logger.debug("Watcher dispatch failed for event id=%s: %s", event_id, exc)
723
+ en = row.get("event_number")
724
+ if en is not None:
725
+ return max(last_event_number, int(en))
726
+ return last_event_number
727
+
728
+ # -- Lifecycle -----------------------------------------------------------
729
+
730
+ def close(self) -> None:
731
+ """Stop watchers, release resources. Safe to call multiple times."""
732
+ self._finalizer.detach()
733
+ self._watcher_stop.set()
734
+ if self._watcher_thread is not None:
735
+ self._watcher_thread.join(timeout=2.0)
736
+ self._watcher_thread = None
737
+
738
+ # Close event logs — their on_flush callback pushes final batches to Flight
739
+ for log in list(self._event_logs.values()):
740
+ try:
741
+ log.close()
742
+ except Exception as exc:
743
+ warnings.warn(f"EventLog close failed: {exc}", stacklevel=2)
744
+ self._event_logs.clear()
745
+
746
+ try:
747
+ self._put_stream.close()
748
+ except Exception as exc:
749
+ warnings.warn(f"FlightPutStream close failed: {exc}", stacklevel=2)
750
+
751
+ with self._lock:
752
+ self._subscriptions.clear()
753
+ self._delivered_ids.clear()
754
+
755
+ try:
756
+ self._flight.close()
757
+ except Exception as exc:
758
+ warnings.warn(f"FlightQueryClient close failed: {exc}", stacklevel=2)
759
+ try:
760
+ duckdb_manager.release(self._events_dir)
761
+ except Exception as exc:
762
+ warnings.warn(f"duckdb_manager.release failed: {exc}", stacklevel=2)
763
+
764
+ def __enter__(self) -> EventStore:
765
+ return self
766
+
767
+ def __exit__(self, *_: object) -> None:
768
+ self.close()