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,1172 @@
|
|
|
1
|
+
"""Shared row-building helpers for the parquet backend.
|
|
2
|
+
|
|
3
|
+
Produces denormalized rows with run-level and measurement-level fields.
|
|
4
|
+
This module extracts the common logic so new columns only need to be
|
|
5
|
+
added in one place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
17
|
+
|
|
18
|
+
from testerkit.data.models import Measurement, TestRun, TestVector
|
|
19
|
+
from testerkit.data.ref import classify_value, is_ref
|
|
20
|
+
from testerkit.environment import EnvironmentSnapshot
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import importlib.util as _ilu
|
|
24
|
+
|
|
25
|
+
HAS_NUMPY = _ilu.find_spec("numpy") is not None
|
|
26
|
+
except (ImportError, ValueError):
|
|
27
|
+
HAS_NUMPY = False
|
|
28
|
+
|
|
29
|
+
# Canonical list of instrument struct field names for the at-rest
|
|
30
|
+
# ``instruments`` LIST<STRUCT> column. Lives here (data layer) so the
|
|
31
|
+
# daemon and parquet backend can import it without pulling in the
|
|
32
|
+
# execution framework.
|
|
33
|
+
INSTRUMENT_STRUCT_FIELDS: tuple[str, ...] = (
|
|
34
|
+
"name",
|
|
35
|
+
"id",
|
|
36
|
+
"driver",
|
|
37
|
+
"resource",
|
|
38
|
+
"protocol",
|
|
39
|
+
"manufacturer",
|
|
40
|
+
"model",
|
|
41
|
+
"serial_number",
|
|
42
|
+
"firmware",
|
|
43
|
+
"cal_due",
|
|
44
|
+
"cal_last",
|
|
45
|
+
"cal_certificate",
|
|
46
|
+
"cal_lab",
|
|
47
|
+
"mocked",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Prefix for path references in output columns (legacy, use file:// URIs)
|
|
51
|
+
REF_PATH_PREFIX = "_ref/"
|
|
52
|
+
|
|
53
|
+
# Vector ID prefix length for filename namespacing in _ref/ directories.
|
|
54
|
+
VECTOR_ID_LENGTH = 8
|
|
55
|
+
|
|
56
|
+
# EAV lane struct — the at-rest nested representation of one input / output
|
|
57
|
+
# entry. ``value_type`` is the value-type discriminator that selects which
|
|
58
|
+
# ``value_*`` lane holds the value. The Arrow struct type in
|
|
59
|
+
# ``schemas._LANE_STRUCT`` must match these names (guarded there).
|
|
60
|
+
LANE_FIELDS: tuple[str, ...] = (
|
|
61
|
+
"name",
|
|
62
|
+
"value_type",
|
|
63
|
+
"value_int",
|
|
64
|
+
"value_double",
|
|
65
|
+
"value_bool",
|
|
66
|
+
"value_text",
|
|
67
|
+
"value_timestamp",
|
|
68
|
+
"value_json",
|
|
69
|
+
"unit",
|
|
70
|
+
"uut_pin",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Fields of the at-rest nested measurement struct carried on the vector row.
|
|
74
|
+
MEASUREMENT_STRUCT_FIELDS: tuple[str, ...] = (
|
|
75
|
+
"name",
|
|
76
|
+
"value",
|
|
77
|
+
"unit",
|
|
78
|
+
"outcome",
|
|
79
|
+
"timestamp",
|
|
80
|
+
"limit_low",
|
|
81
|
+
"limit_high",
|
|
82
|
+
"limit_nominal",
|
|
83
|
+
"limit_comparator",
|
|
84
|
+
"characteristic_id",
|
|
85
|
+
"spec_ref",
|
|
86
|
+
"uut_pin",
|
|
87
|
+
"fixture_connection",
|
|
88
|
+
"instrument_name",
|
|
89
|
+
"instrument_resource",
|
|
90
|
+
"instrument_channel",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Flat measurement scalar columns — present on the flat-fact / overlay / export
|
|
94
|
+
# row, but dropped from the at-rest parquet (at rest a measurement lives in the
|
|
95
|
+
# vector row's nested ``measurements`` list, not as flat columns).
|
|
96
|
+
_MEASUREMENT_SCALAR_FIELDS: frozenset[str] = frozenset(
|
|
97
|
+
{
|
|
98
|
+
"measurement_name",
|
|
99
|
+
"measurement_timestamp",
|
|
100
|
+
"measurement_value",
|
|
101
|
+
"measurement_unit",
|
|
102
|
+
"measurement_outcome",
|
|
103
|
+
"limit_low",
|
|
104
|
+
"limit_high",
|
|
105
|
+
"limit_nominal",
|
|
106
|
+
"limit_comparator",
|
|
107
|
+
"characteristic_id",
|
|
108
|
+
"spec_ref",
|
|
109
|
+
"uut_pin",
|
|
110
|
+
"fixture_connection",
|
|
111
|
+
"instrument_name",
|
|
112
|
+
"instrument_resource",
|
|
113
|
+
"instrument_channel",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _decode_io_map(map_value: dict | list | None) -> dict[str, Any]:
|
|
119
|
+
"""Decode one query-time ``inputs_map``/``outputs_map`` VARCHAR map into a dict.
|
|
120
|
+
|
|
121
|
+
Shared by :meth:`RunStore.get_measurements` and
|
|
122
|
+
:meth:`StepsQuery._rows_from` / ``list_for_session``. Each call site
|
|
123
|
+
receives the map as either a plain ``dict`` (DuckDB Arrow conversion) or
|
|
124
|
+
a list of ``(key, value)`` tuples. ``None`` keys or values are skipped.
|
|
125
|
+
VARCHAR values are coerced: ``"true"``/``"false"`` → ``bool``; numeric
|
|
126
|
+
strings → ``float``.
|
|
127
|
+
|
|
128
|
+
Replaces the old merged, ``in_``/``out_``-prefixed ``dynamic_attrs`` MAP
|
|
129
|
+
decode (projection-normalization, 0.3.1) — callers now get one map per
|
|
130
|
+
role (no prefix, no split) and call this once per map.
|
|
131
|
+
"""
|
|
132
|
+
out: dict[str, Any] = {}
|
|
133
|
+
if not map_value:
|
|
134
|
+
return out
|
|
135
|
+
pairs = map_value.items() if isinstance(map_value, dict) else map_value
|
|
136
|
+
for k, v in pairs:
|
|
137
|
+
if k is None or v is None:
|
|
138
|
+
continue
|
|
139
|
+
out[k] = _coerce_io_map_value(v)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _decode_io_maps(
|
|
144
|
+
inputs_map: dict | list | None,
|
|
145
|
+
outputs_map: dict | list | None,
|
|
146
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
147
|
+
"""Decode a ``(inputs_map, outputs_map)`` pair into ``(inputs, outputs)`` dicts."""
|
|
148
|
+
return _decode_io_map(inputs_map), _decode_io_map(outputs_map)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _coerce_io_map_value(v: Any) -> Any:
|
|
152
|
+
"""Coerce an inputs_map/outputs_map VARCHAR value to its native Python type."""
|
|
153
|
+
if not isinstance(v, str):
|
|
154
|
+
return v
|
|
155
|
+
if v == "true":
|
|
156
|
+
return True
|
|
157
|
+
if v == "false":
|
|
158
|
+
return False
|
|
159
|
+
try:
|
|
160
|
+
return float(v)
|
|
161
|
+
except ValueError:
|
|
162
|
+
return v
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _as_utc(value: datetime) -> datetime:
|
|
166
|
+
"""Normalise a datetime to tz-aware UTC (assume UTC if naive)."""
|
|
167
|
+
if value.tzinfo is None:
|
|
168
|
+
return value.replace(tzinfo=UTC)
|
|
169
|
+
return value.astimezone(UTC)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _lane_entry(
|
|
173
|
+
name: str, value: Any, unit: str | None = None, uut_pin: str | None = None
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Encode one ``(name, value)`` into an EAV lane struct dict.
|
|
176
|
+
|
|
177
|
+
``observation_kind`` routes the value to exactly one ``value_*`` lane;
|
|
178
|
+
the others stay ``None``. ``unit`` carries the optional engineering unit;
|
|
179
|
+
``uut_pin`` carries the pin this observation belongs to (or None = all pins).
|
|
180
|
+
"""
|
|
181
|
+
value_type = observation_kind(value)
|
|
182
|
+
entry: dict[str, Any] = dict.fromkeys(LANE_FIELDS)
|
|
183
|
+
entry["name"] = name
|
|
184
|
+
entry["value_type"] = value_type
|
|
185
|
+
entry["unit"] = unit
|
|
186
|
+
entry["uut_pin"] = uut_pin
|
|
187
|
+
if value_type == "scalar:bool":
|
|
188
|
+
entry["value_bool"] = bool(value)
|
|
189
|
+
elif value_type == "scalar:int":
|
|
190
|
+
entry["value_int"] = int(value)
|
|
191
|
+
elif value_type == "scalar:float":
|
|
192
|
+
entry["value_double"] = float(value)
|
|
193
|
+
elif value_type == "scalar:datetime":
|
|
194
|
+
entry["value_timestamp"] = _as_utc(value)
|
|
195
|
+
elif value_type in ("scalar:str", "uri"):
|
|
196
|
+
entry["value_text"] = str(value)
|
|
197
|
+
elif value_type in ("list", "dict"):
|
|
198
|
+
entry["value_json"] = json.dumps(value, default=str)
|
|
199
|
+
else: # other:*
|
|
200
|
+
entry["value_text"] = repr(value)
|
|
201
|
+
return entry
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def encode_lane_structs(
|
|
205
|
+
values: dict[str, Any],
|
|
206
|
+
units: dict[str, str] | None = None,
|
|
207
|
+
pins: dict[str, str] | None = None,
|
|
208
|
+
) -> list[dict[str, Any]]:
|
|
209
|
+
"""Encode an inputs or outputs dict into a list of lane structs.
|
|
210
|
+
|
|
211
|
+
``units`` maps a slot name to its engineering unit; ``pins`` maps a slot
|
|
212
|
+
name to its ``uut_pin``. Both ride into the lane's named fields.
|
|
213
|
+
"""
|
|
214
|
+
units = units or {}
|
|
215
|
+
pins = pins or {}
|
|
216
|
+
return [
|
|
217
|
+
_lane_entry(name, value, units.get(name), pins.get(name)) for name, value in values.items()
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _lane_value(entry: dict[str, Any]) -> Any:
|
|
222
|
+
"""Inverse of :func:`_lane_entry` — read the value from its lane by ``value_type``."""
|
|
223
|
+
value_type = entry.get("value_type")
|
|
224
|
+
if value_type == "scalar:bool":
|
|
225
|
+
return entry.get("value_bool")
|
|
226
|
+
if value_type == "scalar:int":
|
|
227
|
+
return entry.get("value_int")
|
|
228
|
+
if value_type == "scalar:float":
|
|
229
|
+
return entry.get("value_double")
|
|
230
|
+
if value_type == "scalar:datetime":
|
|
231
|
+
return entry.get("value_timestamp")
|
|
232
|
+
if value_type in ("list", "dict"):
|
|
233
|
+
raw = entry.get("value_json")
|
|
234
|
+
return json.loads(raw) if raw is not None else None
|
|
235
|
+
return entry.get("value_text") # scalar:str, uri, other:*
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def decode_lane_structs(entries: list[dict[str, Any]] | None) -> dict[str, Any]:
|
|
239
|
+
"""Decode a list of lane structs back into a ``{name: value}`` dict."""
|
|
240
|
+
return {entry["name"]: _lane_value(entry) for entry in (entries or [])}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _to_datetime(value: Any) -> datetime | None:
|
|
244
|
+
"""Coerce a value to ``datetime`` if possible, else ``None``.
|
|
245
|
+
|
|
246
|
+
Accepts a ``datetime`` (returned as-is), an ISO-8601 string (parsed
|
|
247
|
+
via ``datetime.fromisoformat``), or anything else (``None``).
|
|
248
|
+
Malformed strings return ``None`` rather than raising.
|
|
249
|
+
"""
|
|
250
|
+
if isinstance(value, datetime):
|
|
251
|
+
return value
|
|
252
|
+
if isinstance(value, str):
|
|
253
|
+
try:
|
|
254
|
+
return datetime.fromisoformat(value)
|
|
255
|
+
except ValueError:
|
|
256
|
+
return None
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class RunParquetRow(BaseModel):
|
|
261
|
+
"""A single denormalized row for streaming and storage.
|
|
262
|
+
|
|
263
|
+
Three row kinds, distinguished by the explicit ``record_type``
|
|
264
|
+
discriminator:
|
|
265
|
+
|
|
266
|
+
* ``record_type = 'run'`` — one row per run; carries run-level
|
|
267
|
+
identity / UUT / station / fixture / environment context. Step
|
|
268
|
+
and vector columns are NULL. Provides an addressable
|
|
269
|
+
"runs table" within the unified per-run parquet (lakehouse
|
|
270
|
+
adopters can ``WHERE record_type = 'run'`` for clean ingest).
|
|
271
|
+
* ``record_type = 'step'`` — one per ``(step_path, step_retry,
|
|
272
|
+
vector_outer_index)`` execution; carries code identity + timing +
|
|
273
|
+
rolled-up outcome. ``vector_index`` is always NULL on this row kind.
|
|
274
|
+
* ``record_type = 'vector'`` — one execution carrier; holds the
|
|
275
|
+
``inputs``/``outputs`` lanes and the nested ``measurements``
|
|
276
|
+
list for that execution.
|
|
277
|
+
|
|
278
|
+
Run rows are keyed by ``run_id``; steps and vectors share grain
|
|
279
|
+
``(run_id, step_path, step_retry, vector_outer_index)`` — a step row's
|
|
280
|
+
own ``vector_index`` is always NULL, while its vector rows carry
|
|
281
|
+
``vector_index`` 0..N within that same grain.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
model_config = ConfigDict(extra="forbid")
|
|
285
|
+
|
|
286
|
+
# Discriminator. ``measurement`` is the flat one-row-per-measurement fact
|
|
287
|
+
# (the overlay / export shape + what the daemon UNNESTs into
|
|
288
|
+
# ``measurements_materialized``); its ``vector_index`` mirrors its carrier
|
|
289
|
+
# (NULL for an ambient/step-scope measurement, 0..N for a vector-scope one).
|
|
290
|
+
record_type: Literal["run", "step", "vector", "measurement"]
|
|
291
|
+
|
|
292
|
+
# Session / run identity
|
|
293
|
+
session_id: str
|
|
294
|
+
run_id: str
|
|
295
|
+
# None only pre-RunStarted-correlation (see the ``run_started is None``
|
|
296
|
+
# placeholder branch below) — a streaming transient, never a persisted
|
|
297
|
+
# "no site" state. Corrected to ``run_started.site_index`` (always 0+)
|
|
298
|
+
# once RunStarted arrives.
|
|
299
|
+
site_index: int | None = None
|
|
300
|
+
site_name: str | None = None
|
|
301
|
+
run_started_at: datetime | None = None
|
|
302
|
+
run_ended_at: datetime | None = None
|
|
303
|
+
|
|
304
|
+
# Operator
|
|
305
|
+
operator_id: str | None = None
|
|
306
|
+
operator_name: str | None = None
|
|
307
|
+
|
|
308
|
+
# UUT
|
|
309
|
+
uut_serial_number: str
|
|
310
|
+
uut_part_number: str | None = None
|
|
311
|
+
uut_revision: str | None = None
|
|
312
|
+
uut_lot_number: str | None = None
|
|
313
|
+
|
|
314
|
+
# Part
|
|
315
|
+
part_id: str | None = None
|
|
316
|
+
part_name: str | None = None
|
|
317
|
+
part_revision: str | None = None
|
|
318
|
+
|
|
319
|
+
# Station — id is None for bringup tier (no station YAML loaded)
|
|
320
|
+
station_id: str | None = None
|
|
321
|
+
station_name: str | None = None
|
|
322
|
+
station_type: str | None = None
|
|
323
|
+
station_location: str | None = None
|
|
324
|
+
station_hostname: str | None = None
|
|
325
|
+
|
|
326
|
+
# Fixture
|
|
327
|
+
fixture_id: str | None = None
|
|
328
|
+
|
|
329
|
+
# Test context
|
|
330
|
+
test_phase: str | None = None
|
|
331
|
+
project_name: str | None = None
|
|
332
|
+
git_commit: str | None = None
|
|
333
|
+
git_branch: str | None = None
|
|
334
|
+
git_remote: str | None = None
|
|
335
|
+
|
|
336
|
+
# Environment traceability
|
|
337
|
+
python_version: str | None = None
|
|
338
|
+
testerkit_version: str | None = None
|
|
339
|
+
env_fingerprint: str | None = None
|
|
340
|
+
|
|
341
|
+
# Step/vector context
|
|
342
|
+
step_name: str
|
|
343
|
+
step_index: int
|
|
344
|
+
step_path: str = ""
|
|
345
|
+
step_started_at: datetime | None = None
|
|
346
|
+
step_ended_at: datetime | None = None
|
|
347
|
+
step_node_id: str | None = None
|
|
348
|
+
step_module: str | None = None
|
|
349
|
+
step_file: str | None = None
|
|
350
|
+
step_class: str | None = None
|
|
351
|
+
step_function: str | None = None
|
|
352
|
+
step_markers: str | None = None
|
|
353
|
+
# 0-based outer (item) retry — pytest-rerunfailures rerun count of this
|
|
354
|
+
# step. On step + scope-vector rows; NULL on run/measurement rows. The
|
|
355
|
+
# inner per-vector retry is ``vector_retry``.
|
|
356
|
+
step_retry: int | None = None
|
|
357
|
+
# NULL on step rows (step.vector_index is always NULL at rest).
|
|
358
|
+
vector_index: int | None = None
|
|
359
|
+
# The vector_index of the outer (class-level) vector a step or vector
|
|
360
|
+
# record belongs to; NULL for top-level steps.
|
|
361
|
+
vector_outer_index: int | None = None
|
|
362
|
+
# 0-based retry counter — 0 for the first execution, N for the Nth retry.
|
|
363
|
+
# Per-measurement (NULL on step / run rows). Companion to ``RetryConfig.max_retries``
|
|
364
|
+
# which bounds the count (max_retries=0 → no retries; max_retries=N → up to N retries).
|
|
365
|
+
vector_retry: int | None = None
|
|
366
|
+
vector_started_at: datetime | None = None
|
|
367
|
+
vector_ended_at: datetime | None = None
|
|
368
|
+
|
|
369
|
+
# Measurement payload — populated only when record_type == 'measurement'.
|
|
370
|
+
measurement_name: str | None = None
|
|
371
|
+
measurement_timestamp: datetime | None = None
|
|
372
|
+
measurement_value: float | None = None
|
|
373
|
+
measurement_unit: str | None = None
|
|
374
|
+
measurement_outcome: str | None = None
|
|
375
|
+
limit_low: float | None = None
|
|
376
|
+
limit_high: float | None = None
|
|
377
|
+
limit_nominal: float | None = None
|
|
378
|
+
limit_comparator: str | None = None
|
|
379
|
+
characteristic_id: str | None = None
|
|
380
|
+
spec_ref: str | None = None
|
|
381
|
+
uut_pin: str | None = None
|
|
382
|
+
fixture_connection: str | None = None
|
|
383
|
+
instrument_name: str | None = None
|
|
384
|
+
instrument_resource: str | None = None
|
|
385
|
+
instrument_channel: str | None = None
|
|
386
|
+
|
|
387
|
+
# Outcomes (cascade rollups: measurement → vector → step → run)
|
|
388
|
+
step_outcome: str | None = None
|
|
389
|
+
vector_outcome: str | None = None
|
|
390
|
+
run_outcome: str | None = None
|
|
391
|
+
|
|
392
|
+
# Dynamic namespaced columns
|
|
393
|
+
inputs: dict[str, Any] = Field(default_factory=dict)
|
|
394
|
+
outputs: dict[str, Any] = Field(default_factory=dict)
|
|
395
|
+
instruments: list[dict[str, Any]] = Field(default_factory=list)
|
|
396
|
+
# Optional per-site engineering unit for inputs / outputs (name → unit),
|
|
397
|
+
# flowed into the lane's ``unit`` field at encode time.
|
|
398
|
+
input_units: dict[str, str] = Field(default_factory=dict)
|
|
399
|
+
output_units: dict[str, str] = Field(default_factory=dict)
|
|
400
|
+
output_pins: dict[str, str] = Field(default_factory=dict)
|
|
401
|
+
# Nested measurements carried on the vector row (LIST<STRUCT>).
|
|
402
|
+
measurements: list[dict[str, Any]] = Field(default_factory=list)
|
|
403
|
+
|
|
404
|
+
@model_validator(mode="after")
|
|
405
|
+
def _check_grain_invariant(self) -> RunParquetRow:
|
|
406
|
+
"""Enforce the grain law: ``vector_index IS NULL`` ⟺ the logical step.
|
|
407
|
+
|
|
408
|
+
A ``vector`` row is a real condition point and ALWAYS carries a concrete
|
|
409
|
+
``vector_index`` (0..N); ``run`` and ``step`` rows are the ambient /
|
|
410
|
+
logical grain and carry NULL. ``measurement`` rows mirror their carrier
|
|
411
|
+
(NULL = ambient, 0..N = vector-scope) and are unconstrained. This makes
|
|
412
|
+
a malformed row — e.g. a ``vector`` with NULL index — impossible to
|
|
413
|
+
construct, rather than a silent leak into a grain-typed query later.
|
|
414
|
+
"""
|
|
415
|
+
if self.record_type == "vector" and self.vector_index is None:
|
|
416
|
+
raise ValueError(
|
|
417
|
+
"record_type='vector' requires a concrete vector_index (0..N); "
|
|
418
|
+
"NULL vector_index marks the logical step grain"
|
|
419
|
+
)
|
|
420
|
+
if self.record_type in ("run", "step") and self.vector_index is not None:
|
|
421
|
+
raise ValueError(
|
|
422
|
+
f"record_type='{self.record_type}' requires vector_index=None "
|
|
423
|
+
f"(NULL ⟺ the logical step / ambient grain); got {self.vector_index}"
|
|
424
|
+
)
|
|
425
|
+
return self
|
|
426
|
+
|
|
427
|
+
def to_flat_dict(self, *, at_rest: bool = False) -> dict[str, Any]:
|
|
428
|
+
"""Flatten to denormalized dict for the Parquet write boundary.
|
|
429
|
+
|
|
430
|
+
``inputs`` / ``outputs`` are encoded as nested EAV lane structs
|
|
431
|
+
(``LIST<STRUCT>``; see :func:`encode_lane_structs`) under the
|
|
432
|
+
``inputs`` / ``outputs`` keys. ``input_units`` / ``output_units``
|
|
433
|
+
ride into each lane's ``unit`` field. ``instruments`` passes
|
|
434
|
+
through as a ``list[dict]`` (nested LIST<STRUCT> at rest).
|
|
435
|
+
|
|
436
|
+
``at_rest=True`` drops the flat measurement scalar columns: at rest a
|
|
437
|
+
measurement lives in the vector row's nested ``measurements`` list, not
|
|
438
|
+
as flat columns. The flat-fact path (overlay / export) keeps them.
|
|
439
|
+
|
|
440
|
+
Datetime values are left as ``datetime`` objects — callers must
|
|
441
|
+
serialise them at the actual write boundary (e.g. ``.isoformat()``).
|
|
442
|
+
"""
|
|
443
|
+
exclude = {
|
|
444
|
+
"inputs",
|
|
445
|
+
"outputs",
|
|
446
|
+
"input_units",
|
|
447
|
+
"output_units",
|
|
448
|
+
"output_pins",
|
|
449
|
+
}
|
|
450
|
+
if at_rest:
|
|
451
|
+
exclude |= _MEASUREMENT_SCALAR_FIELDS
|
|
452
|
+
row = self.model_dump(exclude=exclude)
|
|
453
|
+
row["inputs"] = encode_lane_structs(self.inputs, self.input_units)
|
|
454
|
+
row["outputs"] = encode_lane_structs(self.outputs, self.output_units, self.output_pins)
|
|
455
|
+
return row
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def build_run_metadata(test_run: TestRun) -> dict[str, Any]:
|
|
459
|
+
"""Extract run-level metadata fields from a TestRun.
|
|
460
|
+
|
|
461
|
+
These fields are identical on every row in a run. Returns raw
|
|
462
|
+
Python objects (datetime, str, None) — callers that need JSON
|
|
463
|
+
serialisation should post-process timestamps.
|
|
464
|
+
"""
|
|
465
|
+
return {
|
|
466
|
+
"session_id": str(test_run.session_id),
|
|
467
|
+
"run_id": str(test_run.id),
|
|
468
|
+
"site_index": test_run.site_index,
|
|
469
|
+
"site_name": test_run.site_name,
|
|
470
|
+
"run_started_at": test_run.started_at,
|
|
471
|
+
"run_ended_at": test_run.ended_at,
|
|
472
|
+
# WHO
|
|
473
|
+
"operator_id": test_run.operator_id,
|
|
474
|
+
"operator_name": test_run.operator_name,
|
|
475
|
+
# UUT
|
|
476
|
+
"uut_serial_number": test_run.uut.serial,
|
|
477
|
+
"uut_part_number": test_run.uut.part_number,
|
|
478
|
+
"uut_revision": test_run.uut.revision,
|
|
479
|
+
"uut_lot_number": test_run.uut.lot_number,
|
|
480
|
+
# Part
|
|
481
|
+
"part_id": test_run.part_id,
|
|
482
|
+
"part_name": test_run.part_name,
|
|
483
|
+
"part_revision": test_run.part_revision,
|
|
484
|
+
# Station
|
|
485
|
+
"station_id": test_run.station_id,
|
|
486
|
+
"station_name": test_run.station_name,
|
|
487
|
+
"station_type": test_run.station_type,
|
|
488
|
+
"station_location": test_run.station_location,
|
|
489
|
+
"station_hostname": test_run.station_hostname,
|
|
490
|
+
# Fixture
|
|
491
|
+
"fixture_id": test_run.fixture_id,
|
|
492
|
+
# Test context
|
|
493
|
+
"test_phase": test_run.test_phase,
|
|
494
|
+
"project_name": test_run.project_name,
|
|
495
|
+
"git_commit": test_run.git_commit,
|
|
496
|
+
"git_branch": test_run.git_branch,
|
|
497
|
+
"git_remote": test_run.git_remote,
|
|
498
|
+
# Environment traceability (scalars from environment snapshot)
|
|
499
|
+
**_env_columns(test_run.environment_json),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _env_columns(environment_json: str | None) -> dict[str, str | None]:
|
|
504
|
+
"""Extract queryable environment columns from the JSON snapshot."""
|
|
505
|
+
if not environment_json:
|
|
506
|
+
return {"python_version": None, "testerkit_version": None, "env_fingerprint": None}
|
|
507
|
+
|
|
508
|
+
snapshot = EnvironmentSnapshot.model_validate_json(environment_json)
|
|
509
|
+
return {
|
|
510
|
+
"python_version": snapshot.python_version,
|
|
511
|
+
"testerkit_version": snapshot.testerkit_version,
|
|
512
|
+
"env_fingerprint": snapshot.lockfile_hash,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def run_context_from_run_started(
|
|
517
|
+
run_started: Any | None,
|
|
518
|
+
event: Any,
|
|
519
|
+
*,
|
|
520
|
+
include_env: bool = False,
|
|
521
|
+
) -> dict[str, Any]:
|
|
522
|
+
"""Run-level context kwargs derived from a cached ``RunStarted`` event.
|
|
523
|
+
|
|
524
|
+
Streaming-path counterpart to :func:`build_run_metadata` (which
|
|
525
|
+
operates on a ``TestRun`` model).
|
|
526
|
+
|
|
527
|
+
``event`` supplies the row's ``run_id`` (a measurement event may carry
|
|
528
|
+
it before ``RunStarted`` arrives). When ``run_started`` is ``None``
|
|
529
|
+
(events arrived before RunStarted), falls back to a sparse dict with
|
|
530
|
+
placeholder defaults.
|
|
531
|
+
|
|
532
|
+
Set ``include_env=True`` to include environment columns
|
|
533
|
+
(``python_version``, ``testerkit_version``, ``env_fingerprint``). The
|
|
534
|
+
measurement schema exposes them; the steps schema does not.
|
|
535
|
+
"""
|
|
536
|
+
if run_started is None:
|
|
537
|
+
kwargs: dict[str, Any] = {
|
|
538
|
+
"session_id": str(event.session_id),
|
|
539
|
+
"run_id": str(event.run_id) if event.run_id else "",
|
|
540
|
+
"site_index": None,
|
|
541
|
+
"site_name": None,
|
|
542
|
+
"run_started_at": None,
|
|
543
|
+
"run_ended_at": None,
|
|
544
|
+
"operator_id": None,
|
|
545
|
+
"operator_name": None,
|
|
546
|
+
"uut_serial_number": "unknown",
|
|
547
|
+
"uut_part_number": None,
|
|
548
|
+
"uut_revision": None,
|
|
549
|
+
"uut_lot_number": None,
|
|
550
|
+
"part_id": None,
|
|
551
|
+
"part_name": None,
|
|
552
|
+
"part_revision": None,
|
|
553
|
+
"station_id": "unknown",
|
|
554
|
+
"station_name": None,
|
|
555
|
+
"station_type": None,
|
|
556
|
+
"station_location": None,
|
|
557
|
+
"station_hostname": None,
|
|
558
|
+
"fixture_id": None,
|
|
559
|
+
"test_phase": None,
|
|
560
|
+
"project_name": None,
|
|
561
|
+
"git_commit": None,
|
|
562
|
+
"git_branch": None,
|
|
563
|
+
"git_remote": None,
|
|
564
|
+
}
|
|
565
|
+
else:
|
|
566
|
+
kwargs = {
|
|
567
|
+
"session_id": str(run_started.session_id),
|
|
568
|
+
"run_id": str(event.run_id) if event.run_id else "",
|
|
569
|
+
"site_index": run_started.site_index,
|
|
570
|
+
"site_name": run_started.site_name,
|
|
571
|
+
"run_started_at": run_started.occurred_at,
|
|
572
|
+
"run_ended_at": None,
|
|
573
|
+
"operator_id": run_started.operator_id,
|
|
574
|
+
"operator_name": run_started.operator_name,
|
|
575
|
+
"uut_serial_number": run_started.uut_serial_number,
|
|
576
|
+
"uut_part_number": run_started.uut_part_number,
|
|
577
|
+
"uut_revision": run_started.uut_revision,
|
|
578
|
+
"uut_lot_number": run_started.uut_lot_number,
|
|
579
|
+
"part_id": run_started.part_id,
|
|
580
|
+
"part_name": run_started.part_name,
|
|
581
|
+
"part_revision": run_started.part_revision,
|
|
582
|
+
"station_id": run_started.station_id,
|
|
583
|
+
"station_name": run_started.station_name,
|
|
584
|
+
"station_type": run_started.station_type,
|
|
585
|
+
"station_location": run_started.station_location,
|
|
586
|
+
"station_hostname": run_started.station_hostname,
|
|
587
|
+
"fixture_id": run_started.fixture_id,
|
|
588
|
+
"test_phase": run_started.test_phase,
|
|
589
|
+
"project_name": run_started.project_name,
|
|
590
|
+
"git_commit": run_started.git_commit,
|
|
591
|
+
"git_branch": run_started.git_branch,
|
|
592
|
+
"git_remote": run_started.git_remote,
|
|
593
|
+
}
|
|
594
|
+
if include_env:
|
|
595
|
+
env_json = run_started.environment_json if run_started else None
|
|
596
|
+
kwargs.update(_env_columns(env_json))
|
|
597
|
+
return kwargs
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def build_measurement_fields(measurement: Measurement) -> dict[str, Any]:
|
|
601
|
+
"""Extract measurement-level fields from a Measurement."""
|
|
602
|
+
return {
|
|
603
|
+
"measurement_name": measurement.name,
|
|
604
|
+
"measurement_timestamp": measurement.timestamp,
|
|
605
|
+
"measurement_value": measurement.value,
|
|
606
|
+
"measurement_unit": measurement.unit,
|
|
607
|
+
# measurement.outcome is contractually set by log_measurement
|
|
608
|
+
# (RuntimeError raised in execution/run_scope.py if None reaches here).
|
|
609
|
+
"measurement_outcome": measurement.outcome.value if measurement.outcome else None,
|
|
610
|
+
# Limits
|
|
611
|
+
"limit_low": measurement.limit_low,
|
|
612
|
+
"limit_high": measurement.limit_high,
|
|
613
|
+
"limit_nominal": measurement.limit_nominal,
|
|
614
|
+
"limit_comparator": measurement.limit_comparator,
|
|
615
|
+
# Spec traceability
|
|
616
|
+
"characteristic_id": measurement.characteristic_id,
|
|
617
|
+
"spec_ref": measurement.spec_ref,
|
|
618
|
+
# Signal path
|
|
619
|
+
"uut_pin": measurement.uut_pin,
|
|
620
|
+
"fixture_connection": measurement.fixture_connection,
|
|
621
|
+
"instrument_name": measurement.instrument_name,
|
|
622
|
+
"instrument_resource": measurement.instrument_resource,
|
|
623
|
+
"instrument_channel": measurement.instrument_channel,
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def build_measurement_struct(measurement: Measurement) -> dict[str, Any]:
|
|
628
|
+
"""Encode a Measurement into the at-rest nested struct on the vector row.
|
|
629
|
+
|
|
630
|
+
Field order/names must match ``MEASUREMENT_STRUCT_FIELDS`` (and
|
|
631
|
+
``schemas._MEASUREMENT_STRUCT``, guarded there).
|
|
632
|
+
"""
|
|
633
|
+
return {
|
|
634
|
+
"name": measurement.name,
|
|
635
|
+
"value": measurement.value,
|
|
636
|
+
"unit": measurement.unit,
|
|
637
|
+
"outcome": measurement.outcome.value if measurement.outcome else None,
|
|
638
|
+
"timestamp": measurement.timestamp,
|
|
639
|
+
"limit_low": measurement.limit_low,
|
|
640
|
+
"limit_high": measurement.limit_high,
|
|
641
|
+
"limit_nominal": measurement.limit_nominal,
|
|
642
|
+
"limit_comparator": measurement.limit_comparator,
|
|
643
|
+
"characteristic_id": measurement.characteristic_id,
|
|
644
|
+
"spec_ref": measurement.spec_ref,
|
|
645
|
+
"uut_pin": measurement.uut_pin,
|
|
646
|
+
"fixture_connection": measurement.fixture_connection,
|
|
647
|
+
"instrument_name": measurement.instrument_name,
|
|
648
|
+
"instrument_resource": measurement.instrument_resource,
|
|
649
|
+
"instrument_channel": measurement.instrument_channel,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def build_input_columns(vector: TestVector) -> dict[str, Any]:
|
|
654
|
+
"""Build inputs dict from vector params and stimulus records.
|
|
655
|
+
|
|
656
|
+
Keys are unprefixed (e.g. ``"vin"``); the result is encoded as the
|
|
657
|
+
``inputs`` lane struct list by ``to_flat_dict()``.
|
|
658
|
+
"""
|
|
659
|
+
cols: dict[str, Any] = {}
|
|
660
|
+
|
|
661
|
+
for param, value in vector.params.items():
|
|
662
|
+
if param.startswith("_"):
|
|
663
|
+
continue
|
|
664
|
+
cols[param] = value
|
|
665
|
+
|
|
666
|
+
for stim in vector.stimulus:
|
|
667
|
+
param = stim.param
|
|
668
|
+
if stim.value is not None:
|
|
669
|
+
cols[param] = stim.value
|
|
670
|
+
if stim.instrument:
|
|
671
|
+
cols[f"{param}_instrument"] = stim.instrument
|
|
672
|
+
if stim.resource:
|
|
673
|
+
cols[f"{param}_resource"] = stim.resource
|
|
674
|
+
if stim.channel:
|
|
675
|
+
cols[f"{param}_channel"] = stim.channel
|
|
676
|
+
if stim.uut_pin:
|
|
677
|
+
cols[f"{param}_uut_pin"] = stim.uut_pin
|
|
678
|
+
if stim.fixture_connection:
|
|
679
|
+
cols[f"{param}_fixture_connection"] = stim.fixture_connection
|
|
680
|
+
|
|
681
|
+
return cols
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def observation_kind(value: Any) -> str:
|
|
685
|
+
"""Classify a value into its EAV value-type tag.
|
|
686
|
+
|
|
687
|
+
Returns a short tag (``scalar:int`` / ``scalar:float`` / ``scalar:bool`` /
|
|
688
|
+
``scalar:str`` / ``scalar:datetime`` / ``uri`` / ``list`` / ``dict`` /
|
|
689
|
+
``other:*``) that :func:`_lane_entry` uses to route the value to its
|
|
690
|
+
``value_*`` lane and that is stored as the ``value_type`` field.
|
|
691
|
+
|
|
692
|
+
URIs (``channel://`` and ``file://``) are tagged ``"uri"`` even
|
|
693
|
+
though they're ``str`` — keeps a claim-check ref distinct from a free
|
|
694
|
+
string (both share the ``value_text`` lane, disambiguated by ``value_type``).
|
|
695
|
+
"""
|
|
696
|
+
if is_ref(value):
|
|
697
|
+
return "uri"
|
|
698
|
+
if isinstance(value, bool):
|
|
699
|
+
return "scalar:bool"
|
|
700
|
+
if isinstance(value, int):
|
|
701
|
+
return "scalar:int"
|
|
702
|
+
if isinstance(value, float):
|
|
703
|
+
return "scalar:float"
|
|
704
|
+
if isinstance(value, str):
|
|
705
|
+
return "scalar:str"
|
|
706
|
+
if isinstance(value, datetime):
|
|
707
|
+
return "scalar:datetime"
|
|
708
|
+
if isinstance(value, list):
|
|
709
|
+
return "list"
|
|
710
|
+
if isinstance(value, dict):
|
|
711
|
+
return "dict"
|
|
712
|
+
return f"other:{type(value).__name__}"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def build_output_columns(
|
|
716
|
+
vector: TestVector,
|
|
717
|
+
ref_saver: Callable[[str, str, Any], str] | None = None,
|
|
718
|
+
) -> dict[str, Any]:
|
|
719
|
+
"""Build outputs dict from vector observations.
|
|
720
|
+
|
|
721
|
+
Keys are unprefixed (e.g. ``"temperature"``); the result is encoded as
|
|
722
|
+
the ``outputs`` lane struct list by ``to_flat_dict()``.
|
|
723
|
+
|
|
724
|
+
By the time this runs, observations already contain URIs (from
|
|
725
|
+
Context.observe() writing to ChannelStore) or inline scalars.
|
|
726
|
+
|
|
727
|
+
Routing:
|
|
728
|
+
- **ref URI** (``channel://``, ``file://``) → pass through as-is
|
|
729
|
+
- **scalar** → inline value
|
|
730
|
+
- **blob** → ``ref_saver()`` → ``file://`` URI, or ``repr()``
|
|
731
|
+
"""
|
|
732
|
+
cols: dict[str, Any] = {}
|
|
733
|
+
|
|
734
|
+
for key, value in vector.observations.items():
|
|
735
|
+
if key.startswith("_"):
|
|
736
|
+
continue
|
|
737
|
+
|
|
738
|
+
# Already a URI (from proxy or context.observe writing to stores)
|
|
739
|
+
if is_ref(value):
|
|
740
|
+
cols[key] = value
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
vtype = classify_value(value)
|
|
744
|
+
|
|
745
|
+
if vtype == "scalar":
|
|
746
|
+
cols[key] = value
|
|
747
|
+
elif vtype == "blob" and ref_saver is not None:
|
|
748
|
+
cols[key] = ref_saver(str(vector.id)[:VECTOR_ID_LENGTH], key, value)
|
|
749
|
+
elif isinstance(value, (list, dict)):
|
|
750
|
+
cols[key] = value
|
|
751
|
+
elif ref_saver is not None:
|
|
752
|
+
cols[key] = ref_saver(str(vector.id)[:VECTOR_ID_LENGTH], key, value)
|
|
753
|
+
else:
|
|
754
|
+
cols[key] = repr(value)
|
|
755
|
+
|
|
756
|
+
return cols
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def save_ref_to_dir(ref_dir: Path, vector_id: str, key: str, value: Any) -> str:
|
|
760
|
+
"""Save observation data to a _ref/ directory and return the reference path.
|
|
761
|
+
|
|
762
|
+
Both materialization paths use this helper to save out-of-row
|
|
763
|
+
artifacts alongside the parquet (the ``_ref/`` sibling directory
|
|
764
|
+
convention). The dispatch table itself lives in
|
|
765
|
+
:mod:`testerkit.data.files.serializers` (build item 12); this
|
|
766
|
+
helper just owns the ``_ref/`` filename / URI shape.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
ref_dir: Target directory for reference files.
|
|
770
|
+
vector_id: Vector ID prefix.
|
|
771
|
+
key: Key name for the data.
|
|
772
|
+
value: Data to save. Routed through
|
|
773
|
+
:func:`~testerkit.data.files.serializers.find_serializer`
|
|
774
|
+
— see that module for the convention table and the
|
|
775
|
+
``testerkit_serialize`` / :func:`register_serializer`
|
|
776
|
+
extension points.
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
Reference string like ``"file://_ref/abc123_waveform.npz"``.
|
|
780
|
+
"""
|
|
781
|
+
from testerkit.data.files.serializers import find_serializer
|
|
782
|
+
|
|
783
|
+
serializer = find_serializer(value)
|
|
784
|
+
# Path values: source suffix wins over the serializer's default
|
|
785
|
+
# ``.bin`` so e.g. ``capture.tdms`` stays ``.tdms`` on disk.
|
|
786
|
+
if isinstance(value, Path):
|
|
787
|
+
ext = value.suffix or serializer.extension
|
|
788
|
+
else:
|
|
789
|
+
ext = serializer.extension
|
|
790
|
+
filename = f"{vector_id}_{key}{ext}"
|
|
791
|
+
serializer.write(value, ref_dir / filename)
|
|
792
|
+
return f"file://{REF_PATH_PREFIX}{filename}"
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def build_run_row(
|
|
796
|
+
*,
|
|
797
|
+
run_context: dict[str, Any],
|
|
798
|
+
run_outcome: str | None,
|
|
799
|
+
run_ended_at: datetime | None,
|
|
800
|
+
instruments: list[dict[str, Any]],
|
|
801
|
+
) -> dict[str, Any]:
|
|
802
|
+
"""Build the single ``record_type = 'run'`` row for a parquet.
|
|
803
|
+
|
|
804
|
+
Carries run-level identity / UUT / station / fixture / environment
|
|
805
|
+
columns. Step and measurement columns stay NULL. Provides an
|
|
806
|
+
addressable run-row inside the unified per-run parquet so lakehouse
|
|
807
|
+
adopters can ``WHERE record_type = 'run'`` for clean ingest into a
|
|
808
|
+
``runs`` table without ``SELECT DISTINCT`` over the denormalized
|
|
809
|
+
step + measurement rows.
|
|
810
|
+
|
|
811
|
+
Conventionally written first in the parquet so readers / row-group
|
|
812
|
+
pruners reach the run identity at the start of the file.
|
|
813
|
+
"""
|
|
814
|
+
ctx = dict(run_context)
|
|
815
|
+
ctx["run_ended_at"] = run_ended_at
|
|
816
|
+
row = RunParquetRow(
|
|
817
|
+
record_type="run",
|
|
818
|
+
**ctx,
|
|
819
|
+
# Step / vector context: NULL on run rows. ``step_name`` and
|
|
820
|
+
# ``step_index`` are required-non-None on the model so they
|
|
821
|
+
# carry sentinel "" / 0 values.
|
|
822
|
+
step_name="",
|
|
823
|
+
step_index=0,
|
|
824
|
+
step_path="",
|
|
825
|
+
step_started_at=None,
|
|
826
|
+
step_ended_at=None,
|
|
827
|
+
step_node_id=None,
|
|
828
|
+
step_module=None,
|
|
829
|
+
step_file=None,
|
|
830
|
+
step_class=None,
|
|
831
|
+
step_function=None,
|
|
832
|
+
step_markers=None,
|
|
833
|
+
step_outcome=None,
|
|
834
|
+
vector_index=None,
|
|
835
|
+
vector_retry=None,
|
|
836
|
+
# Measurement payload: NULL on run rows.
|
|
837
|
+
measurement_name=None,
|
|
838
|
+
run_outcome=run_outcome,
|
|
839
|
+
inputs={},
|
|
840
|
+
outputs={},
|
|
841
|
+
instruments=instruments,
|
|
842
|
+
)
|
|
843
|
+
return row.to_flat_dict(at_rest=True)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def build_step_row(
|
|
847
|
+
*,
|
|
848
|
+
run_context: dict[str, Any],
|
|
849
|
+
entry: dict[str, Any],
|
|
850
|
+
run_outcome: str | None,
|
|
851
|
+
run_ended_at: datetime | None,
|
|
852
|
+
instruments: list[dict[str, Any]],
|
|
853
|
+
) -> dict[str, Any]:
|
|
854
|
+
"""Build one ``record_type = 'step'`` row from a step manifest entry.
|
|
855
|
+
|
|
856
|
+
Single source of truth for step-row construction. Used by BOTH the
|
|
857
|
+
streaming subscriber path
|
|
858
|
+
(``materialize_run_to_parquet``) and the batch path
|
|
859
|
+
(``ParquetBackend._append_step_rows``) so the on-disk shape is
|
|
860
|
+
identical regardless of which writer produced it.
|
|
861
|
+
|
|
862
|
+
Every ``(step_path, vector_index)`` pair gets a step row — including
|
|
863
|
+
pairs that also have measurement rows. Step rows are independent of
|
|
864
|
+
measurements; queries count steps via
|
|
865
|
+
``COUNT(*) FILTER (WHERE record_type = 'step')`` instead of
|
|
866
|
+
deduping over measurement rows.
|
|
867
|
+
|
|
868
|
+
``run_context`` is the dict returned by ``build_run_metadata`` or
|
|
869
|
+
``run_context_from_run_started`` (with ``run_ended_at`` overridden
|
|
870
|
+
by the caller for the streaming case). ``entry`` is one step
|
|
871
|
+
manifest entry as produced by ``step_entry_dict`` /
|
|
872
|
+
``_append_not_started``.
|
|
873
|
+
"""
|
|
874
|
+
ctx = dict(run_context)
|
|
875
|
+
ctx["run_ended_at"] = run_ended_at
|
|
876
|
+
raw_vi = entry.get("vector_index")
|
|
877
|
+
raw_idx = entry.get("index")
|
|
878
|
+
row = RunParquetRow(
|
|
879
|
+
record_type="step",
|
|
880
|
+
**ctx,
|
|
881
|
+
step_name=entry.get("name") or "",
|
|
882
|
+
step_index=int(raw_idx) if raw_idx is not None else 0,
|
|
883
|
+
step_path=entry.get("step_path") or "",
|
|
884
|
+
step_started_at=_to_datetime(entry.get("started_at")),
|
|
885
|
+
step_ended_at=_to_datetime(entry.get("ended_at")),
|
|
886
|
+
step_node_id=entry.get("node_id"),
|
|
887
|
+
step_module=entry.get("module"),
|
|
888
|
+
step_file=entry.get("file"),
|
|
889
|
+
step_class=entry.get("class_name"),
|
|
890
|
+
step_function=entry.get("function"),
|
|
891
|
+
step_markers=entry.get("markers"),
|
|
892
|
+
step_outcome=entry.get("outcome"),
|
|
893
|
+
step_retry=entry.get("step_retry") or 0,
|
|
894
|
+
vector_index=raw_vi,
|
|
895
|
+
vector_outer_index=entry.get("vector_outer_index"),
|
|
896
|
+
vector_retry=None,
|
|
897
|
+
measurement_name=None,
|
|
898
|
+
run_outcome=run_outcome,
|
|
899
|
+
inputs=dict(entry.get("inputs") or {}),
|
|
900
|
+
outputs=dict(entry.get("outputs") or {}),
|
|
901
|
+
input_units=dict(entry.get("input_units") or {}),
|
|
902
|
+
output_units=dict(entry.get("output_units") or {}),
|
|
903
|
+
output_pins=dict(entry.get("output_pins") or {}),
|
|
904
|
+
measurements=entry.get("measurements") or [],
|
|
905
|
+
instruments=instruments,
|
|
906
|
+
)
|
|
907
|
+
return row.to_flat_dict(at_rest=True)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def build_vector_row(
|
|
911
|
+
*,
|
|
912
|
+
run_context: dict[str, Any],
|
|
913
|
+
entry: dict[str, Any],
|
|
914
|
+
run_outcome: str | None,
|
|
915
|
+
run_ended_at: datetime | None,
|
|
916
|
+
instruments: list[dict[str, Any]],
|
|
917
|
+
) -> dict[str, Any]:
|
|
918
|
+
"""Build one ``record_type = 'vector'`` row from a vector manifest entry.
|
|
919
|
+
|
|
920
|
+
Vector rows are the leaf carriers for ALL emitted VectorStarted/VectorEnded
|
|
921
|
+
events — Mode-1 (parametrize outer), class-outer sweeps, and Mode-2
|
|
922
|
+
in-body iterations (the ``vectors`` fixture / ``run_vector`` loop). Each
|
|
923
|
+
carries the iteration's own ``(step_path, vector_index, retry)`` identity,
|
|
924
|
+
``inputs`` (this iteration's conditions), ``outputs``, nested
|
|
925
|
+
``measurements``, and ``vector_outcome``. ``measurement_*`` scalar
|
|
926
|
+
columns are NULL (measurements live in the nested list).
|
|
927
|
+
|
|
928
|
+
``entry`` is one vector manifest entry as produced by
|
|
929
|
+
``vector_entry_dict``. Mirrors :func:`build_step_row` so both record
|
|
930
|
+
kinds share the same write boundary.
|
|
931
|
+
"""
|
|
932
|
+
ctx = dict(run_context)
|
|
933
|
+
ctx["run_ended_at"] = run_ended_at
|
|
934
|
+
raw_vi = entry.get("vector_index")
|
|
935
|
+
raw_retry = entry.get("retry")
|
|
936
|
+
raw_idx = entry.get("index")
|
|
937
|
+
row = RunParquetRow(
|
|
938
|
+
record_type="vector",
|
|
939
|
+
**ctx,
|
|
940
|
+
step_name=entry.get("name") or "",
|
|
941
|
+
step_index=int(raw_idx) if raw_idx is not None else 0,
|
|
942
|
+
step_path=entry.get("step_path") or "",
|
|
943
|
+
step_started_at=_to_datetime(entry.get("step_started_at")),
|
|
944
|
+
step_ended_at=_to_datetime(entry.get("step_ended_at")),
|
|
945
|
+
step_node_id=entry.get("node_id"),
|
|
946
|
+
step_module=entry.get("module"),
|
|
947
|
+
step_file=entry.get("file"),
|
|
948
|
+
step_class=entry.get("class_name"),
|
|
949
|
+
step_function=entry.get("function"),
|
|
950
|
+
step_markers=entry.get("markers"),
|
|
951
|
+
step_outcome=None,
|
|
952
|
+
step_retry=entry.get("step_retry") or 0,
|
|
953
|
+
# A vector row MUST have a concrete index (0..N) — vector_entry_dict
|
|
954
|
+
# always supplies one; a None here is a real bug, caught loudly by
|
|
955
|
+
# RunParquetRow._check_grain_invariant rather than masked as vector 0.
|
|
956
|
+
vector_index=raw_vi,
|
|
957
|
+
vector_outer_index=entry.get("vector_outer_index"),
|
|
958
|
+
vector_retry=raw_retry if raw_retry is not None else 0,
|
|
959
|
+
vector_started_at=_to_datetime(entry.get("started_at")),
|
|
960
|
+
vector_ended_at=_to_datetime(entry.get("ended_at")),
|
|
961
|
+
vector_outcome=entry.get("outcome"),
|
|
962
|
+
measurement_name=None,
|
|
963
|
+
run_outcome=run_outcome,
|
|
964
|
+
inputs=dict(entry.get("inputs") or {}),
|
|
965
|
+
outputs=dict(entry.get("outputs") or {}),
|
|
966
|
+
input_units=dict(entry.get("input_units") or {}),
|
|
967
|
+
output_units=dict(entry.get("output_units") or {}),
|
|
968
|
+
output_pins=dict(entry.get("output_pins") or {}),
|
|
969
|
+
measurements=entry.get("measurements") or [],
|
|
970
|
+
instruments=instruments,
|
|
971
|
+
)
|
|
972
|
+
return row.to_flat_dict(at_rest=True)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def vector_entry_dict(
|
|
976
|
+
*,
|
|
977
|
+
index: int,
|
|
978
|
+
name: str,
|
|
979
|
+
node_id: str | None,
|
|
980
|
+
file: str | None,
|
|
981
|
+
function: str | None,
|
|
982
|
+
class_name: str | None,
|
|
983
|
+
module: str | None,
|
|
984
|
+
step_path: str,
|
|
985
|
+
markers: str | None,
|
|
986
|
+
step_started_at: datetime | None,
|
|
987
|
+
step_ended_at: datetime | None,
|
|
988
|
+
vector_index: int,
|
|
989
|
+
vector_outer_index: int | None = None,
|
|
990
|
+
retry: int,
|
|
991
|
+
step_retry: int = 0,
|
|
992
|
+
outcome: str | None,
|
|
993
|
+
started_at: datetime | None,
|
|
994
|
+
ended_at: datetime | None,
|
|
995
|
+
inputs: dict[str, Any] | None = None,
|
|
996
|
+
outputs: dict[str, Any] | None = None,
|
|
997
|
+
input_units: dict[str, str] | None = None,
|
|
998
|
+
output_units: dict[str, str] | None = None,
|
|
999
|
+
output_pins: dict[str, str] | None = None,
|
|
1000
|
+
measurements: list[dict[str, Any]] | None = None,
|
|
1001
|
+
instrument_records: list[dict[str, Any]] | None = None,
|
|
1002
|
+
) -> dict[str, Any]:
|
|
1003
|
+
"""Single source of truth for one in-body vector manifest entry's shape.
|
|
1004
|
+
|
|
1005
|
+
Distinct from :func:`step_entry_dict` — a vector entry keys on
|
|
1006
|
+
``(step_path, vector_outer_index, vector_index, retry)`` and carries
|
|
1007
|
+
vector-grain timing and outcome. ``step_retry`` is the enclosing step's
|
|
1008
|
+
outer (item) attempt. Timestamps are serialised here.
|
|
1009
|
+
"""
|
|
1010
|
+
return {
|
|
1011
|
+
"index": index,
|
|
1012
|
+
"name": name,
|
|
1013
|
+
"node_id": node_id,
|
|
1014
|
+
"file": file,
|
|
1015
|
+
"function": function,
|
|
1016
|
+
"class_name": class_name,
|
|
1017
|
+
"module": module,
|
|
1018
|
+
"step_path": step_path,
|
|
1019
|
+
"markers": markers,
|
|
1020
|
+
"step_started_at": step_started_at.isoformat() if step_started_at else None,
|
|
1021
|
+
"step_ended_at": step_ended_at.isoformat() if step_ended_at else None,
|
|
1022
|
+
"vector_index": vector_index,
|
|
1023
|
+
"vector_outer_index": vector_outer_index,
|
|
1024
|
+
"retry": retry,
|
|
1025
|
+
"step_retry": step_retry,
|
|
1026
|
+
"outcome": outcome,
|
|
1027
|
+
"started_at": started_at.isoformat() if started_at else None,
|
|
1028
|
+
"ended_at": ended_at.isoformat() if ended_at else None,
|
|
1029
|
+
"inputs": inputs or {},
|
|
1030
|
+
"outputs": outputs or {},
|
|
1031
|
+
"input_units": input_units or {},
|
|
1032
|
+
"output_units": output_units or {},
|
|
1033
|
+
"output_pins": output_pins or {},
|
|
1034
|
+
"measurements": measurements or [],
|
|
1035
|
+
"instrument_records": instrument_records or [],
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def step_entry_dict(
|
|
1040
|
+
*,
|
|
1041
|
+
index: int,
|
|
1042
|
+
name: str,
|
|
1043
|
+
node_id: str | None,
|
|
1044
|
+
file: str | None,
|
|
1045
|
+
function: str | None,
|
|
1046
|
+
class_name: str | None,
|
|
1047
|
+
module: str | None,
|
|
1048
|
+
step_path: str,
|
|
1049
|
+
description: str | None,
|
|
1050
|
+
markers: str | None,
|
|
1051
|
+
outcome: str | None,
|
|
1052
|
+
started_at: datetime | None,
|
|
1053
|
+
ended_at: datetime | None,
|
|
1054
|
+
vector_index: int | None = None,
|
|
1055
|
+
vector_outer_index: int | None = None,
|
|
1056
|
+
inputs: dict[str, Any] | None = None,
|
|
1057
|
+
outputs: dict[str, Any] | None = None,
|
|
1058
|
+
input_units: dict[str, str] | None = None,
|
|
1059
|
+
output_units: dict[str, str] | None = None,
|
|
1060
|
+
output_pins: dict[str, str] | None = None,
|
|
1061
|
+
measurements: list[dict[str, Any]] | None = None,
|
|
1062
|
+
measurement_count: int,
|
|
1063
|
+
step_retry: int = 0,
|
|
1064
|
+
instrument_records: list[dict[str, Any]] | None = None,
|
|
1065
|
+
) -> dict[str, Any]:
|
|
1066
|
+
"""Single source of truth for one step manifest entry's shape.
|
|
1067
|
+
|
|
1068
|
+
Shared by the batch path (``ParquetBackend._append_step_rows``) and the
|
|
1069
|
+
streaming path (``EventAccumulator._build_step_results_from_events``);
|
|
1070
|
+
both pre-compute their values and pass them as kwargs. Timestamps are
|
|
1071
|
+
serialised here, ``duration_s`` derived from start/end.
|
|
1072
|
+
|
|
1073
|
+
``vector_index`` is always NULL at rest (step rows never carry their
|
|
1074
|
+
own sweep index). ``vector_outer_index`` identifies which outer (class-
|
|
1075
|
+
level) vector this step ran inside; NULL for top-level steps.
|
|
1076
|
+
"""
|
|
1077
|
+
duration_s: float | None = None
|
|
1078
|
+
if started_at and ended_at:
|
|
1079
|
+
duration_s = (ended_at - started_at).total_seconds()
|
|
1080
|
+
return {
|
|
1081
|
+
"index": index,
|
|
1082
|
+
"name": name,
|
|
1083
|
+
"node_id": node_id,
|
|
1084
|
+
"file": file,
|
|
1085
|
+
"function": function,
|
|
1086
|
+
"class_name": class_name,
|
|
1087
|
+
"module": module,
|
|
1088
|
+
"step_path": step_path,
|
|
1089
|
+
"description": description,
|
|
1090
|
+
"markers": markers,
|
|
1091
|
+
"outcome": outcome,
|
|
1092
|
+
"started_at": started_at.isoformat() if started_at else None,
|
|
1093
|
+
"ended_at": ended_at.isoformat() if ended_at else None,
|
|
1094
|
+
"duration_s": duration_s,
|
|
1095
|
+
"vector_index": vector_index,
|
|
1096
|
+
"vector_outer_index": vector_outer_index,
|
|
1097
|
+
"inputs": inputs or {},
|
|
1098
|
+
"outputs": outputs or {},
|
|
1099
|
+
"input_units": input_units or {},
|
|
1100
|
+
"output_units": output_units or {},
|
|
1101
|
+
"output_pins": output_pins or {},
|
|
1102
|
+
"measurements": measurements or [],
|
|
1103
|
+
"measurement_count": measurement_count,
|
|
1104
|
+
"step_retry": step_retry,
|
|
1105
|
+
"instrument_records": instrument_records or [],
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _append_not_started(
|
|
1110
|
+
manifest: list[dict[str, Any]],
|
|
1111
|
+
collected_items: list[dict[str, str | int | None]],
|
|
1112
|
+
executed_node_ids: set[str],
|
|
1113
|
+
*,
|
|
1114
|
+
executed_vectors: set[tuple[str, int | None]] | None = None,
|
|
1115
|
+
) -> None:
|
|
1116
|
+
"""Append ``planned`` entries for collected items that never executed.
|
|
1117
|
+
|
|
1118
|
+
Used by the streaming path
|
|
1119
|
+
(``EventAccumulator._build_step_results_from_events``); the offline
|
|
1120
|
+
batch path (``ParquetBackend._append_step_rows``) writes exactly the
|
|
1121
|
+
steps present on the ``TestRun`` and has no separate not-started notion.
|
|
1122
|
+
|
|
1123
|
+
Each collected item maps to ONE execution at its own
|
|
1124
|
+
``(step_path, vector_index)``. We add a "not-started" entry iff
|
|
1125
|
+
that specific pair did not appear in the executed events.
|
|
1126
|
+
|
|
1127
|
+
``executed_vectors`` is keyed by ``(step_path, vector_index)`` —
|
|
1128
|
+
matching the accumulator's keying — so the check is unambiguous
|
|
1129
|
+
even when multiple pytest items (parametrize variants) share one
|
|
1130
|
+
logical step.
|
|
1131
|
+
"""
|
|
1132
|
+
next_index = len(manifest)
|
|
1133
|
+
for ci in collected_items:
|
|
1134
|
+
node_id = ci.get("node_id") or ""
|
|
1135
|
+
step_path = ci.get("step_path") or ""
|
|
1136
|
+
raw_vi = ci.get("vector_index") or 0
|
|
1137
|
+
vi = raw_vi if isinstance(raw_vi, int) else 0
|
|
1138
|
+
if executed_vectors is not None and (step_path, vi) in executed_vectors:
|
|
1139
|
+
# This exact (step_path, vector_index) ran; nothing to fill in.
|
|
1140
|
+
continue
|
|
1141
|
+
if node_id in executed_node_ids and executed_vectors is None:
|
|
1142
|
+
# Legacy path (no per-vector info): node_id ran, so nothing to do.
|
|
1143
|
+
continue
|
|
1144
|
+
manifest.append(
|
|
1145
|
+
{
|
|
1146
|
+
"index": next_index,
|
|
1147
|
+
"name": ci.get("function") or node_id,
|
|
1148
|
+
"node_id": node_id,
|
|
1149
|
+
"file": ci.get("file"),
|
|
1150
|
+
"function": ci.get("function"),
|
|
1151
|
+
"class_name": ci.get("class_name"),
|
|
1152
|
+
"module": ci.get("module"),
|
|
1153
|
+
"step_path": step_path,
|
|
1154
|
+
"description": None,
|
|
1155
|
+
# No outcome stamped — the absence IS the receipt
|
|
1156
|
+
# that this step never ran (the row was collected
|
|
1157
|
+
# but its turn never came). Display layer renders
|
|
1158
|
+
# "Never Ran" for outcome=None at finalize time.
|
|
1159
|
+
"outcome": None,
|
|
1160
|
+
"started_at": None,
|
|
1161
|
+
"ended_at": None,
|
|
1162
|
+
# A never-ran marker is the logical step grain (vector_index
|
|
1163
|
+
# NULL), same as a ran step row — ``vi`` above is only the
|
|
1164
|
+
# dedup key against executed_vectors, not this row's grain.
|
|
1165
|
+
"vector_index": None,
|
|
1166
|
+
"inputs": {},
|
|
1167
|
+
"outputs": {},
|
|
1168
|
+
"measurement_count": 0,
|
|
1169
|
+
"step_retry": 0,
|
|
1170
|
+
}
|
|
1171
|
+
)
|
|
1172
|
+
next_index += 1
|