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