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,1719 @@
1
+ """Data services for UI - business logic wrappers over testerkit.store.
2
+
3
+ NO direct yaml.safe_load or Path I/O here — all persistence goes through testerkit.store.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from testerkit.data.backends.parquet import ParquetBackend
13
+ from testerkit.data.models import RunSummary
14
+ from testerkit.instruments.loader import resolve_station_instruments
15
+ from testerkit.matching import service as matching_service
16
+ from testerkit.models.catalog import InstrumentCatalogEntry
17
+ from testerkit.models.part import Part
18
+ from testerkit.models.station import StationConfig, StationType
19
+ from testerkit.models.test_config import FixtureConfig
20
+ from testerkit.parts.folder import PartFolder
21
+ from testerkit.store import (
22
+ create_catalog_entry as store_create_catalog_entry,
23
+ )
24
+ from testerkit.store import (
25
+ create_fixture as store_create_fixture,
26
+ )
27
+ from testerkit.store import (
28
+ create_part as store_create_part,
29
+ )
30
+ from testerkit.store import (
31
+ create_station as store_create_station,
32
+ )
33
+ from testerkit.store import (
34
+ find_catalog_dirs,
35
+ load_catalog_from_directory,
36
+ load_instrument_files,
37
+ load_part,
38
+ load_project_config,
39
+ normalize_and_check_instrument_types,
40
+ )
41
+ from testerkit.store import (
42
+ get_catalog_entry as store_get_catalog_entry,
43
+ )
44
+ from testerkit.store import (
45
+ get_fixture as store_get_fixture,
46
+ )
47
+ from testerkit.store import (
48
+ get_instrument_asset as store_get_instrument_asset,
49
+ )
50
+ from testerkit.store import (
51
+ get_part as store_get_part,
52
+ )
53
+ from testerkit.store import (
54
+ get_station as store_get_station,
55
+ )
56
+ from testerkit.store import (
57
+ list_fixtures as store_list_fixtures,
58
+ )
59
+ from testerkit.store import (
60
+ list_instrument_assets as store_list_instrument_assets,
61
+ )
62
+ from testerkit.store import (
63
+ list_stations as store_list_stations,
64
+ )
65
+ from testerkit.store import (
66
+ load_station_type as store_load_station_type,
67
+ )
68
+ from testerkit.store import (
69
+ save_catalog_entry as store_save_catalog_entry,
70
+ )
71
+ from testerkit.store import (
72
+ save_fixture as store_save_fixture,
73
+ )
74
+ from testerkit.store import (
75
+ save_part as store_save_part,
76
+ )
77
+ from testerkit.store import (
78
+ save_station as store_save_station,
79
+ )
80
+ from testerkit.store import (
81
+ save_station_type as store_save_station_type,
82
+ )
83
+ from testerkit.utils.paths import get_instrument_paths
84
+
85
+ # -----------------------------------------------------------------------------
86
+ # Part Services
87
+ # -----------------------------------------------------------------------------
88
+
89
+
90
+ def discover_parts() -> list[dict]:
91
+ """Discover parts from the parts/ directory.
92
+
93
+ Flat files (parts/id.yaml) are the canonical convention.
94
+ Manifest-based folders and other nested layouts are also supported via rglob.
95
+ """
96
+ parts = []
97
+ seen_ids: set[str] = set()
98
+
99
+ parts_dirs = [Path.cwd() / "parts"]
100
+
101
+ for parts_dir in parts_dirs:
102
+ if not parts_dir.exists():
103
+ continue
104
+
105
+ # 1. Check manifest-based folders (full workflow with tracking)
106
+ for folder in PartFolder.list_all(parts_dir):
107
+ spec = folder.load_spec()
108
+ part_id = folder.part_id
109
+
110
+ if part_id in seen_ids:
111
+ continue
112
+ seen_ids.add(part_id)
113
+
114
+ if spec:
115
+ parts.append(
116
+ {
117
+ "id": spec.id,
118
+ "part_number": spec.part_number,
119
+ "name": spec.name,
120
+ "description": spec.description or "",
121
+ "revision": spec.revision or "",
122
+ "pins": None,
123
+ "characteristics": {
124
+ name: char.model_dump() for name, char in spec.characteristics.items()
125
+ },
126
+ "file": (
127
+ str(folder.path / folder.manifest.files.spec)
128
+ if folder.manifest.files.spec
129
+ else None
130
+ ),
131
+ "folder_path": str(folder.path),
132
+ "workflow_step": folder.current_step.value if folder.current_step else None,
133
+ "completed_steps": [s.value for s in folder.manifest.completed_steps],
134
+ "files": folder.manifest.files.model_dump(),
135
+ }
136
+ )
137
+ else:
138
+ parts.append(
139
+ {
140
+ "id": part_id,
141
+ "part_number": None,
142
+ "name": folder.name,
143
+ "description": folder.manifest.description or "",
144
+ "revision": "",
145
+ "pins": None,
146
+ "characteristics": {},
147
+ "file": None,
148
+ "folder_path": str(folder.path),
149
+ "workflow_step": folder.current_step.value if folder.current_step else None,
150
+ "completed_steps": [s.value for s in folder.manifest.completed_steps],
151
+ "files": folder.manifest.files.model_dump(),
152
+ }
153
+ )
154
+
155
+ # 2. Discover all YAML files (flat and nested, no manifest required)
156
+ for yaml_file in sorted(parts_dir.rglob("*.yaml")):
157
+ if yaml_file.name.startswith("_"):
158
+ continue
159
+ try:
160
+ p = load_part(yaml_file)
161
+ except (OSError, ValueError, KeyError):
162
+ continue
163
+ if p.id in seen_ids:
164
+ continue
165
+ seen_ids.add(p.id)
166
+ parts.append(
167
+ {
168
+ "id": p.id,
169
+ "part_number": p.part_number,
170
+ "name": p.name,
171
+ "description": p.description or "",
172
+ "revision": p.revision or "",
173
+ "pins": None,
174
+ "characteristics": {
175
+ name: char.model_dump() for name, char in p.characteristics.items()
176
+ },
177
+ "file": str(yaml_file),
178
+ "folder_path": str(yaml_file.parent),
179
+ "workflow_step": None,
180
+ "completed_steps": [],
181
+ "files": {},
182
+ }
183
+ )
184
+
185
+ return parts
186
+
187
+
188
+ class PartRow(BaseModel):
189
+ """One row in the merged parts list (YAML-configured + parquet-observed).
190
+
191
+ Mirrors :class:`StationRow`'s shape (provenance values + run counts)
192
+ so the entity-observed-view pages stay structurally identical.
193
+ """
194
+
195
+ model_config = {"extra": "forbid"}
196
+
197
+ id: str
198
+ name: str = ""
199
+ revision: str = ""
200
+ characteristics: int = 0
201
+ runs: int = 0
202
+ passed: int = 0
203
+ failed: int = 0
204
+ last_run: datetime | None = None
205
+ provenance: Literal["configured", "observed_only"]
206
+
207
+
208
+ def parts_with_provenance() -> list[PartRow]:
209
+ """Union of YAML-configured parts and parts observed in runs.
210
+
211
+ Observation keys on ``uut_part_number`` — the hardware part number
212
+ every run carries regardless of runner — not the config-slug
213
+ ``part_id``. Configured parts join to observed runs by their declared
214
+ ``part_number``; a ``uut_part_number`` seen in run history that no
215
+ configured part declares becomes an ``observed_only`` row, identified
216
+ by that part number (all the global store knows about it).
217
+
218
+ See ``docs/_internal/explorations/best-available-identity.md`` (Phase 0).
219
+ """
220
+ configured = discover_parts()
221
+ usage = usage_stats_by("uut_part_number")
222
+
223
+ rows: list[PartRow] = []
224
+ claimed: set[str] = set()
225
+ for part in configured:
226
+ part_number = part.get("part_number") or ""
227
+ stats = usage.get(part_number, {}) if part_number else {}
228
+ if part_number:
229
+ claimed.add(part_number)
230
+ rows.append(
231
+ PartRow(
232
+ id=part["id"],
233
+ name=part.get("name", "") or "",
234
+ revision=part.get("revision", "") or "",
235
+ characteristics=len(part.get("characteristics", {}) or {}),
236
+ runs=stats.get("runs", 0),
237
+ passed=stats.get("passed", 0),
238
+ failed=stats.get("failed", 0),
239
+ last_run=stats.get("last_run"),
240
+ provenance="configured",
241
+ )
242
+ )
243
+
244
+ for part_number, stats in usage.items():
245
+ if part_number in claimed:
246
+ continue
247
+ rows.append(
248
+ PartRow(
249
+ id=part_number,
250
+ runs=stats.get("runs", 0),
251
+ passed=stats.get("passed", 0),
252
+ failed=stats.get("failed", 0),
253
+ last_run=stats.get("last_run"),
254
+ provenance="observed_only",
255
+ )
256
+ )
257
+
258
+ return rows
259
+
260
+
261
+ def load_part_model(part_id: str):
262
+ """Load a Part model by ID."""
263
+ return store_get_part(part_id)
264
+
265
+
266
+ def create_part(part_id: str, name: str, description: str = "") -> dict | None:
267
+ """Create a new part folder.
268
+
269
+ Returns dict with part info if successful, None if part already exists.
270
+ """
271
+ part = store_create_part(part_id, name, description)
272
+ if part is None:
273
+ return None
274
+
275
+ parts_dir = Path.cwd() / "parts"
276
+ folder_path = parts_dir / part_id
277
+ return {
278
+ "id": part.id,
279
+ "name": part.name,
280
+ "description": part.description or "",
281
+ "folder_path": str(folder_path),
282
+ }
283
+
284
+
285
+ def get_required_capabilities(part) -> list[dict]:
286
+ """Get required instrument capabilities for a part."""
287
+ if not part:
288
+ return []
289
+
290
+ capabilities = []
291
+ for char_name, char in part.characteristics.items():
292
+ capabilities.append(
293
+ {
294
+ "characteristic": char_name,
295
+ "direction": char.direction.value,
296
+ "function": char.function.value,
297
+ "signals": ", ".join(char.signals.keys()) if char.signals else "",
298
+ }
299
+ )
300
+ return capabilities
301
+
302
+
303
+ def get_compatible_stations_for_part(part_id: str) -> list[dict]:
304
+ """Get stations that have instruments satisfying part requirements."""
305
+ part = store_get_part(part_id)
306
+ if not part:
307
+ return []
308
+
309
+ matches = matching_service.find_compatible_stations(part)
310
+ return [
311
+ {"id": m.station_id, "name": m.station_name, "location": m.station_name}
312
+ for m in matches
313
+ if m.compatible
314
+ ]
315
+
316
+
317
+ def get_partial_stations_for_part(part_id: str) -> list[dict]:
318
+ """Get stations with partial capability coverage for a part."""
319
+ part = store_get_part(part_id)
320
+ if not part:
321
+ return []
322
+
323
+ partial_matches = matching_service.find_partial_stations(part)
324
+ return [
325
+ {
326
+ "id": m.station_id,
327
+ "name": m.station_name,
328
+ "location": m.location,
329
+ "coverage": m.coverage_pct,
330
+ "missing": m.missing,
331
+ }
332
+ for m in partial_matches
333
+ ]
334
+
335
+
336
+ def get_all_station_matches_for_part(part_id: str) -> dict[str, list]:
337
+ """Get all stations categorized by compatibility level."""
338
+ part = store_get_part(part_id)
339
+ if not part:
340
+ return {"compatible": [], "partial": [], "incompatible": []}
341
+
342
+ return matching_service.find_all_station_matches(part)
343
+
344
+
345
+ def save_part(part_id: str, part_data: dict) -> None:
346
+ """Save part specification to YAML file."""
347
+ part_dict = {
348
+ "id": part_data.get("id", part_id),
349
+ "name": part_data.get("name", ""),
350
+ "description": part_data.get("description", ""),
351
+ "characteristics": part_data.get("characteristics", {}),
352
+ }
353
+ if part_data.get("revision"):
354
+ part_dict["revision"] = part_data["revision"]
355
+ if part_data.get("pins"):
356
+ part_dict["pins"] = part_data["pins"]
357
+
358
+ part = Part.model_validate(part_dict)
359
+ store_save_part(part)
360
+
361
+
362
+ # -----------------------------------------------------------------------------
363
+ # Station Services
364
+ # -----------------------------------------------------------------------------
365
+
366
+
367
+ def discover_stations():
368
+ """Discover station configurations from YAML files."""
369
+ return store_list_stations()
370
+
371
+
372
+ class StationRow(BaseModel):
373
+ """One row in the merged stations list (YAML-configured + parquet-observed).
374
+
375
+ `provenance` carries the config-vs-data relationship for the row:
376
+
377
+ - ``configured`` — YAML exists (with or without recorded runs)
378
+ - ``observed_only`` — appears in run history with no YAML counterpart
379
+
380
+ Activity for ``configured`` rows lives in the Runs column, not the
381
+ chip — splitting "in use" into its own chip duplicated the Runs
382
+ column without adding precision.
383
+ """
384
+
385
+ model_config = {"extra": "forbid"}
386
+
387
+ id: str
388
+ name: str = ""
389
+ location: str = ""
390
+ instruments: int = 0
391
+ runs: int = 0
392
+ passed: int = 0
393
+ failed: int = 0
394
+ last_run: datetime | None = None
395
+ provenance: Literal["configured", "observed_only"]
396
+
397
+
398
+ def stations_with_provenance() -> list[StationRow]:
399
+ """Union of YAML-configured stations and stations observed in runs.
400
+
401
+ Two passes: (1) every YAML station becomes a row tagged ``configured``
402
+ or ``in_use`` depending on whether any runs reference its id;
403
+ (2) any station id present in run history without a matching YAML
404
+ file becomes an ``observed_only`` row. Run stats come from the
405
+ existing ``usage_stats_by`` SQL aggregation — no extra query.
406
+ """
407
+ configured = {s.id: s for s in discover_stations()}
408
+ usage = usage_stats_by("station_id")
409
+
410
+ rows: list[StationRow] = []
411
+ for station_id, station in configured.items():
412
+ stats = usage.get(station_id, {})
413
+ runs = stats.get("runs", 0)
414
+ rows.append(
415
+ StationRow(
416
+ id=station_id,
417
+ name=station.name or "",
418
+ location=station.location or "",
419
+ instruments=len(station.instruments or {}),
420
+ runs=runs,
421
+ passed=stats.get("passed", 0),
422
+ failed=stats.get("failed", 0),
423
+ last_run=stats.get("last_run"),
424
+ provenance="configured",
425
+ )
426
+ )
427
+
428
+ for station_id, stats in usage.items():
429
+ if station_id in configured:
430
+ continue
431
+ rows.append(
432
+ StationRow(
433
+ id=station_id,
434
+ runs=stats.get("runs", 0),
435
+ passed=stats.get("passed", 0),
436
+ failed=stats.get("failed", 0),
437
+ last_run=stats.get("last_run"),
438
+ provenance="observed_only",
439
+ )
440
+ )
441
+
442
+ return rows
443
+
444
+
445
+ def load_station_config(station_id: str):
446
+ """Load station configuration by ID."""
447
+ return store_get_station(station_id)
448
+
449
+
450
+ def create_station(
451
+ station_id: str,
452
+ name: str,
453
+ location: str = "",
454
+ description: str = "",
455
+ ):
456
+ """Create a new station configuration file."""
457
+ return store_create_station(station_id, name, location, description)
458
+
459
+
460
+ def save_station(_station_id: str, station_data: dict, instruments_data: dict) -> None:
461
+ """Save station configuration to YAML file."""
462
+ normalize_and_check_instrument_types(instruments_data)
463
+ station_dict = {**station_data, "instruments": instruments_data}
464
+ station = StationConfig.model_validate(station_dict)
465
+ store_save_station(station)
466
+
467
+
468
+ def get_station_capabilities(config):
469
+ """Get capabilities from all instruments in a station."""
470
+ if not config:
471
+ return []
472
+ return matching_service.get_station_capabilities(config)
473
+
474
+
475
+ def station_compatible_with_part(station_config, part) -> bool:
476
+ """Check if a station is compatible with a part."""
477
+ if not station_config or not part:
478
+ return False
479
+ result = matching_service.check_station_compatibility(part.id, station_config.id)
480
+ return result.get("compatible", False) if result else False
481
+
482
+
483
+ # -----------------------------------------------------------------------------
484
+ # Instrument Services
485
+ # -----------------------------------------------------------------------------
486
+
487
+
488
+ def discover_instrument_types():
489
+ """Discover available instrument types from catalog entries.
490
+
491
+ Returns one InstrumentCatalogEntry per unique type (first seen wins).
492
+ """
493
+ entries = []
494
+ seen_types: set[str] = set()
495
+ for cat_dir in find_catalog_dirs():
496
+ for _, entry in load_catalog_from_directory(cat_dir).items():
497
+ if entry.type in seen_types:
498
+ continue
499
+ seen_types.add(entry.type)
500
+ entries.append(entry)
501
+ return entries
502
+
503
+
504
+ def load_catalog_entry_by_type(instrument_type: str):
505
+ """Load a catalog entry by type or ID."""
506
+ return store_get_catalog_entry(instrument_type)
507
+
508
+
509
+ def save_catalog_entry(instrument_type: str, data: dict) -> None:
510
+ """Save a catalog entry to catalog/."""
511
+ inst = data.get("instrument", {})
512
+ entry = InstrumentCatalogEntry.model_validate(
513
+ {
514
+ "id": inst.get("type", instrument_type),
515
+ "type": inst.get("type", instrument_type),
516
+ "manufacturer": inst.get("manufacturer", "User"),
517
+ "model": inst.get("name", instrument_type),
518
+ "name": inst.get("name", ""),
519
+ "description": inst.get("description"),
520
+ "capabilities": data.get("capabilities", []),
521
+ }
522
+ )
523
+ store_save_catalog_entry(entry)
524
+
525
+
526
+ def discover_instrument_assets():
527
+ """Discover per-device instrument asset files."""
528
+ return store_list_instrument_assets()
529
+
530
+
531
+ class UUTRow(BaseModel):
532
+ """One row in the UUTs list — purely observed from run history.
533
+
534
+ UUTs are never declared in YAML by design (they're the unit under
535
+ test, identified at runtime by serial). The UUTs page is the only
536
+ entity list whose rows are all observed-only; no provenance chip
537
+ is rendered.
538
+ """
539
+
540
+ model_config = {"extra": "forbid"}
541
+
542
+ serial: str
543
+ part_number: str = ""
544
+ lot_number: str = ""
545
+ runs: int = 0
546
+ passed: int = 0
547
+ failed: int = 0
548
+ last_run: datetime | None = None
549
+
550
+
551
+ def uuts_from_runs() -> list[UUTRow]:
552
+ """Distinct UUTs observed in run history, with per-UUT run counts.
553
+
554
+ Groups by ``uut_serial_number``; the part number and lot number are
555
+ aggregated via ``MAX`` (expected constant per serial — taking ``MAX``
556
+ is just a SQL-idiomatic way to surface one value when the GROUP BY
557
+ key uniquely determines the row).
558
+ """
559
+ from testerkit.analysis.runs_query import RunsQuery
560
+
561
+ sql = """
562
+ SELECT
563
+ uut_serial_number AS serial,
564
+ MAX(uut_part_number) AS part_number,
565
+ MAX(uut_lot_number) AS lot_number,
566
+ COUNT(*) AS runs,
567
+ COUNT(*) FILTER (WHERE outcome = 'passed') AS passed,
568
+ COUNT(*) FILTER (WHERE outcome = 'failed') AS failed,
569
+ MAX(started_at) AS last_run
570
+ FROM runs
571
+ WHERE uut_serial_number IS NOT NULL AND uut_serial_number <> ''
572
+ GROUP BY uut_serial_number
573
+ ORDER BY last_run DESC NULLS LAST
574
+ """
575
+ try:
576
+ with RunsQuery() as q:
577
+ rows = q._query_dicts(sql) # noqa: SLF001 — direct SQL is the documented escape hatch
578
+ except (ValueError, Exception): # noqa: BLE001 — query layer can raise broadly
579
+ return []
580
+
581
+ return [
582
+ UUTRow(
583
+ serial=r["serial"],
584
+ part_number=r.get("part_number") or "",
585
+ lot_number=r.get("lot_number") or "",
586
+ runs=r.get("runs", 0),
587
+ passed=r.get("passed", 0),
588
+ failed=r.get("failed", 0),
589
+ last_run=r.get("last_run"),
590
+ )
591
+ for r in rows
592
+ if r.get("serial")
593
+ ]
594
+
595
+
596
+ def _instrument_id_usage_stats() -> dict[str, dict[str, Any]]:
597
+ """Run-count stats keyed by instrument id from the ``instruments`` view.
598
+
599
+ One row per (run, instrument) — no UNNEST needed. Groups by
600
+ ``instrument_id`` and counts distinct ``run_id`` per instrument. Reads the
601
+ ``instruments`` VIEW (not the ``instruments_materialized`` table directly)
602
+ for ``run_outcome``/``run_started_at`` — those are run identity, joined in
603
+ from ``runs`` by the view (projection-normalization, 0.3.1); the
604
+ materialized table itself carries only the instrument's own fields.
605
+ """
606
+ from testerkit.analysis.runs_query import RunsQuery
607
+
608
+ sql = """
609
+ SELECT
610
+ instrument_id AS value,
611
+ COUNT(DISTINCT run_id) AS runs,
612
+ COUNT(DISTINCT run_id) FILTER (WHERE run_outcome = 'passed') AS pass_count,
613
+ COUNT(DISTINCT run_id) FILTER (WHERE run_outcome = 'failed') AS fail_count,
614
+ MAX(run_started_at) AS last_run
615
+ FROM instruments
616
+ WHERE instrument_id IS NOT NULL AND instrument_id <> ''
617
+ GROUP BY instrument_id
618
+ ORDER BY runs DESC
619
+ """
620
+ try:
621
+ with RunsQuery() as q:
622
+ rows = q._query_dicts(sql) # noqa: SLF001 — direct SQL is the documented escape hatch
623
+ except (ValueError, Exception): # noqa: BLE001 — query layer can raise broadly
624
+ return {}
625
+
626
+ return {
627
+ r["value"]: {
628
+ "runs": r.get("runs", 0),
629
+ "passed": r.get("pass_count", 0),
630
+ "failed": r.get("fail_count", 0),
631
+ "last_run": r.get("last_run"),
632
+ }
633
+ for r in rows
634
+ if r.get("value")
635
+ }
636
+
637
+
638
+ class InstrumentAssetRow(BaseModel):
639
+ """One row in the merged instrument-inventory list.
640
+
641
+ Mirrors :class:`StationRow` / :class:`PartRow` / :class:`FixtureRow`.
642
+ ``identity`` is the joined manufacturer + model display string from
643
+ the asset YAML (empty for observed-only rows).
644
+ """
645
+
646
+ model_config = {"extra": "forbid"}
647
+
648
+ id: str
649
+ driver: str = ""
650
+ identity: str = ""
651
+ serial: str = ""
652
+ cal_due: str = ""
653
+ cal_lab: str = ""
654
+ runs: int = 0
655
+ passed: int = 0
656
+ failed: int = 0
657
+ last_run: datetime | None = None
658
+ provenance: Literal["configured", "observed_only"]
659
+
660
+
661
+ def instrument_assets_with_provenance() -> list[InstrumentAssetRow]:
662
+ """Union of YAML-configured instrument assets and assets observed in runs.
663
+
664
+ Observed side comes from :func:`_instrument_id_usage_stats` which
665
+ queries ``instruments_materialized`` — an instrument id that appears
666
+ in any run but has no asset YAML is rendered as ``observed_only``.
667
+ """
668
+ from datetime import date as _date
669
+
670
+ configured = {a.id: a for a in discover_instrument_assets()}
671
+ usage = _instrument_id_usage_stats()
672
+
673
+ rows: list[InstrumentAssetRow] = []
674
+ for asset_id, asset in configured.items():
675
+ stats = usage.get(asset_id, {})
676
+ runs = stats.get("runs", 0)
677
+ mfr = asset.info.manufacturer or ""
678
+ model = asset.info.model or ""
679
+ identity = f"{mfr} {model}".strip()
680
+
681
+ cal_due = asset.calibration.due_date
682
+ if cal_due:
683
+ cal_str = cal_due.isoformat() if isinstance(cal_due, _date) else str(cal_due)
684
+ else:
685
+ cal_str = ""
686
+
687
+ rows.append(
688
+ InstrumentAssetRow(
689
+ id=asset_id,
690
+ driver=asset.driver or "",
691
+ identity=identity,
692
+ serial=str(asset.info.serial or ""),
693
+ cal_due=cal_str,
694
+ cal_lab=asset.calibration.lab or "",
695
+ runs=runs,
696
+ passed=stats.get("passed", 0),
697
+ failed=stats.get("failed", 0),
698
+ last_run=stats.get("last_run"),
699
+ provenance="configured",
700
+ )
701
+ )
702
+
703
+ for asset_id, stats in usage.items():
704
+ if asset_id in configured:
705
+ continue
706
+ rows.append(
707
+ InstrumentAssetRow(
708
+ id=asset_id,
709
+ runs=stats.get("runs", 0),
710
+ passed=stats.get("passed", 0),
711
+ failed=stats.get("failed", 0),
712
+ last_run=stats.get("last_run"),
713
+ provenance="observed_only",
714
+ )
715
+ )
716
+
717
+ return rows
718
+
719
+
720
+ def load_instrument_asset_by_id(instrument_id: str):
721
+ """Load a single instrument asset file by ID."""
722
+ return store_get_instrument_asset(instrument_id)
723
+
724
+
725
+ def resolve_station_instrument_records(station_id: str) -> dict:
726
+ """Resolve a station's instruments to InstrumentRecord objects."""
727
+ config = store_get_station(station_id)
728
+ if not config:
729
+ return {}
730
+
731
+ all_instrument_files: dict = {}
732
+ for instruments_dir in get_instrument_paths():
733
+ all_instrument_files.update(load_instrument_files(instruments_dir))
734
+
735
+ return resolve_station_instruments(config, all_instrument_files)
736
+
737
+
738
+ def create_catalog_entry(
739
+ instrument_type: str,
740
+ name: str,
741
+ description: str = "",
742
+ ):
743
+ """Create a new catalog entry in catalog/."""
744
+ return store_create_catalog_entry(instrument_type, name, description)
745
+
746
+
747
+ # -----------------------------------------------------------------------------
748
+ # Test & Sequence Services
749
+ # -----------------------------------------------------------------------------
750
+
751
+
752
+ def discover_tests() -> list[dict]:
753
+ """Discover available test directories.
754
+
755
+ Legacy directory-grouped list — kept for callers that just want the
756
+ set of folders (e.g. the Launch Test form's Test Path dropdown).
757
+ Prefer :func:`walk_test_files` for the operator-UI list page which
758
+ needs per-file structure.
759
+ """
760
+ tests = []
761
+ search_paths = [Path.cwd() / "tests"]
762
+
763
+ for tests_dir in search_paths:
764
+ if not tests_dir.exists():
765
+ continue
766
+ for test_file in tests_dir.rglob("test_*.py"):
767
+ test_dir = test_file.parent
768
+ cwd = Path.cwd()
769
+ relative = test_dir.relative_to(cwd) if test_dir.is_relative_to(cwd) else test_dir
770
+ test_entry = {"path": str(relative), "name": test_dir.name}
771
+ if test_entry not in tests:
772
+ tests.append(test_entry)
773
+ return tests
774
+
775
+
776
+ class TestFunctionRow(BaseModel):
777
+ """One test function found by AST walk."""
778
+
779
+ model_config = {"extra": "forbid"}
780
+
781
+ name: str # bare function name (``test_foo``)
782
+ class_name: str | None = None # parent ``TestX`` class if applicable
783
+ markers: list[str] = Field(default_factory=list) # decorator names sans ``pytest.mark.``
784
+ parametrize_count: int = 0 # rough vector count from @parametrize / @testerkit_sweeps
785
+ has_sidecar_entry: bool = False
786
+
787
+
788
+ class TestModuleRow(BaseModel):
789
+ """One test module — a ``test_*.py`` file with its tests, sidecar, markers."""
790
+
791
+ model_config = {"extra": "forbid"}
792
+
793
+ path: str # relative to cwd
794
+ directory: str # parent directory relative to cwd
795
+ name: str # filename
796
+ tests: list[TestFunctionRow] = Field(default_factory=list)
797
+ classes: list[str] = Field(default_factory=list)
798
+ has_sidecar: bool = False
799
+ parse_error: str | None = None # set when AST parse fails
800
+
801
+
802
+ def _decorator_marker_name(node: Any) -> str | None:
803
+ """Extract the marker name from a decorator node.
804
+
805
+ Returns ``testerkit_sweeps`` for ``@pytest.mark.testerkit_sweeps(...)`` /
806
+ ``@testerkit.mark.testerkit_sweeps(...)`` /
807
+ plain ``@testerkit_sweeps``. Returns ``None`` for decorators that
808
+ don't look like marker references.
809
+ """
810
+ import ast
811
+
812
+ target = node
813
+ if isinstance(node, ast.Call):
814
+ target = node.func
815
+ if isinstance(target, ast.Attribute):
816
+ return target.attr
817
+ if isinstance(target, ast.Name):
818
+ return target.id
819
+ return None
820
+
821
+
822
+ def _estimate_vector_count(node: Any) -> int:
823
+ """Best-effort vector count from a parametrize / testerkit_sweeps decorator.
824
+
825
+ ``@pytest.mark.parametrize("x", [1, 2, 3])`` → 3.
826
+ ``@pytest.mark.testerkit_sweeps([{"vin": [3.3, 5.0]}])`` →
827
+ sums the inner-list lengths for a rough cross-part upper bound.
828
+
829
+ Returns ``0`` when the decorator shape isn't recognised — the
830
+ overview is a sanity gauge, not a contract.
831
+ """
832
+ import ast
833
+
834
+ if not isinstance(node, ast.Call) or not node.args:
835
+ return 0
836
+ name = _decorator_marker_name(node) or ""
837
+ if name == "parametrize":
838
+ # second arg is the values list
839
+ if len(node.args) >= 2 and isinstance(node.args[1], (ast.List, ast.Tuple)):
840
+ return len(node.args[1].elts)
841
+ return 0
842
+ if name in ("testerkit_sweeps", "testerkit_mocks", "testerkit_characteristics"):
843
+ # list-of-dicts form: each dict's values are lists; part across keys
844
+ first = node.args[0]
845
+ if isinstance(first, (ast.List, ast.Tuple)):
846
+ count = 0
847
+ for elt in first.elts:
848
+ if isinstance(elt, ast.Dict):
849
+ part = 1
850
+ for v in elt.values:
851
+ if isinstance(v, (ast.List, ast.Tuple)):
852
+ part *= max(len(v.elts), 1)
853
+ count += part
854
+ return count
855
+ return 0
856
+
857
+
858
+ def _function_to_row(node: Any, class_name: str | None, sidecar_names: set[str]) -> TestFunctionRow:
859
+ import ast
860
+
861
+ markers: list[str] = []
862
+ vector_count = 0
863
+ for d in node.decorator_list: # type: ignore[attr-defined]
864
+ marker_name = _decorator_marker_name(d)
865
+ if marker_name and not isinstance(d, ast.Name):
866
+ markers.append(marker_name)
867
+ elif marker_name:
868
+ markers.append(marker_name)
869
+ if isinstance(d, ast.Call):
870
+ vector_count += _estimate_vector_count(d)
871
+ has_entry = node.name in sidecar_names or (
872
+ class_name is not None and class_name in sidecar_names
873
+ )
874
+ return TestFunctionRow(
875
+ name=node.name,
876
+ class_name=class_name,
877
+ markers=sorted(set(markers)),
878
+ parametrize_count=vector_count,
879
+ has_sidecar_entry=has_entry,
880
+ )
881
+
882
+
883
+ def _sidecar_test_names(yaml_path: Path) -> set[str]:
884
+ """Return the set of test/class names keyed in a sidecar's ``tests:`` block."""
885
+ if not yaml_path.exists():
886
+ return set()
887
+ try:
888
+ import yaml as _yaml
889
+
890
+ data = _yaml.safe_load(yaml_path.read_text()) or {}
891
+ except (OSError, ValueError):
892
+ return set()
893
+ tests_block = data.get("tests") if isinstance(data, dict) else None
894
+ if not isinstance(tests_block, dict):
895
+ return set()
896
+ return set(tests_block.keys())
897
+
898
+
899
+ def walk_test_module(py_path: Path) -> TestModuleRow:
900
+ """AST-walk one test module, populate a :class:`TestModuleRow`.
901
+
902
+ Picks up: top-level ``def test_*`` and ``class Test*`` (with their
903
+ ``def test_*`` methods), decorators on each, and the sidecar's
904
+ ``tests:`` block (if a sibling ``.yaml`` exists) to flag per-test
905
+ sidecar coverage. Survives parse errors — the row gets a
906
+ ``parse_error`` and an empty test list.
907
+ """
908
+ import ast
909
+
910
+ cwd = Path.cwd()
911
+ rel = py_path.relative_to(cwd) if py_path.is_relative_to(cwd) else py_path
912
+ parent = rel.parent
913
+ yaml_path = py_path.with_suffix(".yaml")
914
+ sidecar_names = _sidecar_test_names(yaml_path)
915
+ row = TestModuleRow(
916
+ path=str(rel),
917
+ directory=str(parent) or ".",
918
+ name=py_path.name,
919
+ has_sidecar=yaml_path.exists(),
920
+ )
921
+
922
+ try:
923
+ tree = ast.parse(py_path.read_text(encoding="utf-8"))
924
+ except (OSError, SyntaxError) as exc:
925
+ row.parse_error = str(exc)
926
+ return row
927
+
928
+ classes: list[str] = []
929
+ for node in tree.body:
930
+ if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
931
+ classes.append(node.name)
932
+ for item in node.body:
933
+ if isinstance(
934
+ item, (ast.FunctionDef, ast.AsyncFunctionDef)
935
+ ) and item.name.startswith("test_"):
936
+ row.tests.append(_function_to_row(item, node.name, sidecar_names))
937
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith(
938
+ "test_"
939
+ ):
940
+ row.tests.append(_function_to_row(node, None, sidecar_names))
941
+
942
+ row.classes = classes
943
+ return row
944
+
945
+
946
+ def walk_test_files(project_root: Path | None = None) -> list[TestModuleRow]:
947
+ """AST-walk every ``test_*.py`` under ``tests/``, sorted by path."""
948
+ root = project_root or Path.cwd()
949
+ tests_dir = root / "tests"
950
+ if not tests_dir.exists():
951
+ return []
952
+ return [walk_test_module(p) for p in sorted(tests_dir.rglob("test_*.py"))]
953
+
954
+
955
+ class TestRunStats(BaseModel):
956
+ """Aggregated step-level run history for one ``step_path``."""
957
+
958
+ model_config = {"extra": "forbid"}
959
+
960
+ step_path: str
961
+ runs: int = 0
962
+ passed: int = 0
963
+ failed: int = 0
964
+ last_run: datetime | None = None
965
+
966
+
967
+ def step_path_stats() -> dict[str, TestRunStats]:
968
+ """Aggregate the ``steps`` table by ``step_path`` — counts and recency
969
+ per distinct step path across all of run history.
970
+
971
+ Returns ``{step_path: TestRunStats}``. Skips blank step paths.
972
+ """
973
+ from testerkit.analysis.steps_query import StepsQuery
974
+
975
+ sql = """
976
+ SELECT step_path,
977
+ COUNT(DISTINCT run_id) AS runs,
978
+ COUNT(*) FILTER (WHERE outcome = 'passed') AS passed,
979
+ COUNT(*) FILTER (WHERE outcome = 'failed') AS failed,
980
+ MAX(started_at) AS last_run
981
+ FROM steps
982
+ WHERE step_path IS NOT NULL AND step_path <> ''
983
+ GROUP BY step_path
984
+ """
985
+ try:
986
+ with StepsQuery() as q:
987
+ rows = q._query_dicts(sql) # noqa: SLF001 — documented escape hatch
988
+ except (ValueError, Exception): # noqa: BLE001 — query layer can raise broadly
989
+ return {}
990
+
991
+ return {
992
+ r["step_path"]: TestRunStats(
993
+ step_path=r["step_path"],
994
+ runs=r.get("runs", 0),
995
+ passed=r.get("passed", 0),
996
+ failed=r.get("failed", 0),
997
+ last_run=r.get("last_run"),
998
+ )
999
+ for r in rows
1000
+ }
1001
+
1002
+
1003
+ def _expected_step_paths(modules: list[TestModuleRow]) -> set[str]:
1004
+ """Set of step_paths the AST walk says SHOULD exist in history.
1005
+
1006
+ Both bare class names and ``Class/method`` qualifiers — the runtime
1007
+ emits step rows for class nodes themselves (parent steps) as well as
1008
+ their leaf methods, and either form is legitimately ``in_code``.
1009
+ """
1010
+ expected: set[str] = set()
1011
+ for m in modules:
1012
+ for t in m.tests:
1013
+ if t.class_name:
1014
+ expected.add(t.class_name) # class-as-step parent row
1015
+ expected.add(f"{t.class_name}/{t.name}") # method leaf
1016
+ else:
1017
+ expected.add(t.name)
1018
+ return expected
1019
+
1020
+
1021
+ def observed_only_step_paths(modules: list[TestModuleRow]) -> list[TestRunStats]:
1022
+ """Step paths in history with no matching test function in current source.
1023
+
1024
+ Surfaces tests that have been renamed, deleted, or run from a branch
1025
+ whose code isn't currently checked out. Sorted by last_run desc so
1026
+ the most-recently-orphaned paths come first.
1027
+ """
1028
+ all_stats = step_path_stats()
1029
+ expected = _expected_step_paths(modules)
1030
+ orphans = [stats for sp, stats in all_stats.items() if sp not in expected]
1031
+ orphans.sort(key=lambda s: s.last_run or datetime.min, reverse=True)
1032
+ return orphans
1033
+
1034
+
1035
+ # -----------------------------------------------------------------------------
1036
+ # Fixture Services
1037
+ # -----------------------------------------------------------------------------
1038
+
1039
+
1040
+ def discover_fixtures():
1041
+ """Discover fixture configurations from YAML files."""
1042
+ return store_list_fixtures()
1043
+
1044
+
1045
+ class ProfileRow(BaseModel):
1046
+ """One row in the profiles list.
1047
+
1048
+ Profiles are config-only today — the runs parquet doesn't carry the
1049
+ profile that was active for a given run, so the merged-with-badge
1050
+ pattern doesn't apply. Every row is configured-only by definition.
1051
+ """
1052
+
1053
+ model_config = {"extra": "forbid"}
1054
+
1055
+ name: str
1056
+ station_type: str = ""
1057
+ fixture: str = ""
1058
+ extends: str = ""
1059
+ facets: str = ""
1060
+ tests_count: int = 0
1061
+
1062
+
1063
+ def discover_profiles() -> list[ProfileRow]:
1064
+ """List configured profiles from the project's ``testerkit.yaml`` +
1065
+ ``profiles/*.yaml`` files. Per-profile fields are extracted into a
1066
+ typed display row.
1067
+ """
1068
+ project = load_project_config()
1069
+ rows: list[ProfileRow] = []
1070
+ for name, profile in (project.profiles or {}).items():
1071
+ facets_str = ", ".join(f"{k}={v}" for k, v in (profile.facets or {}).items())
1072
+ rows.append(
1073
+ ProfileRow(
1074
+ name=name,
1075
+ station_type=profile.station_type or "",
1076
+ fixture=profile.fixture or "",
1077
+ extends=profile.extends or "",
1078
+ facets=facets_str,
1079
+ tests_count=len(profile.tests or {}),
1080
+ )
1081
+ )
1082
+ return rows
1083
+
1084
+
1085
+ def load_profile_config(name: str):
1086
+ """Load a single profile's ProfileConfig (already merged from
1087
+ inline + per-file sources by ``load_project_config``).
1088
+ """
1089
+ project = load_project_config()
1090
+ return (project.profiles or {}).get(name)
1091
+
1092
+
1093
+ class FixtureRow(BaseModel):
1094
+ """One row in the merged fixtures list (YAML-configured + parquet-observed).
1095
+
1096
+ Mirrors :class:`StationRow` / :class:`PartRow`. The optional
1097
+ ``part`` label is the display name of the fixture's part
1098
+ family (resolved via ``discover_parts``); observed-only rows
1099
+ leave it empty.
1100
+ """
1101
+
1102
+ model_config = {"extra": "forbid"}
1103
+
1104
+ id: str
1105
+ name: str = ""
1106
+ part: str = ""
1107
+ revision: str = ""
1108
+ connections: int = 0
1109
+ runs: int = 0
1110
+ passed: int = 0
1111
+ failed: int = 0
1112
+ last_run: datetime | None = None
1113
+ provenance: Literal["configured", "observed_only"]
1114
+
1115
+
1116
+ def fixtures_with_provenance() -> list[FixtureRow]:
1117
+ """Union of YAML-configured fixtures and fixtures observed in runs.
1118
+
1119
+ Two passes mirroring :func:`stations_with_provenance`. The display
1120
+ ``part`` label is resolved against ``discover_parts`` so the
1121
+ operator sees the part name, not just the id.
1122
+ """
1123
+ configured = {f.id: f for f in discover_fixtures()}
1124
+ parts = {p["id"]: p for p in discover_parts()}
1125
+ usage = usage_stats_by("fixture_id")
1126
+
1127
+ rows: list[FixtureRow] = []
1128
+ for fixture_id, fixture in configured.items():
1129
+ stats = usage.get(fixture_id, {})
1130
+ runs = stats.get("runs", 0)
1131
+ part_id = fixture.part_id or fixture.part_family or ""
1132
+ part_label = (parts.get(part_id) or {}).get("name") or part_id
1133
+ rows.append(
1134
+ FixtureRow(
1135
+ id=fixture_id,
1136
+ name=fixture.name or "",
1137
+ part=part_label,
1138
+ revision=fixture.part_revision or "",
1139
+ connections=len(fixture.connections or {}),
1140
+ runs=runs,
1141
+ passed=stats.get("passed", 0),
1142
+ failed=stats.get("failed", 0),
1143
+ last_run=stats.get("last_run"),
1144
+ provenance="configured",
1145
+ )
1146
+ )
1147
+
1148
+ for fixture_id, stats in usage.items():
1149
+ if fixture_id in configured:
1150
+ continue
1151
+ rows.append(
1152
+ FixtureRow(
1153
+ id=fixture_id,
1154
+ runs=stats.get("runs", 0),
1155
+ passed=stats.get("passed", 0),
1156
+ failed=stats.get("failed", 0),
1157
+ last_run=stats.get("last_run"),
1158
+ provenance="observed_only",
1159
+ )
1160
+ )
1161
+
1162
+ return rows
1163
+
1164
+
1165
+ def load_fixture_config(fixture_id: str):
1166
+ """Load fixture configuration by ID."""
1167
+ return store_get_fixture(fixture_id)
1168
+
1169
+
1170
+ def create_fixture(
1171
+ fixture_id: str,
1172
+ name: str,
1173
+ part_id: str = "",
1174
+ part_revision: str = "",
1175
+ description: str = "",
1176
+ ):
1177
+ """Create a new fixture configuration file."""
1178
+ return store_create_fixture(fixture_id, name, part_id, part_revision, description)
1179
+
1180
+
1181
+ def save_fixture(_fixture_id: str, fixture_data: dict, connections_data: dict) -> None:
1182
+ """Save fixture configuration to YAML file."""
1183
+ fixture_dict = {**fixture_data, "connections": connections_data}
1184
+ fixture = FixtureConfig.model_validate(fixture_dict)
1185
+ store_save_fixture(fixture)
1186
+
1187
+
1188
+ # -----------------------------------------------------------------------------
1189
+ # Station Type Services
1190
+ # -----------------------------------------------------------------------------
1191
+
1192
+
1193
+ def save_station_type(station_type: StationType) -> None:
1194
+ """Save station type YAML."""
1195
+ store_save_station_type(station_type)
1196
+
1197
+
1198
+ def load_station_type(type_id: str) -> StationType | None:
1199
+ """Load station type by ID."""
1200
+ return store_load_station_type(type_id)
1201
+
1202
+
1203
+ def get_instrument_channels_from_library(instrument_type: str) -> list[str]:
1204
+ """Get channel names from a catalog entry matching the given type."""
1205
+ entry = store_get_catalog_entry(instrument_type)
1206
+ if entry:
1207
+ if entry.channels:
1208
+ return list(entry.channels.keys())
1209
+ return ["1"]
1210
+ return ["1"]
1211
+
1212
+
1213
+ def get_fixtures_for_part(part_family: str):
1214
+ """Get all fixtures for a part family."""
1215
+ all_fixtures = discover_fixtures()
1216
+ return [f for f in all_fixtures if (f.part_family or "") == part_family]
1217
+
1218
+
1219
+ def get_compatible_stations_for_fixture(fixture_id: str):
1220
+ """Get stations that have all instruments referenced by a fixture."""
1221
+ fixture = load_fixture_config(fixture_id)
1222
+ if not fixture:
1223
+ return []
1224
+
1225
+ required_instruments = {c.instrument for c in fixture.connections.values() if c.instrument}
1226
+
1227
+ compatible = []
1228
+ for station in discover_stations():
1229
+ if not station.instruments:
1230
+ continue
1231
+ station_instruments = set(station.instruments.keys())
1232
+ if required_instruments <= station_instruments:
1233
+ compatible.append(station)
1234
+
1235
+ return compatible
1236
+
1237
+
1238
+ # -----------------------------------------------------------------------------
1239
+ # Test Run Services
1240
+ # -----------------------------------------------------------------------------
1241
+
1242
+
1243
+ def _results_backend() -> ParquetBackend:
1244
+ """Build a ParquetBackend using the configured project data_dir."""
1245
+ project = load_project_config()
1246
+ return ParquetBackend(data_dir=project.data_dir)
1247
+
1248
+
1249
+ def get_recent_runs(
1250
+ limit: int = 10,
1251
+ *,
1252
+ offset: int = 0,
1253
+ include_incomplete: bool = False,
1254
+ phase: str | list[str] | None = None,
1255
+ part: str | list[str] | None = None,
1256
+ station: str | list[str] | None = None,
1257
+ lot: str | list[str] | None = None,
1258
+ outcome: str | list[str] | None = None,
1259
+ since: str | None = None,
1260
+ until: str | None = None,
1261
+ ) -> list[RunSummary]:
1262
+ """Return one page of recent runs as RunSummary rows.
1263
+
1264
+ Same RunsQuery → RunSummary adapter as :func:`list_all_runs`.
1265
+ ``offset`` paginates per the Quasar server-side contract:
1266
+ ``offset = (page - 1) * rows_per_page``. Filter args narrow
1267
+ by phase / part / station / lot / outcome / since / until.
1268
+
1269
+ ``include_incomplete=True`` surfaces in-flight runs (no
1270
+ ``ended_at``) — UI list pages opt in so operators see what's
1271
+ running. Default ``False`` keeps the legacy "completed only"
1272
+ behavior for callers that expect aggregates.
1273
+ """
1274
+ return list_all_runs(
1275
+ limit=limit,
1276
+ offset=offset,
1277
+ include_incomplete=include_incomplete,
1278
+ phase=phase,
1279
+ part=part,
1280
+ station=station,
1281
+ lot=lot,
1282
+ outcome=outcome,
1283
+ since=since,
1284
+ until=until,
1285
+ )
1286
+
1287
+
1288
+ def count_recent_runs(
1289
+ *,
1290
+ include_incomplete: bool = False,
1291
+ phase: str | list[str] | None = None,
1292
+ part: str | list[str] | None = None,
1293
+ station: str | list[str] | None = None,
1294
+ lot: str | list[str] | None = None,
1295
+ outcome: str | list[str] | None = None,
1296
+ since: str | None = None,
1297
+ until: str | None = None,
1298
+ ) -> int:
1299
+ """Total run count under the same filters as ``get_recent_runs``.
1300
+
1301
+ Used by ``/results/list`` to render the absolute "of N" in the
1302
+ Quasar pagination footer and the Total Runs stats card. Filter
1303
+ args mirror ``get_recent_runs`` so the count stays consistent
1304
+ with the paginated rows.
1305
+ """
1306
+ from testerkit.analysis.runs_query import RunsQuery
1307
+
1308
+ with RunsQuery() as q:
1309
+ return q.count(
1310
+ include_incomplete=include_incomplete,
1311
+ phase=phase,
1312
+ part=part,
1313
+ station=station,
1314
+ lot=lot,
1315
+ outcome=outcome,
1316
+ since=since,
1317
+ until=until,
1318
+ )
1319
+
1320
+
1321
+ def get_runs_filter_options() -> dict[str, list[str]]:
1322
+ """Return distinct filter values for the /results filter strip.
1323
+
1324
+ Keys: ``test_phase``, ``uut_part_number``, ``station_hostname``,
1325
+ ``uut_lot_number``. Each maps to the sorted distinct values
1326
+ present in the runs table — so the dropdowns only show options
1327
+ that have at least one matching run.
1328
+
1329
+ Per-column failure isolation lives in
1330
+ :meth:`RunsQuery.distinct_filter_values` (a missing column
1331
+ yields ``[]`` for that one filter without affecting the
1332
+ others). Wholesale daemon failure (transient connection
1333
+ error after retries) raises here; the page handler catches
1334
+ and renders a blank-options state.
1335
+ """
1336
+ from testerkit.analysis.runs_query import RunsQuery
1337
+
1338
+ with RunsQuery() as q:
1339
+ return q.distinct_filter_values()
1340
+
1341
+
1342
+ def _run_row_to_summary(row: Any) -> RunSummary:
1343
+ """Adapt a daemon ``RunRow`` to the legacy ``RunSummary`` UI shape.
1344
+
1345
+ Centralizes the field-by-field copy so every callsite that swaps
1346
+ ``backend.get_run`` for ``RunsQuery.get`` doesn't reinvent it. The
1347
+ UI consumes ``RunSummary``; the daemon emits ``RunRow``; this is
1348
+ the only place that names every field.
1349
+ """
1350
+ return RunSummary(
1351
+ test_run_id=row.run_id or "",
1352
+ session_id=row.session_id,
1353
+ site_index=row.site_index,
1354
+ site_name=row.site_name,
1355
+ started_at=row.started_at,
1356
+ ended_at=row.ended_at,
1357
+ uut_serial_number=row.uut_serial_number,
1358
+ uut_part_number=row.uut_part_number,
1359
+ part_id=row.part_id,
1360
+ station_id=row.station_id,
1361
+ station_name=row.station_name,
1362
+ station_hostname=row.station_hostname,
1363
+ fixture_id=row.fixture_id,
1364
+ test_phase=row.test_phase,
1365
+ project_name=row.project_name,
1366
+ operator=row.operator_id,
1367
+ outcome=row.outcome,
1368
+ total_measurements=row.num_measurements or 0,
1369
+ total_steps=row.num_steps or 0,
1370
+ file_path=row.file_path,
1371
+ )
1372
+
1373
+
1374
+ def get_run_detail(run_id: str):
1375
+ """Return ``(run, steps, measurements, is_multi_site)`` for a run.
1376
+
1377
+ Resolves the run through the daemon's typed ``RunsQuery`` rather
1378
+ than the parquet backend. The backend's ``get_run`` walks the
1379
+ parquet glob directly, which can silently miss runs whose unified
1380
+ parquet only has step-summary rows (no measurement rows). The
1381
+ daemon's index aggregates the unified parquet by ``run_id``, so
1382
+ any run reachable from ``/results`` resolves here.
1383
+
1384
+ ``run`` is adapted to ``RunSummary`` (the legacy UI shape the
1385
+ detail page expects). ``steps`` is the typed ``list[StepRow]``
1386
+ from the daemon's ``steps`` table. ``measurements`` is the flat
1387
+ measurement rows when the run recorded any, or ``[]`` for runs
1388
+ that produced only step-summary rows. ``is_multi_site`` is
1389
+ session→runs fan-out (``len(siblings) > 1``) — the parallel-gantt
1390
+ gate. ``site_index`` is always present now (0-based, default 0),
1391
+ so it can no longer signal "this run has sibling sites" the way a
1392
+ null check once did; a single-UUT run is indistinguishable from a
1393
+ multi-site run by ``site_index`` alone.
1394
+ """
1395
+ from testerkit.analysis.runs_query import RunsQuery
1396
+ from testerkit.analysis.steps_query import StepsQuery
1397
+
1398
+ backend = _results_backend()
1399
+ with RunsQuery(_data_dir=backend.data_dir) as rq:
1400
+ run_row = rq.get(run_id)
1401
+ if run_row is None:
1402
+ return None, [], [], False
1403
+
1404
+ run = _run_row_to_summary(run_row)
1405
+
1406
+ is_multi_site = False
1407
+ if run.session_id:
1408
+ siblings = rq.list_for_session(str(run.session_id), include_incomplete=True)
1409
+ is_multi_site = len(siblings) > 1
1410
+
1411
+ measurements: list[dict] = backend.get_measurements(run_id) if run.file_path else []
1412
+
1413
+ with StepsQuery(_data_dir=backend.data_dir) as q:
1414
+ steps = q.list_for_run(run_id, include_incomplete=True)
1415
+
1416
+ return run, steps, measurements, is_multi_site
1417
+
1418
+
1419
+ def load_artifact_ref(run_id: str, uri: str):
1420
+ """Resolve a measurement-output ref URI to its in-memory payload.
1421
+
1422
+ UI-side counterpart of ``GET /api/runs/{run_id}/ref``: avoids an
1423
+ extra HTTP round-trip when the page is rendered in the same
1424
+ Python process as the API.
1425
+ """
1426
+ from pathlib import Path
1427
+
1428
+ from testerkit.data.backends.parquet import load_ref
1429
+
1430
+ backend = _results_backend()
1431
+ run = backend.get_run(run_id)
1432
+ if run is None or not run.file_path:
1433
+ raise FileNotFoundError(f"Run {run_id!r} has no parquet file")
1434
+
1435
+ parquet_path = Path(run.file_path)
1436
+ if uri.startswith("channel://"):
1437
+ from testerkit.data.channels.client import channel_query_client
1438
+
1439
+ # Resolve channel refs through the daemon's warm index, not an
1440
+ # ephemeral globbing store (req 2). ChannelClient.query matches
1441
+ # the channel_store.query() shape load_ref calls.
1442
+ with channel_query_client(backend.data_dir / "channels") as client:
1443
+ return load_ref(uri, parquet_path=parquet_path, channel_store=client)
1444
+ return load_ref(uri, parquet_path=parquet_path, channel_store=None)
1445
+
1446
+
1447
+ def get_session_steps(session_id: str):
1448
+ """Return every step row across the session's sibling runs (typed)."""
1449
+ from testerkit.analysis.steps_query import StepsQuery
1450
+
1451
+ with StepsQuery() as q:
1452
+ return q.list_for_session(session_id)
1453
+
1454
+
1455
+ def list_all_runs(
1456
+ limit: int = 100,
1457
+ *,
1458
+ offset: int = 0,
1459
+ include_incomplete: bool = False,
1460
+ phase: str | list[str] | None = None,
1461
+ part: str | list[str] | None = None,
1462
+ station: str | list[str] | None = None,
1463
+ lot: str | list[str] | None = None,
1464
+ outcome: str | list[str] | None = None,
1465
+ since: str | None = None,
1466
+ until: str | None = None,
1467
+ ) -> list[RunSummary]:
1468
+ """List a page of runs for cross-run views (UUT history etc.).
1469
+
1470
+ Sources from ``RunsQuery`` (typed) and adapts each ``RunRow``
1471
+ to the legacy ``RunSummary`` shape the UI's run-list page
1472
+ consumes. Same data, no parquet read.
1473
+
1474
+ ``offset`` skips that many rows before returning ``limit``;
1475
+ used by Quasar's server-side pagination. Filter args
1476
+ (``phase`` / ``part`` / ``station`` / ``lot`` / ``outcome`` /
1477
+ ``since`` / ``until``) pass through to :meth:`RunsQuery.list_recent`.
1478
+ """
1479
+ from testerkit.analysis.runs_query import RunsQuery
1480
+
1481
+ with RunsQuery() as q:
1482
+ rows = q.list_recent(
1483
+ limit=limit,
1484
+ offset=offset,
1485
+ include_incomplete=include_incomplete,
1486
+ phase=phase,
1487
+ part=part,
1488
+ station=station,
1489
+ lot=lot,
1490
+ outcome=outcome,
1491
+ since=since,
1492
+ until=until,
1493
+ )
1494
+ return [_run_row_to_summary(r) for r in rows]
1495
+
1496
+
1497
+ def usage_stats_by(field: str) -> dict[str, dict[str, Any]]:
1498
+ """Aggregate run stats grouped by a ``RunRow`` field.
1499
+
1500
+ Used by the configuration list pages (Stations, Parts,
1501
+ Fixtures, Instruments, Tests) to show "how busy is this entity"
1502
+ columns next to each row. Returns
1503
+ ``{value: {runs, passed, failed, last_run}}`` keyed by the
1504
+ grouped field's value (e.g. ``station_id``).
1505
+
1506
+ Skips runs where the grouped field is null. Aggregation is pushed
1507
+ into SQL so the daemon returns one row per distinct value — safe
1508
+ regardless of total run count.
1509
+
1510
+ ``field`` must be a valid ``usage_stats`` group-by column; an invalid
1511
+ name raises ``ValueError`` — a programming error, surfaced rather than
1512
+ silently masked as "no data" (the failure mode that hid the parts bug).
1513
+ """
1514
+ from testerkit.analysis.runs_query import RunsQuery
1515
+
1516
+ with RunsQuery() as q:
1517
+ rows = q.usage_stats(field)
1518
+
1519
+ return {
1520
+ r["value"]: {
1521
+ "runs": r.get("runs", 0),
1522
+ "passed": r.get("pass_count", 0),
1523
+ "failed": r.get("fail_count", 0),
1524
+ "errored": r.get("errored_count", 0),
1525
+ "last_run": r.get("last_run"),
1526
+ }
1527
+ for r in rows
1528
+ if r.get("value")
1529
+ }
1530
+
1531
+
1532
+ def aggregate_run_stats(steps: list, measurements: list[dict]) -> dict[str, Any]:
1533
+ """Compute run-level stats from typed steps + flat measurement rows.
1534
+
1535
+ ``steps`` is the typed ``list[StepRow]`` from the daemon's
1536
+ ``steps`` table — total_steps and failed_steps come straight
1537
+ from there so measurement-less runs render correct counts.
1538
+ Measurement counts come from the flat measurement parquet rows.
1539
+
1540
+ Returned keys: total_measurements, passed_measurements,
1541
+ failed_measurements, total_steps, failed_steps.
1542
+ """
1543
+ # Measurement rows key their outcome as ``measurement_outcome`` (the
1544
+ # ``measurements`` view column) — the same key the flat Measurements tab reads.
1545
+ # ``outcome`` is not a column on a measurement row, so it always missed → 0/0.
1546
+ total_measurements = len(measurements)
1547
+ failed_measurements = sum(1 for m in measurements if m.get("measurement_outcome") == "failed")
1548
+ passed_measurements = sum(1 for m in measurements if m.get("measurement_outcome") == "passed")
1549
+
1550
+ total_steps = len(steps)
1551
+ failed_steps = sum(1 for s in steps if s.outcome == "failed")
1552
+
1553
+ return {
1554
+ "total_measurements": total_measurements,
1555
+ "passed_measurements": passed_measurements,
1556
+ "failed_measurements": failed_measurements,
1557
+ "total_steps": total_steps,
1558
+ "failed_steps": failed_steps,
1559
+ }
1560
+
1561
+
1562
+ # -----------------------------------------------------------------------------
1563
+ # Event / Channel Store Services
1564
+ # -----------------------------------------------------------------------------
1565
+ #
1566
+ # Thin in-process adapters over the shared query implementations in
1567
+ # ``testerkit.mcp.tools``. The HTTP API uses the same functions, so the
1568
+ # operator UI and external clients see the same shapes.
1569
+
1570
+
1571
+ def _resolve_data_dir() -> Path | None:
1572
+ """Project-configured results dir (or None for the platformdirs default)."""
1573
+ project = load_project_config()
1574
+ return Path(project.data_dir) if project.data_dir else None
1575
+
1576
+
1577
+ def query_events(
1578
+ *,
1579
+ session_id: str | None = None,
1580
+ event_type: str | None = None,
1581
+ role: str | None = None,
1582
+ since: str | None = None,
1583
+ limit: int = 100,
1584
+ ) -> dict[str, Any]:
1585
+ """Browse the event log with optional filters.
1586
+
1587
+ Returns ``{"events": [...], "count": int}``. See
1588
+ :func:`testerkit.mcp.tools.events_query` for filter semantics.
1589
+ """
1590
+ from testerkit.mcp.tools import events_query
1591
+
1592
+ return events_query(
1593
+ session_id,
1594
+ event_type,
1595
+ role,
1596
+ since,
1597
+ limit,
1598
+ data_dir=_resolve_data_dir(),
1599
+ )
1600
+
1601
+
1602
+ def query_sessions() -> dict[str, Any]:
1603
+ """List known sessions (``session.started`` events).
1604
+
1605
+ Returns ``{"sessions": [...], "count": int}``. Each entry is a
1606
+ SessionStarted event dict — ``session_id``, ``client``,
1607
+ ``occurred_at``, ``station_hostname``, ``operator_name``, etc.
1608
+ See :func:`format_session_label` for the operator-readable
1609
+ one-liner the filter widgets render.
1610
+ """
1611
+ from testerkit.mcp.tools import sessions_query
1612
+
1613
+ return sessions_query(data_dir=_resolve_data_dir())
1614
+
1615
+
1616
+ def list_channels() -> dict[str, Any]:
1617
+ """Return the channel registry as ``{"channels": {channel_id: {...}}}``."""
1618
+ from testerkit.mcp.tools import channels_list_query
1619
+
1620
+ return channels_list_query(data_dir=_resolve_data_dir())
1621
+
1622
+
1623
+ def list_channels_recent(*, last_n: int = 50) -> dict[str, Any]:
1624
+ """Return the channel registry plus recent samples per channel.
1625
+
1626
+ Used by the operator UI to render sparkline cells and a live-
1627
+ updating "Latest" column. ``last_n`` caps the per-channel sample
1628
+ count returned (default 50 — enough for a sparkline trace).
1629
+ """
1630
+ from testerkit.mcp.tools import channels_recent_query
1631
+
1632
+ return channels_recent_query(last_n=last_n, data_dir=_resolve_data_dir())
1633
+
1634
+
1635
+ def query_channel(
1636
+ channel_id: str,
1637
+ *,
1638
+ session_id: str | None = None,
1639
+ since: str | None = None,
1640
+ until: str | None = None,
1641
+ last_n: int | None = None,
1642
+ max_points: int | None = None,
1643
+ ) -> dict[str, Any]:
1644
+ """Query data for one channel with optional filters / LTTB decimation.
1645
+
1646
+ Returns ``{"channel_id": str, "data": [row, ...]}`` where each row is
1647
+ a dict matching the channel's Arrow schema (timestamp + value /
1648
+ samples + source_method + session_id + sample_offset).
1649
+ """
1650
+ from testerkit.mcp.tools import channels_query
1651
+
1652
+ return channels_query(
1653
+ channel_id,
1654
+ session_id=session_id,
1655
+ since=since,
1656
+ until=until,
1657
+ last_n=last_n,
1658
+ max_points=max_points,
1659
+ data_dir=_resolve_data_dir(),
1660
+ )
1661
+
1662
+
1663
+ # -----------------------------------------------------------------------------
1664
+ # FileStore Services
1665
+ # -----------------------------------------------------------------------------
1666
+
1667
+
1668
+ def files_dir_exists() -> bool:
1669
+ """Return True if the FileStore on-disk directory exists.
1670
+
1671
+ Lets the operator UI distinguish "no artifacts written yet"
1672
+ (directory exists, empty walk) from "files directory not found"
1673
+ (directory missing — possible data wipe, misconfigured data_dir,
1674
+ or fresh project that has never written a FileStore artifact).
1675
+ The two surfaces want different empty-state copy.
1676
+ """
1677
+ from testerkit.data.data_dir import resolve_data_dir
1678
+
1679
+ project_dir = _resolve_data_dir()
1680
+ return (resolve_data_dir(project_dir) / "files").exists()
1681
+
1682
+
1683
+ def list_recent_files(*, limit: int = 200) -> list[dict[str, Any]]:
1684
+ """Return artifact descriptors from the files catalog daemon, newest first.
1685
+
1686
+ Each descriptor: ``{"uri", "session_id", "filename", "mime",
1687
+ "extension", "size_bytes", "created_at", "attributes"}``, up to
1688
+ ``limit`` most-recent artifacts. Reads through the daemon's warm
1689
+ catalog (req 2) — never a directory walk — so it works identically
1690
+ against a local or remote blob backend. Returns an empty list when
1691
+ the files dir doesn't exist yet (nothing has been written).
1692
+ """
1693
+ import json
1694
+
1695
+ from testerkit.data.data_dir import resolve_data_dir
1696
+ from testerkit.data.files.catalog_manager import list_recent
1697
+
1698
+ project_dir = _resolve_data_dir()
1699
+ files_dir = resolve_data_dir(project_dir) / "files"
1700
+ if not files_dir.exists():
1701
+ return []
1702
+ return [
1703
+ {
1704
+ "uri": r["uri"],
1705
+ "session_id": r["session_id"],
1706
+ "filename": r["name"],
1707
+ "mime": r["mime"],
1708
+ "extension": r["extension"],
1709
+ "size_bytes": r["size_bytes"],
1710
+ "created_at": r["created_at"],
1711
+ "attributes": json.loads(r["attributes"]) if r["attributes"] else {},
1712
+ }
1713
+ for r in list_recent(files_dir, limit)
1714
+ ]
1715
+
1716
+
1717
+ # -----------------------------------------------------------------------------
1718
+ # Yield Services
1719
+ # -----------------------------------------------------------------------------