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,54 @@
1
+ """Instrument accessors for test functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from testerkit.execution._state import get_current_step_aliases
8
+ from testerkit.models.instrument import InstrumentRecord
9
+
10
+
11
+ def _instrument_not_found(alias: str, target: str, instruments: dict[str, Any]) -> KeyError:
12
+ """Build a KeyError for a missing instrument, listing available roles."""
13
+ available = ", ".join(sorted(instruments)) or "(none)"
14
+ if alias != target:
15
+ return KeyError(
16
+ f"Alias '{alias}' targets '{target}' which is not in "
17
+ f"station instruments. Available: {available}"
18
+ )
19
+ return KeyError(f"No instrument with role '{alias}'. Available: {available}")
20
+
21
+
22
+ class InstrumentAccessor:
23
+ """Callable accessor for instruments by role, with type-based grouping."""
24
+
25
+ def __init__(self, instruments: dict[str, Any], records: dict[str, InstrumentRecord]):
26
+ self._instruments = instruments
27
+ self._records = records
28
+
29
+ def _current_aliases(self) -> dict[str, str]:
30
+ """Get current step aliases from the active ContextVar."""
31
+ return get_current_step_aliases()
32
+
33
+ def __call__(self, role: str) -> Any:
34
+ """Get instrument by role name, resolving aliases. Raises KeyError with available roles."""
35
+ aliases = self._current_aliases()
36
+ resolved = aliases.get(role, role)
37
+ if resolved not in self._instruments:
38
+ raise _instrument_not_found(role, resolved, self._instruments)
39
+ return self._instruments[resolved]
40
+
41
+ def by_type(self, driver_path: str) -> dict[str, Any]:
42
+ """Get all instruments matching a driver class import path."""
43
+ return {
44
+ role: self._instruments[role]
45
+ for role, record in self._records.items()
46
+ if record.driver == driver_path and role in self._instruments
47
+ }
48
+
49
+ def roles(self) -> list[str]:
50
+ """List available instrument role names, including active aliases."""
51
+ aliases = self._current_aliases()
52
+ names = set(self._instruments.keys())
53
+ names.update(aliases.keys())
54
+ return sorted(names)
@@ -0,0 +1,51 @@
1
+ """Runner-neutral traceability audit.
2
+
3
+ Walks the just-completed step's measurements and reports any that
4
+ are missing required traceability fields. Works for any runner that
5
+ records measurements through :class:`RunScope` — pytest, OpenHTF,
6
+ unittest wrappers all share this check.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ def audit_traceability(logger_inst: Any, *, strict: bool, spec_active: bool) -> None:
15
+ """Check the current step's measurements for traceability completeness.
16
+
17
+ Walks measurements recorded during the just-completed test and
18
+ flags any that lack required fields:
19
+
20
+ * ``step_path`` — always required (populated by the runner's step
21
+ wrapper).
22
+ * ``spec_ref`` OR ``uut_pin`` — required only when ``spec_active``
23
+ is True (i.e. a part spec is loaded for the session). Runs
24
+ without a spec exercise the graceful-degradation path and are
25
+ not penalized for lacking pin/spec references.
26
+
27
+ In ``strict`` mode, raises :class:`AssertionError` if any
28
+ measurement is incomplete so the test fails. Otherwise returns
29
+ silently — the caller decides whether to surface the issues.
30
+ """
31
+ steps = getattr(getattr(logger_inst, "test_run", None), "steps", None)
32
+ if not steps:
33
+ return
34
+ step = steps[-1]
35
+
36
+ incomplete: list[str] = []
37
+ for vec in step.vectors:
38
+ for m in vec.measurements:
39
+ missing: list[str] = []
40
+ if not m.step_path:
41
+ missing.append("step_path")
42
+ if spec_active and not m.spec_ref and not m.uut_pin:
43
+ missing.append("spec_ref/uut_pin")
44
+ if missing:
45
+ incomplete.append(f"{m.name}: missing {', '.join(missing)}")
46
+
47
+ if incomplete and strict:
48
+ raise AssertionError(
49
+ "--strict-traceability: measurements missing required fields:\n "
50
+ + "\n ".join(incomplete)
51
+ )
@@ -0,0 +1,85 @@
1
+ """Runner-neutral cascade walk — sidecar + profile → merged :class:`TestEntry`.
2
+
3
+ Re-exports :func:`testerkit.execution.sidecar.merged_test_entry` and adds
4
+ :func:`cascade_for` that combines sidecar lookup, profile lookup, and
5
+ merge into a single call. Each runner's plugin invokes this once per
6
+ test to get the typed :class:`TestEntry` it needs to inject markers
7
+ from.
8
+
9
+ Inputs are runner-neutral (a :class:`SidecarConfig` and the active
10
+ :class:`ProfileConfig`, plus class / function names). Pytest's
11
+ plugin reads the sidecar via ``load_sidecar(module_file)`` and the
12
+ profile via ``get_active_profile()``; another runner reads its
13
+ host's equivalents and calls the same function.
14
+
15
+ Also exposes :func:`find_unmatched_profile_keys` — a profile typo
16
+ detector. A profile that lists ``tests.test_typo`` when no test by
17
+ that name exists in the collected suite is a production-screen
18
+ silent no-op; warn loudly. The runner's plugin gathers the test IDs
19
+ its host collected and asks this module which profile keys map to
20
+ nothing.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Iterable
26
+
27
+ from testerkit.execution.sidecar import _merge_entry_into, merged_test_entry
28
+ from testerkit.models.project import ProfileConfig
29
+ from testerkit.models.test_config import SidecarConfig, TestEntry
30
+
31
+ __all__ = ["cascade_for", "find_unmatched_profile_keys", "merged_test_entry"]
32
+
33
+
34
+ def cascade_for(
35
+ sidecar: SidecarConfig | None,
36
+ profile: ProfileConfig | None,
37
+ cls_name: str | None,
38
+ func_name: str | None,
39
+ ) -> TestEntry:
40
+ """Build the merged :class:`TestEntry` for one test.
41
+
42
+ Cascade order: sidecar (file → class → leaf) then profile
43
+ (root → class → leaf), with last-wins per field. Either source
44
+ may be ``None``.
45
+ """
46
+ merged = TestEntry()
47
+ if sidecar is not None:
48
+ _merge_entry_into(merged, merged_test_entry(sidecar, cls_name, func_name))
49
+ if profile is not None:
50
+ _merge_entry_into(merged, merged_test_entry(profile, cls_name, func_name))
51
+ return merged
52
+
53
+
54
+ def find_unmatched_profile_keys(
55
+ profile: ProfileConfig,
56
+ test_ids: Iterable[tuple[str | None, str]],
57
+ ) -> list[str]:
58
+ """Return profile.tests keys that match no collected test.
59
+
60
+ ``test_ids`` is the iterable of ``(class_name, func_name)`` pairs
61
+ the runner collected. Classless module-level tests pass
62
+ ``class_name=None``. Returns formatted ``profile.tests[...]``
63
+ paths in declared order — caller emits a warning if non-empty.
64
+ """
65
+ func_names: set[str] = set()
66
+ methods_by_class: dict[str, set[str]] = {}
67
+ class_names: set[str] = set()
68
+ for cls_name, func_name in test_ids:
69
+ func_names.add(func_name)
70
+ if cls_name is not None:
71
+ class_names.add(cls_name)
72
+ methods_by_class.setdefault(cls_name, set()).add(func_name)
73
+
74
+ unmatched: list[str] = []
75
+ for key, entry in profile.tests.items():
76
+ if key in class_names:
77
+ for nested_key in entry.tests:
78
+ if nested_key in methods_by_class[key]:
79
+ continue
80
+ unmatched.append(f" profile.tests[{key!r}].tests[{nested_key!r}]")
81
+ continue
82
+ if key in func_names:
83
+ continue
84
+ unmatched.append(f" profile.tests[{key!r}]")
85
+ return unmatched
@@ -0,0 +1,491 @@
1
+ """Resolve ``testerkit_characteristics`` / ``testerkit_connections`` markers to fixture connections.
2
+
3
+ The two markers compose into a single ordered list of
4
+ :class:`FixtureConnection` instances that the test body iterates via
5
+ ``ctx.connections``. ``testerkit_characteristics`` supplies the
6
+ characteristic list (each char's ``resolved_pins``);
7
+ ``testerkit_connections`` supplies an explicit named-connection list or
8
+ an instrument-channel selector. When both are present the connection
9
+ list narrows the union of chars' pin sets; when only one is present
10
+ the resolver derives the iteration set from it directly.
11
+
12
+ Pulled out of ``plugin.py`` so that file stays focused on pytest hook
13
+ registration. The pytest fixture wrapping this resolver
14
+ (``_testerkit_resolve_connections``) lives in ``plugin.py``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import KeysView, Sequence, ValuesView
20
+ from contextvars import Token
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from testerkit.execution._state import (
24
+ push_active_characteristic,
25
+ push_active_connection,
26
+ reset_active_characteristic,
27
+ reset_active_connection,
28
+ )
29
+ from testerkit.models.test_config import FixtureConfig, FixtureConnection
30
+
31
+ if TYPE_CHECKING:
32
+ import pytest
33
+
34
+
35
+ class ConnectionResolutionError(Exception):
36
+ """Raised when ``testerkit_characteristics`` / ``testerkit_connections`` cannot resolve.
37
+
38
+ Runner-neutral: the pytest adapter catches this and re-raises as
39
+ ``ConnectionResolutionError`` at the fixture boundary; other runners adapt
40
+ to their own user-error type.
41
+ """
42
+
43
+
44
+ class ConnectionIterator:
45
+ """Iterable + name-addressable view over the bound :class:`FixtureConnection` list.
46
+
47
+ Built by ``_testerkit_resolve_connections`` from a test's spec/connections markers.
48
+ The instance is iterable (drives the ``_active_connection_var``
49
+ ContextVar so drivers can route to the *current* connection) and
50
+ also supports ``__getitem__`` / ``keys()`` / ``values()`` /
51
+ ``__contains__`` for passive name lookup. Iteration is single-use
52
+ and follows insertion order; lookup never pushes the ContextVar.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ connections: list[FixtureConnection],
58
+ conn_to_char: dict[str, str] | None = None,
59
+ _parent: ConnectionIterator | None = None,
60
+ ) -> None:
61
+ self._connections = connections
62
+ self._by_name: dict[str, FixtureConnection] = {c.name: c for c in connections}
63
+ # Per-connection char lookup, keyed by connection name. Built
64
+ # by ``resolve_test_connections`` from the spec; empty when no
65
+ # chars are in scope (pure ``testerkit_connections`` bringup).
66
+ self._conn_to_char: dict[str, str] = conn_to_char or {}
67
+ # Parent iterator (for ``for_characteristic`` sub-iterators) so
68
+ # iterating the child also marks the parent ``started`` —
69
+ # actual iteration, not iterator construction, is what the
70
+ # consumption guard cares about.
71
+ self._parent: ConnectionIterator | None = _parent
72
+ self._idx = 0
73
+ self._conn_token: Token[FixtureConnection | None] | None = None
74
+ self._char_token: Token[str | None] | None = None
75
+ self.started = False
76
+
77
+ def __iter__(self) -> ConnectionIterator:
78
+ return self
79
+
80
+ def __next__(self) -> FixtureConnection:
81
+ self._reset_tokens()
82
+ if self._idx >= len(self._connections):
83
+ raise StopIteration
84
+ connection = self._connections[self._idx]
85
+ self._idx += 1
86
+ self.started = True
87
+ if self._parent is not None:
88
+ self._parent.started = True
89
+ self._conn_token = push_active_connection(connection)
90
+ char = self._conn_to_char.get(connection.name)
91
+ if char is not None:
92
+ self._char_token = push_active_characteristic(char)
93
+ return connection
94
+
95
+ def __len__(self) -> int:
96
+ return len(self._connections)
97
+
98
+ def __getitem__(self, key: str | int) -> FixtureConnection:
99
+ if isinstance(key, int):
100
+ return self._connections[key]
101
+ return self._by_name[key]
102
+
103
+ def __contains__(self, key: object) -> bool:
104
+ return key in self._by_name
105
+
106
+ def keys(self) -> KeysView[str]:
107
+ return self._by_name.keys()
108
+
109
+ def values(self) -> ValuesView[FixtureConnection]:
110
+ return self._by_name.values()
111
+
112
+ def for_characteristic(self, char_id: str) -> ConnectionIterator:
113
+ """Return a fresh iterator scoped to connections owned by ``char_id``.
114
+
115
+ Iterates only the subset whose ``conn_to_char`` mapping matches
116
+ ``char_id``. Each ``__next__`` pushes the active-char ContextVar
117
+ to ``char_id`` (so ``verify`` stamps the right
118
+ ``characteristic_id``) AND marks the parent iterator
119
+ ``started`` — so the consumption guard in
120
+ ``_testerkit_resolve_connections`` sees real iteration, not just
121
+ iterator construction. Building a sub-iterator and never
122
+ iterating it does NOT count as consumption.
123
+ """
124
+ subset = [
125
+ conn for conn in self._connections if self._conn_to_char.get(conn.name) == char_id
126
+ ]
127
+ return ConnectionIterator(subset, {c.name: char_id for c in subset}, _parent=self)
128
+
129
+ def _reset_tokens(self) -> None:
130
+ if self._char_token is not None:
131
+ reset_active_characteristic(self._char_token)
132
+ self._char_token = None
133
+ if self._conn_token is not None:
134
+ reset_active_connection(self._conn_token)
135
+ self._conn_token = None
136
+
137
+ def cleanup(self) -> None:
138
+ """Pop any lingering active-connection / active-char tokens on teardown."""
139
+ self._reset_tokens()
140
+
141
+
142
+ def _per_char_pins(characteristics: Sequence[str], spec_ctx: Any) -> dict[str, list[str]]:
143
+ """Return per-char ``resolved_pins`` lists, validating each char.
144
+
145
+ Keys preserve user-listed order; values are each char's pin list.
146
+ Raises if the spec context is missing or a char is unknown.
147
+ """
148
+ if spec_ctx is None:
149
+ raise ConnectionResolutionError(
150
+ f"testerkit_characteristics({list(characteristics)!r}) "
151
+ "requires a part (load via --part=<id-or-path> / parts/ auto-discovery)."
152
+ )
153
+ out: dict[str, list[str]] = {}
154
+ for char_id in characteristics:
155
+ char = spec_ctx.part.characteristics.get(char_id)
156
+ if char is None:
157
+ raise ConnectionResolutionError(
158
+ f"Characteristic {char_id!r} not found in part {spec_ctx.part.id!r}."
159
+ )
160
+ out[char_id] = list(char.resolved_pins)
161
+ return out
162
+
163
+
164
+ def _pick_connection_for_pin(
165
+ pin_id: str,
166
+ target_function: Any,
167
+ pin_net: str | None,
168
+ fixture_cfg: FixtureConfig,
169
+ ) -> FixtureConnection | None:
170
+ """Pick the fixture connection that routes ``(pin, function)``.
171
+
172
+ Match priority, highest first:
173
+
174
+ 1. **Exact**: char's ``function`` and conn's ``function`` are both set
175
+ and equal — the strongest match.
176
+ 2. **Pin-only**: at least one side has no ``function``. Either the
177
+ char doesn't declare what function it needs (backward-compatible
178
+ with pre-function fixtures) or the conn is generic
179
+ (function-unset). The first such conn for the pin wins.
180
+
181
+ Connections where both sides set ``function`` but the values differ
182
+ are skipped — a wired-up DC connection isn't valid for an AC char.
183
+ Match by ``uut_pin`` first; falls back to net match when
184
+ ``uut_pin`` is unset on the connection.
185
+ """
186
+ exact_match: FixtureConnection | None = None
187
+ pin_only_match: FixtureConnection | None = None
188
+ for conn in fixture_cfg.connections.values():
189
+ pin_hit = conn.uut_pin == pin_id or (pin_net is not None and conn.net == pin_net)
190
+ if not pin_hit:
191
+ continue
192
+ if target_function is not None and conn.function == target_function:
193
+ exact_match = exact_match or conn
194
+ elif target_function is None or conn.function is None:
195
+ # At least one side is None — pin-only fallback.
196
+ pin_only_match = pin_only_match or conn
197
+ # else: both sides set but different — skip (e.g. char wants
198
+ # ac_voltage but this conn is dc_voltage-only).
199
+ return exact_match or pin_only_match
200
+
201
+
202
+ def _resolve_chars_to_connections(
203
+ characteristics: Sequence[str],
204
+ spec_ctx: Any,
205
+ fixture_cfg: FixtureConfig,
206
+ ) -> tuple[list[FixtureConnection], dict[str, str]]:
207
+ """Match each char's ``(pin, function)`` against the fixture.
208
+
209
+ Returns a deduplicated connection list (preserving char order, then
210
+ char-pin order) plus a ``conn_name → char_id`` lookup. When the
211
+ same fixture connection is selected by multiple chars, the
212
+ first-listing char owns it (matching marker order).
213
+
214
+ Edge case: when two chars share a pin but neither declares
215
+ ``function:`` (or both share the same function), they bind to the
216
+ same connection — only the first-listed char owns it in
217
+ ``conn_to_char``. ``ctx.connections.for_characteristic(<later_char>)``
218
+ will return an empty subset for the loser. To avoid this, declare
219
+ distinct ``function:`` values on the chars and the matching
220
+ connections (the per-function tie-break in
221
+ :func:`_pick_connection_for_pin`), or put the chars on different
222
+ pins.
223
+ """
224
+ matched: list[FixtureConnection] = []
225
+ conn_to_char: dict[str, str] = {}
226
+ seen_names: set[str] = set()
227
+ for char_id in characteristics:
228
+ char = spec_ctx.part.characteristics.get(char_id)
229
+ if char is None:
230
+ continue
231
+ for pin_id in char.resolved_pins:
232
+ pin = spec_ctx.part.pins.get(pin_id)
233
+ net = pin.net if pin else None
234
+ conn = _pick_connection_for_pin(pin_id, char.function, net, fixture_cfg)
235
+ if conn is None or conn.name in seen_names:
236
+ continue
237
+ matched.append(conn)
238
+ conn_to_char[conn.name] = char_id
239
+ seen_names.add(conn.name)
240
+ return matched, conn_to_char
241
+
242
+
243
+ def _resolve_connections_payload(
244
+ mark: Any,
245
+ ) -> tuple[list[str] | None, dict[str, Any] | None]:
246
+ """Return ``(names, instrument_channels)`` from a ``testerkit_connections`` mark.
247
+
248
+ The marker is a shape-discriminated one-of:
249
+
250
+ * Positional list of strings → bind by fixture-connection name
251
+ (``@pytest.mark.testerkit_connections(["vout_measure"])``).
252
+ * Kwargs by instrument name → bind by instrument → channel selectors
253
+ (``@pytest.mark.testerkit_connections(dmm=["ch1"])``).
254
+
255
+ Exactly one shape may appear per marker invocation. Pydantic already
256
+ enforces the one-of at the YAML layer (``TestEntry.connections`` is a
257
+ ``list[str] | dict[str, Any]`` discriminated union); this function
258
+ is the parallel guard for the inline-marker call site.
259
+ """
260
+ args = list(getattr(mark, "args", ()) or ())
261
+ kwargs = dict(getattr(mark, "kwargs", {}) or {})
262
+ if args and kwargs:
263
+ raise ConnectionResolutionError(
264
+ "testerkit_connections takes EITHER a positional list of "
265
+ "connection names OR instrument kwargs, not both. Got "
266
+ f"args={args!r} kwargs={kwargs!r}."
267
+ )
268
+ if args:
269
+ positional: Any = args[0] if len(args) == 1 else None
270
+ if not isinstance(positional, list):
271
+ raise ConnectionResolutionError(
272
+ "testerkit_connections positional form takes one list of "
273
+ f"connection names. Got {args!r}."
274
+ )
275
+ names: list[Any] = positional
276
+ if not all(isinstance(n, str) for n in names):
277
+ raise ConnectionResolutionError(
278
+ f"testerkit_connections names must be strings; got {names!r}."
279
+ )
280
+ return [str(n) for n in names], None
281
+ if kwargs:
282
+ return None, kwargs
283
+ raise ConnectionResolutionError(
284
+ "testerkit_connections requires either a positional list of "
285
+ "connection names or instrument kwargs."
286
+ )
287
+
288
+
289
+ def _named_connections(
290
+ names: list[str],
291
+ fixture_cfg: FixtureConfig | None,
292
+ char_pins: set[str] | None,
293
+ ) -> list[FixtureConnection]:
294
+ """Resolve ``connections=[name, ...]`` against the fixture, in user-listed order."""
295
+ if fixture_cfg is None:
296
+ raise ConnectionResolutionError(
297
+ "testerkit_connections(connections=...) requires a fixture config; "
298
+ "connection names are only meaningful relative to a fixture YAML."
299
+ )
300
+ resolved: list[FixtureConnection] = []
301
+ for name in names:
302
+ conn = fixture_cfg.connections.get(name)
303
+ if conn is None:
304
+ raise ConnectionResolutionError(
305
+ f"Fixture connection {name!r} not found in fixture config."
306
+ )
307
+ resolved.append(conn)
308
+ if char_pins is not None:
309
+ invalid = [conn.name for conn in resolved if conn.uut_pin not in char_pins]
310
+ if invalid:
311
+ raise ConnectionResolutionError(
312
+ f"testerkit_connections names {invalid} resolve to pins outside "
313
+ f"the union of declared characteristics' pin sets {sorted(char_pins)}."
314
+ )
315
+ return resolved
316
+
317
+
318
+ def _channel_selectors(
319
+ instrument_channels: dict[str, Any],
320
+ fixture_cfg: FixtureConfig | None,
321
+ char_pins: set[str] | None,
322
+ ) -> list[FixtureConnection]:
323
+ """Resolve ``instrument_channels={inst: [chs]}`` in user-listed order.
324
+
325
+ With a fixture, matches each (instrument, channel) selector against
326
+ fixture connections; with ``char_pins`` set, every match must be in
327
+ that pin set. Without a fixture, synthesizes minimal
328
+ :class:`FixtureConnection` stubs from the (instrument, channel)
329
+ tuples — usable for early bringup where the UUT-pin mapping isn't
330
+ yet captured.
331
+ """
332
+ if fixture_cfg is None:
333
+ stubs: list[FixtureConnection] = []
334
+ for inst_name, channels in instrument_channels.items():
335
+ if channels == "all":
336
+ raise ConnectionResolutionError(
337
+ f"testerkit_connections instrument_channels[{inst_name!r}]='all' "
338
+ "requires a fixture config."
339
+ )
340
+ for ch in channels:
341
+ ch_str = str(ch)
342
+ stubs.append(
343
+ FixtureConnection(
344
+ name=f"{inst_name}_ch{ch_str}",
345
+ instrument=inst_name,
346
+ instrument_channel=ch_str,
347
+ )
348
+ )
349
+ return stubs
350
+
351
+ matched: list[FixtureConnection] = []
352
+ for inst_name, channels in instrument_channels.items():
353
+ if channels == "all":
354
+ inst_matches = [
355
+ conn for conn in fixture_cfg.connections.values() if conn.instrument == inst_name
356
+ ]
357
+ else:
358
+ inst_matches = []
359
+ for ch in channels:
360
+ ch_str = str(ch)
361
+ hit = next(
362
+ (
363
+ conn
364
+ for conn in fixture_cfg.connections.values()
365
+ if conn.instrument == inst_name and conn.instrument_channel == ch_str
366
+ ),
367
+ None,
368
+ )
369
+ if hit is None:
370
+ raise ConnectionResolutionError(
371
+ f"testerkit_connections instrument_channels[{inst_name!r}]={ch_str!r} "
372
+ "matched no fixture connection."
373
+ )
374
+ inst_matches.append(hit)
375
+ matched.extend(inst_matches)
376
+
377
+ if char_pins is not None:
378
+ invalid = [
379
+ f"{conn.instrument}/{conn.instrument_channel}"
380
+ for conn in matched
381
+ if conn.uut_pin not in char_pins
382
+ ]
383
+ if invalid:
384
+ raise ConnectionResolutionError(
385
+ f"testerkit_connections selected channels {invalid} resolve to pins outside "
386
+ f"the union of declared characteristics' pin sets {sorted(char_pins)}."
387
+ )
388
+ return matched
389
+
390
+
391
+ def resolve_test_connections(
392
+ characteristics: Sequence[str],
393
+ conn_marker: pytest.Mark | None,
394
+ spec_ctx: Any,
395
+ fixture_cfg: FixtureConfig | None,
396
+ ) -> tuple[list[FixtureConnection], dict[str, str]]:
397
+ """Resolve the iterable connection set + per-connection char lookup.
398
+
399
+ The two markers compose:
400
+
401
+ * **chars only** — derive connections from each char's
402
+ ``(pin, function)``, matched against the fixture. Empty list
403
+ when ``characteristics`` is empty AND no ``conn_marker``.
404
+ * **connections only** — explicit lookup against the fixture, or
405
+ synthesized stubs when ``instrument_channels`` is given without
406
+ a fixture.
407
+ * **chars + connections** — connections narrows the union of the
408
+ chars' pin sets. Iteration order follows the user-listed
409
+ connections; every selected connection must lie within the
410
+ union pin set. Fixtureless ``instrument_channels`` with
411
+ characteristics in scope is rejected — there is no ``uut_pin``
412
+ to validate against (drop the characteristics for pure bringup,
413
+ or load a fixture).
414
+
415
+ Returns a tuple of ``(connections, conn_to_char)`` where
416
+ ``conn_to_char`` maps each connection's ``name`` to the char that
417
+ owns it (used by ``ConnectionIterator`` to push the active-char
418
+ ContextVar per connection). The lookup is empty for the
419
+ pure-``testerkit_connections`` bringup case.
420
+ """
421
+ if not characteristics and conn_marker is None:
422
+ return [], {}
423
+
424
+ per_char_pins: dict[str, list[str]] = {}
425
+ if characteristics:
426
+ per_char_pins = _per_char_pins(characteristics, spec_ctx)
427
+
428
+ if conn_marker is None:
429
+ if fixture_cfg is None:
430
+ # Chars in scope but no fixture loaded → ctx.connections is
431
+ # an empty iterator. Tests that only resolve limits (and
432
+ # never iterate connections) work fine in this mode; tests
433
+ # that DO iterate trip the post-run "declared but didn't
434
+ # consume" guard in _testerkit_resolve_connections.
435
+ return [], {}
436
+ return _resolve_chars_to_connections(characteristics, spec_ctx, fixture_cfg)
437
+
438
+ union_pins: set[str] | None = None
439
+ if per_char_pins:
440
+ union_pins = {pin for pins in per_char_pins.values() for pin in pins}
441
+
442
+ names, instrument_channels = _resolve_connections_payload(conn_marker)
443
+ if names is not None:
444
+ connections = _named_connections(names, fixture_cfg, union_pins)
445
+ else:
446
+ assert instrument_channels is not None
447
+ # Tighten bug #15: chars in scope + fixtureless instrument_channels
448
+ # would silently bypass the per-char pin-set cross-check (no
449
+ # uut_pin to validate against). That makes testerkit_characteristics
450
+ # dead weight in this branch — flag it loud.
451
+ if characteristics and fixture_cfg is None:
452
+ raise ConnectionResolutionError(
453
+ f"testerkit_characteristics({list(characteristics)!r}) cannot "
454
+ "validate fixtureless instrument_channels selectors — there "
455
+ "is no uut_pin to cross-check against. Drop "
456
+ "testerkit_characteristics for pure bringup, or load a "
457
+ "fixture."
458
+ )
459
+ connections = _channel_selectors(instrument_channels, fixture_cfg, union_pins)
460
+
461
+ conn_to_char = _connections_to_char_map(connections, per_char_pins, spec_ctx)
462
+ return connections, conn_to_char
463
+
464
+
465
+ def _connections_to_char_map(
466
+ connections: list[FixtureConnection],
467
+ per_char_pins: dict[str, list[str]],
468
+ spec_ctx: Any,
469
+ ) -> dict[str, str]:
470
+ """Build the ``conn.name → char_id`` lookup for explicit ``testerkit_connections`` paths.
471
+
472
+ For each connection, pick the first char (in user-declared order)
473
+ whose pin set covers the connection's ``uut_pin``. Connections
474
+ outside any char's pin set (only possible for fixtureless
475
+ ``instrument_channels`` stubs without a spec) get no entry.
476
+ """
477
+ if not per_char_pins:
478
+ return {}
479
+ out: dict[str, str] = {}
480
+ for conn in connections:
481
+ pin_id = conn.uut_pin
482
+ if pin_id is None and spec_ctx is not None and conn.net is not None:
483
+ net_pins = spec_ctx.part.get_pins_by_net(conn.net)
484
+ pin_id = net_pins[0] if net_pins else None
485
+ if pin_id is None:
486
+ continue
487
+ for char_id, pins in per_char_pins.items():
488
+ if pin_id in pins:
489
+ out[conn.name] = char_id
490
+ break
491
+ return out