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