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,1143 @@
|
|
|
1
|
+
"""Parquet storage backend for test results.
|
|
2
|
+
|
|
3
|
+
Implements an analysis-ready schema with one row per measurement and all
|
|
4
|
+
metadata denormalized for easy querying with DuckDB, Spark, Polars, etc.
|
|
5
|
+
|
|
6
|
+
Directory structure:
|
|
7
|
+
results/runs/{date}/
|
|
8
|
+
├── {timestamp}_{run_id8}_{serial}.parquet # With serial (production)
|
|
9
|
+
├── {timestamp}_{run_id8}.parquet # Without serial (dev/debug)
|
|
10
|
+
└── {timestamp}_{run_id8}_{serial}_ref/ # Reference data (waveforms, images)
|
|
11
|
+
|
|
12
|
+
The run_id (8-char prefix) sits in a fixed position right after the
|
|
13
|
+
timestamp so the optional serial can trail without shifting it; it
|
|
14
|
+
disambiguates two runs of the same serial that start in the same second
|
|
15
|
+
(otherwise the second would silently overwrite the first).
|
|
16
|
+
|
|
17
|
+
All timestamps are UTC for consistent cross-timezone analysis.
|
|
18
|
+
|
|
19
|
+
Schema design:
|
|
20
|
+
- One row per measurement
|
|
21
|
+
- All metadata denormalized onto each row
|
|
22
|
+
- Inputs lane: LIST<STRUCT> of stimulus conditions (role='input')
|
|
23
|
+
- Outputs lane: LIST<STRUCT> of observations (scalars inline, URIs for large data)
|
|
24
|
+
- Config snapshots in Parquet file-level metadata
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import io
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import pickle
|
|
33
|
+
from collections import defaultdict
|
|
34
|
+
from collections.abc import Callable, Generator
|
|
35
|
+
from contextlib import contextmanager
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from pathlib import Path, PurePosixPath
|
|
38
|
+
from typing import Any, BinaryIO
|
|
39
|
+
from uuid import UUID
|
|
40
|
+
|
|
41
|
+
import pyarrow as pa
|
|
42
|
+
import pyarrow.ipc as ipc
|
|
43
|
+
import pyarrow.parquet as pq
|
|
44
|
+
|
|
45
|
+
from testerkit.data._atomic import atomic_write_table
|
|
46
|
+
from testerkit.data.backends._event_accumulator import EventAccumulator
|
|
47
|
+
from testerkit.data.backends._row_helpers import (
|
|
48
|
+
HAS_NUMPY,
|
|
49
|
+
REF_PATH_PREFIX,
|
|
50
|
+
build_measurement_struct,
|
|
51
|
+
build_output_columns,
|
|
52
|
+
build_run_metadata,
|
|
53
|
+
build_run_row,
|
|
54
|
+
build_step_row,
|
|
55
|
+
build_vector_row,
|
|
56
|
+
decode_lane_structs,
|
|
57
|
+
run_context_from_run_started,
|
|
58
|
+
step_entry_dict,
|
|
59
|
+
vector_entry_dict,
|
|
60
|
+
)
|
|
61
|
+
from testerkit.data.data_dir import resolve_data_dir
|
|
62
|
+
from testerkit.data.models import (
|
|
63
|
+
UUT,
|
|
64
|
+
Measurement,
|
|
65
|
+
Outcome,
|
|
66
|
+
RunSummary,
|
|
67
|
+
TestRun,
|
|
68
|
+
TestStep,
|
|
69
|
+
TestVector,
|
|
70
|
+
Waveform,
|
|
71
|
+
_utcnow,
|
|
72
|
+
)
|
|
73
|
+
from testerkit.data.ref import is_ref, parse_channel_uri, ref_scheme
|
|
74
|
+
from testerkit.data.run_store import RunStore
|
|
75
|
+
from testerkit.data.schemas import (
|
|
76
|
+
SCHEMA_VERSION,
|
|
77
|
+
_build_write_schema,
|
|
78
|
+
table_from_rows,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
# Suffix patterns that identify signal-path metadata keys among an
|
|
84
|
+
# inputs/outputs lane name. A key ending in one of these suffixes is
|
|
85
|
+
# metadata, not a stimulus value.
|
|
86
|
+
_STIMULUS_SUFFIXES = ("_instrument", "_resource", "_channel", "_uut_pin", "_fixture_connection")
|
|
87
|
+
|
|
88
|
+
# Outcome priority for deterministic worst-case selection from a set.
|
|
89
|
+
# Lower rank = worse outcome. Ties (same rank) pick the same "worst" value.
|
|
90
|
+
OUTCOME_RANK: dict[str, int] = {"failed": 0, "errored": 1, "skipped": 2, "passed": 3}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_parquet_filename(timestamp: str, run_id: str, serial: str) -> str:
|
|
94
|
+
"""Per-run parquet filename: ``{timestamp}_{run_id8}[_{serial}]``.
|
|
95
|
+
|
|
96
|
+
The 8-char run_id sits in a fixed position right after the timestamp; the
|
|
97
|
+
optional serial trails so its absence never shifts the leading parts. The
|
|
98
|
+
run_id prefix disambiguates two runs of the same serial that start in the
|
|
99
|
+
same second — without it the second would silently overwrite the first.
|
|
100
|
+
"""
|
|
101
|
+
parts = [timestamp]
|
|
102
|
+
if run_id:
|
|
103
|
+
parts.append(run_id[:8])
|
|
104
|
+
if serial:
|
|
105
|
+
parts.append(serial)
|
|
106
|
+
return "_".join(parts) + ".parquet"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_stimulus_key(name: str) -> bool:
|
|
110
|
+
"""True if ``name`` is stimulus signal-path metadata, not a param value."""
|
|
111
|
+
return any(name.endswith(s) for s in _STIMULUS_SUFFIXES)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _params_from_inputs(inputs: dict[str, Any]) -> dict[str, Any]:
|
|
115
|
+
"""Split decoded inputs into vector params (drop stimulus signal-path keys)."""
|
|
116
|
+
return {k: v for k, v in inputs.items() if not _is_stimulus_key(k)}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _build_parquet_metadata(
|
|
120
|
+
*,
|
|
121
|
+
environment_json: str | None = None,
|
|
122
|
+
custom_metadata: dict[str, Any] | None = None,
|
|
123
|
+
) -> dict[bytes, bytes]:
|
|
124
|
+
"""Build Parquet file-level metadata.
|
|
125
|
+
|
|
126
|
+
Shared by ParquetBackend (from TestRun) and
|
|
127
|
+
:func:`materialize_run_to_parquet` (from cached RunStarted event).
|
|
128
|
+
"""
|
|
129
|
+
metadata: dict[bytes, bytes] = {}
|
|
130
|
+
|
|
131
|
+
if environment_json:
|
|
132
|
+
metadata[b"environment_json"] = environment_json.encode("utf-8")
|
|
133
|
+
if custom_metadata:
|
|
134
|
+
metadata[b"custom_metadata"] = json.dumps(custom_metadata, default=str).encode("utf-8")
|
|
135
|
+
|
|
136
|
+
metadata[b"schema_version"] = SCHEMA_VERSION.encode()
|
|
137
|
+
return metadata
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ParquetMeasurementWriter:
|
|
141
|
+
"""Writes measurement RecordBatches as Parquet files."""
|
|
142
|
+
|
|
143
|
+
def write_batch(
|
|
144
|
+
self,
|
|
145
|
+
batch: pa.RecordBatch,
|
|
146
|
+
path: Path,
|
|
147
|
+
*,
|
|
148
|
+
file_metadata: dict[bytes, bytes] | None = None,
|
|
149
|
+
) -> Path:
|
|
150
|
+
"""Write a measurement batch to a Parquet file.
|
|
151
|
+
|
|
152
|
+
Converts the RecordBatch to a Table, attaches file-level metadata,
|
|
153
|
+
and writes atomically (temp file + os.replace).
|
|
154
|
+
"""
|
|
155
|
+
table = pa.Table.from_batches([batch])
|
|
156
|
+
if file_metadata:
|
|
157
|
+
table = table.replace_schema_metadata(file_metadata)
|
|
158
|
+
atomic_write_table(table, path)
|
|
159
|
+
return path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ParquetBackend:
|
|
163
|
+
"""Save test results to Parquet files with analysis-ready schema.
|
|
164
|
+
|
|
165
|
+
Key design principles:
|
|
166
|
+
1. One row per measurement - enables flexible queries
|
|
167
|
+
2. All metadata denormalized - no joins needed
|
|
168
|
+
3. Dynamic schema - inputs/outputs lanes vary per test
|
|
169
|
+
4. Config snapshots in file metadata - full reconstruction possible
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, data_dir: Path | str | None = None):
|
|
173
|
+
# ``data_dir`` is the parent (the project's results dir
|
|
174
|
+
# containing ``runs/``, ``events/``, ``channels/`` subdirs).
|
|
175
|
+
# Aligns with ``RunStore._data_dir`` and
|
|
176
|
+
# ``ProjectConfig.data_dir``. Parquets are written under
|
|
177
|
+
# ``self._runs_dir = data_dir / "runs"``.
|
|
178
|
+
self.data_dir = resolve_data_dir(data_dir)
|
|
179
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
self._runs_dir = self.data_dir / "runs"
|
|
181
|
+
self._runs_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
self._writer = ParquetMeasurementWriter()
|
|
183
|
+
|
|
184
|
+
def save_test_run(
|
|
185
|
+
self,
|
|
186
|
+
test_run: TestRun,
|
|
187
|
+
instrument_records: list[dict[str, Any]] | None = None,
|
|
188
|
+
) -> Path:
|
|
189
|
+
"""Save test run to Parquet with analysis-ready schema.
|
|
190
|
+
|
|
191
|
+
Creates files:
|
|
192
|
+
runs/{date}/{timestamp}_{run_id8}_{serial}.parquet (with serial)
|
|
193
|
+
runs/{date}/{timestamp}_{run_id8}.parquet (without serial)
|
|
194
|
+
|
|
195
|
+
All timestamps are UTC for consistent cross-timezone analysis.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
test_run: Complete TestRun with steps, vectors, and measurements.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Path to the Parquet file.
|
|
202
|
+
"""
|
|
203
|
+
# UTC timestamp for filename (compact ISO 8601 basic format)
|
|
204
|
+
timestamp = test_run.started_at.strftime("%Y%m%dT%H%M%SZ")
|
|
205
|
+
date_str = test_run.started_at.strftime("%Y-%m-%d")
|
|
206
|
+
uut_serial = test_run.uut.serial.strip() if test_run.uut.serial else ""
|
|
207
|
+
|
|
208
|
+
# Create date directory under runs/
|
|
209
|
+
date_dir = self._runs_dir / date_str
|
|
210
|
+
date_dir.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
|
|
212
|
+
# timestamp, then run_id (always present, fixed position), then the
|
|
213
|
+
# optional serial last — so serial's absence never shifts the leading
|
|
214
|
+
# parts and the run_id breaks same-second same-serial collisions.
|
|
215
|
+
filename = _run_parquet_filename(timestamp, str(test_run.id), uut_serial)
|
|
216
|
+
|
|
217
|
+
# Determine parquet path for _ref/ directory creation
|
|
218
|
+
parquet_path = date_dir / filename
|
|
219
|
+
|
|
220
|
+
# Run row first — written at the start of the parquet so row-
|
|
221
|
+
# group min/max stats prune ``WHERE record_type = 'run'`` to the
|
|
222
|
+
# first row group, and so ``parquet-tools head`` surfaces run
|
|
223
|
+
# identity immediately. Always present, including for runs with
|
|
224
|
+
# no steps or measurements (in which case the run row alone is
|
|
225
|
+
# the entire parquet — naturally handles the placeholder case).
|
|
226
|
+
rows: list[dict[str, Any]] = [self._build_run_row(test_run, instrument_records)]
|
|
227
|
+
|
|
228
|
+
# Append a ``record_type='step'`` row for every (step, vector)
|
|
229
|
+
# — containers, action steps, swept variants, and
|
|
230
|
+
# planned-but-unrun vectors all live in the same parquet under
|
|
231
|
+
# the unified RUN_ROW_SCHEMA. Step rows are emitted regardless
|
|
232
|
+
# of whether measurements exist for the same pair; queries
|
|
233
|
+
# discriminate via ``record_type`` rather than via row absence.
|
|
234
|
+
self._append_step_rows(test_run, rows)
|
|
235
|
+
|
|
236
|
+
# Convert to RecordBatch with explicit schema
|
|
237
|
+
schema = _build_write_schema(rows)
|
|
238
|
+
table = table_from_rows(rows, schema)
|
|
239
|
+
batch = table.combine_chunks().to_batches()[0]
|
|
240
|
+
|
|
241
|
+
# Single unified parquet — write triggers daemon notify.
|
|
242
|
+
metadata = self._build_file_metadata(test_run)
|
|
243
|
+
self._writer.write_batch(batch, parquet_path, file_metadata=metadata)
|
|
244
|
+
|
|
245
|
+
return parquet_path
|
|
246
|
+
|
|
247
|
+
def _append_step_rows(self, test_run: TestRun, rows: list[dict[str, Any]]) -> None:
|
|
248
|
+
"""Append step and vector rows for the offline batch path.
|
|
249
|
+
|
|
250
|
+
Used by the batch writer (``save_test_run``). For each step in the
|
|
251
|
+
TestRun:
|
|
252
|
+
- ONE step row (vector_index always NULL — a step row never carries
|
|
253
|
+
its own sweep index, top-level or nested)
|
|
254
|
+
- ONE vector row per vector in ``step.vectors`` (from own VectorStarted
|
|
255
|
+
events — Mode-1 outer, class-outer, or Mode-2 in-body)
|
|
256
|
+
|
|
257
|
+
Step-scope measurements (in ``step.measurements``) ride the step row;
|
|
258
|
+
vector measurements ride their vector row.
|
|
259
|
+
"""
|
|
260
|
+
if not test_run.steps:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
run_context = build_run_metadata(test_run)
|
|
264
|
+
run_outcome = test_run.outcome.value if test_run.outcome else None
|
|
265
|
+
run_ended_at = test_run.ended_at
|
|
266
|
+
ref_saver = self._filestore_ref_saver(test_run)
|
|
267
|
+
|
|
268
|
+
for index, step in enumerate(test_run.steps):
|
|
269
|
+
step_path = step.step_path or step.name
|
|
270
|
+
step_started = step.started_at or test_run.started_at
|
|
271
|
+
step_ended = step.ended_at or test_run.ended_at or test_run.started_at
|
|
272
|
+
instruments = list(step.instrument_records or [])
|
|
273
|
+
|
|
274
|
+
# step.vector_index is always NULL at rest — the invariant the
|
|
275
|
+
# event-driven accumulator honors (a step row never carries its
|
|
276
|
+
# own sweep index, swept or not; vectors are separate rows).
|
|
277
|
+
at_rest_vi: int | None = None
|
|
278
|
+
|
|
279
|
+
# Same canonical struct builder the vector rows use below — an inline
|
|
280
|
+
# copy here had drifted (it isoformat'd the timestamp to a string, but
|
|
281
|
+
# the struct type is a TIMESTAMP), which only surfaced once a caller
|
|
282
|
+
# actually populated step-scope measurements.
|
|
283
|
+
step_meas = [build_measurement_struct(m) for m in (step.measurements or [])]
|
|
284
|
+
|
|
285
|
+
step_entry = step_entry_dict(
|
|
286
|
+
index=index,
|
|
287
|
+
name=step.name,
|
|
288
|
+
node_id=step.node_id,
|
|
289
|
+
file=step.file,
|
|
290
|
+
function=step.function,
|
|
291
|
+
class_name=step.class_name,
|
|
292
|
+
module=step.module,
|
|
293
|
+
step_path=step_path,
|
|
294
|
+
description=step.description,
|
|
295
|
+
markers=step.markers,
|
|
296
|
+
outcome=step.outcome.value if step.outcome else None,
|
|
297
|
+
started_at=step_started,
|
|
298
|
+
ended_at=step_ended,
|
|
299
|
+
vector_index=at_rest_vi,
|
|
300
|
+
inputs=dict(step.inputs),
|
|
301
|
+
outputs=dict(step.outputs),
|
|
302
|
+
measurements=step_meas,
|
|
303
|
+
measurement_count=len(step_meas),
|
|
304
|
+
step_retry=step.retry,
|
|
305
|
+
instrument_records=instruments,
|
|
306
|
+
)
|
|
307
|
+
rows.append(
|
|
308
|
+
build_step_row(
|
|
309
|
+
run_context=run_context,
|
|
310
|
+
entry=step_entry,
|
|
311
|
+
run_outcome=run_outcome,
|
|
312
|
+
run_ended_at=run_ended_at,
|
|
313
|
+
instruments=instruments,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
for vec_offset, vector in enumerate(step.vectors):
|
|
318
|
+
vec_idx = vector.index if vector.index is not None else vec_offset
|
|
319
|
+
vec_entry = vector_entry_dict(
|
|
320
|
+
index=index,
|
|
321
|
+
name=step.name,
|
|
322
|
+
node_id=step.node_id,
|
|
323
|
+
file=step.file,
|
|
324
|
+
function=step.function,
|
|
325
|
+
class_name=step.class_name,
|
|
326
|
+
module=step.module,
|
|
327
|
+
step_path=step_path,
|
|
328
|
+
markers=step.markers,
|
|
329
|
+
step_started_at=step_started,
|
|
330
|
+
step_ended_at=step_ended,
|
|
331
|
+
vector_index=vec_idx,
|
|
332
|
+
retry=vector.retry,
|
|
333
|
+
step_retry=step.retry,
|
|
334
|
+
outcome=vector.outcome.value if vector.outcome else None,
|
|
335
|
+
started_at=vector.started_at or step_started,
|
|
336
|
+
ended_at=vector.ended_at or step_ended,
|
|
337
|
+
inputs=dict(vector.params),
|
|
338
|
+
outputs=build_output_columns(vector, ref_saver=ref_saver),
|
|
339
|
+
input_units=dict(vector.param_units),
|
|
340
|
+
output_units=dict(vector.observation_units),
|
|
341
|
+
output_pins=dict(vector.observation_pins),
|
|
342
|
+
measurements=[build_measurement_struct(m) for m in vector.measurements],
|
|
343
|
+
instrument_records=instruments,
|
|
344
|
+
)
|
|
345
|
+
rows.append(
|
|
346
|
+
build_vector_row(
|
|
347
|
+
run_context=run_context,
|
|
348
|
+
entry=vec_entry,
|
|
349
|
+
run_outcome=run_outcome,
|
|
350
|
+
run_ended_at=run_ended_at,
|
|
351
|
+
instruments=instruments,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@contextmanager
|
|
356
|
+
def _run_store_ctx(self) -> Generator[RunStore, None, None]:
|
|
357
|
+
"""Yield a configured RunStore, closing it on exit.
|
|
358
|
+
|
|
359
|
+
ParquetBackend's read methods (``list_runs``, ``find_run_file``,
|
|
360
|
+
``get_run``, etc.) delegate to RunStore — this helper centralises
|
|
361
|
+
the lifecycle.
|
|
362
|
+
|
|
363
|
+
Both classes use ``data_dir`` to mean *the parent* (the
|
|
364
|
+
results dir containing ``runs/``, ``events/``, ``channels/``).
|
|
365
|
+
Each appends its own subdir internally.
|
|
366
|
+
"""
|
|
367
|
+
store = RunStore(_data_dir=self.data_dir)
|
|
368
|
+
try:
|
|
369
|
+
yield store
|
|
370
|
+
finally:
|
|
371
|
+
store.close()
|
|
372
|
+
|
|
373
|
+
def _filestore_ref_saver(self, test_run: TestRun) -> Callable[[str, str, Any], str]:
|
|
374
|
+
"""Build the FileStore-backed ref_saver for this run's blobs.
|
|
375
|
+
|
|
376
|
+
Item 1d: ref writes route through FileStore (one canonical home
|
|
377
|
+
for all blobs) instead of the per-parquet sibling ``{stem}_ref/``.
|
|
378
|
+
The vector_id-shortened prefix on the FileStore filename preserves
|
|
379
|
+
the audit trail. Shared by the measurement and step-row writers so
|
|
380
|
+
a blob is claim-checked the same way regardless of which lane carries it.
|
|
381
|
+
|
|
382
|
+
Lazy import: ``data.files`` transitively pulls PIL / serializers
|
|
383
|
+
that are only needed when this writer runs. Top-level would add
|
|
384
|
+
load cost to every consumer that imports ParquetBackend.
|
|
385
|
+
"""
|
|
386
|
+
from testerkit.data.files import get_filestore # noqa: PLC0415
|
|
387
|
+
|
|
388
|
+
filestore = get_filestore()
|
|
389
|
+
session_id_str = str(test_run.session_id)
|
|
390
|
+
|
|
391
|
+
def ref_saver(vector_id: str, key: str, value: Any) -> str:
|
|
392
|
+
return filestore.write(
|
|
393
|
+
key,
|
|
394
|
+
value,
|
|
395
|
+
session_id=session_id_str,
|
|
396
|
+
vector_id=vector_id,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
return ref_saver
|
|
400
|
+
|
|
401
|
+
def _build_run_row(
|
|
402
|
+
self,
|
|
403
|
+
test_run: TestRun,
|
|
404
|
+
instrument_records: list[dict[str, Any]] | None = None,
|
|
405
|
+
) -> dict[str, Any]:
|
|
406
|
+
"""Build the single ``record_type='run'`` row for the parquet.
|
|
407
|
+
|
|
408
|
+
Carries run-level identity / UUT / station / fixture / environment
|
|
409
|
+
columns. Step and measurement columns are NULL. Always present (one per
|
|
410
|
+
parquet); for empty runs it is the entire parquet.
|
|
411
|
+
"""
|
|
412
|
+
return build_run_row(
|
|
413
|
+
run_context=build_run_metadata(test_run),
|
|
414
|
+
run_outcome=test_run.outcome.value if test_run.outcome else None,
|
|
415
|
+
run_ended_at=test_run.ended_at,
|
|
416
|
+
instruments=instrument_records or [],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def _build_file_metadata(self, test_run: TestRun) -> dict[bytes, bytes]:
|
|
420
|
+
"""Build Parquet file-level metadata."""
|
|
421
|
+
return _build_parquet_metadata(
|
|
422
|
+
environment_json=test_run.environment_json,
|
|
423
|
+
custom_metadata=dict(test_run.custom_metadata) or None,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def list_runs(self, limit: int = 50) -> list[RunSummary]:
|
|
427
|
+
"""List recent test runs. Delegates to RunStore."""
|
|
428
|
+
with self._run_store_ctx() as store:
|
|
429
|
+
return store.list_runs(limit=limit)
|
|
430
|
+
|
|
431
|
+
def find_run_file(self, run_id: str) -> Path | None:
|
|
432
|
+
"""Find parquet file for a run_id. Delegates to RunStore."""
|
|
433
|
+
with self._run_store_ctx() as store:
|
|
434
|
+
return store.find_run_file(run_id)
|
|
435
|
+
|
|
436
|
+
def get_run(self, run_id: str) -> RunSummary | None:
|
|
437
|
+
"""Get a specific test run by ID. Delegates to RunStore."""
|
|
438
|
+
with self._run_store_ctx() as store:
|
|
439
|
+
return store.get_run(run_id)
|
|
440
|
+
|
|
441
|
+
def get_measurements(self, run_id: str) -> list[dict[str, Any]]:
|
|
442
|
+
"""Get all measurements for a specific test run. Delegates to RunStore."""
|
|
443
|
+
with self._run_store_ctx() as store:
|
|
444
|
+
return store.get_measurements(run_id)
|
|
445
|
+
|
|
446
|
+
def get_measurement(
|
|
447
|
+
self,
|
|
448
|
+
file_path: str,
|
|
449
|
+
measurement_name: str,
|
|
450
|
+
*,
|
|
451
|
+
step_index: int | None = None,
|
|
452
|
+
) -> list[dict[str, Any]]:
|
|
453
|
+
"""Get rows for a specific measurement name with row-group pushdown."""
|
|
454
|
+
with self._run_store_ctx() as store:
|
|
455
|
+
return store.get_measurement(file_path, measurement_name, step_index=step_index)
|
|
456
|
+
|
|
457
|
+
def get_vectors(self, run_id: str) -> list[dict]:
|
|
458
|
+
"""Get unique test vectors for a specific test run."""
|
|
459
|
+
measurements = self.get_measurements(run_id)
|
|
460
|
+
if not measurements:
|
|
461
|
+
return []
|
|
462
|
+
|
|
463
|
+
# Group by (step_name, vector_index, retry)
|
|
464
|
+
vectors_seen: dict[tuple, dict] = {}
|
|
465
|
+
for m in measurements:
|
|
466
|
+
key = (m.get("step_name"), m.get("vector_index"), m.get("vector_retry"))
|
|
467
|
+
if key not in vectors_seen:
|
|
468
|
+
# Extract vector-level info
|
|
469
|
+
vector_info = {
|
|
470
|
+
"test_run_id": m.get("run_id"),
|
|
471
|
+
"step_name": m.get("step_name"),
|
|
472
|
+
"index": m.get("vector_index"),
|
|
473
|
+
"retry": m.get("vector_retry"),
|
|
474
|
+
"outcome": m.get("vector_outcome"),
|
|
475
|
+
"started_at": m.get("vector_started_at"),
|
|
476
|
+
"ended_at": m.get("vector_ended_at"),
|
|
477
|
+
"uut_serial_number": m.get("uut_serial_number"),
|
|
478
|
+
"part_id": m.get("part_id"),
|
|
479
|
+
"station_id": m.get("station_id"),
|
|
480
|
+
}
|
|
481
|
+
vector_info["params"] = _params_from_inputs(m.get("inputs") or {})
|
|
482
|
+
vectors_seen[key] = vector_info
|
|
483
|
+
|
|
484
|
+
return list(vectors_seen.values())
|
|
485
|
+
|
|
486
|
+
def get_run_metadata(self, run_id: str) -> dict[str, str] | None:
|
|
487
|
+
"""Get file-level metadata (config snapshots) for a run."""
|
|
488
|
+
with self._run_store_ctx() as store:
|
|
489
|
+
pq_file = store.find_run_file(run_id)
|
|
490
|
+
if pq_file is None:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
pf = pq.ParquetFile(pq_file)
|
|
495
|
+
raw_metadata = pf.schema_arrow.metadata or {}
|
|
496
|
+
return {k.decode("utf-8"): v.decode("utf-8") for k, v in raw_metadata.items()}
|
|
497
|
+
except (OSError, pa.ArrowInvalid):
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
# =========================================================================
|
|
501
|
+
# TestRun Reconstruction (for post-hoc export)
|
|
502
|
+
# =========================================================================
|
|
503
|
+
|
|
504
|
+
def reconstruct_test_run(self, run_id: str) -> TestRun:
|
|
505
|
+
"""Reconstruct a TestRun model from a stored Parquet file.
|
|
506
|
+
|
|
507
|
+
Groups denormalized rows back into the TestRun → TestStep → TestVector
|
|
508
|
+
→ Measurement hierarchy. Used by exporters for post-hoc conversion.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
run_id: Full or partial run ID.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Reconstructed TestRun model.
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
FileNotFoundError: If no Parquet file found for the run ID.
|
|
518
|
+
"""
|
|
519
|
+
pq_file = self.find_run_file(run_id)
|
|
520
|
+
if pq_file is None:
|
|
521
|
+
raise FileNotFoundError(f"No Parquet file found for run '{run_id}' in {self.data_dir}/")
|
|
522
|
+
return reconstruct_test_run_from_file(pq_file)
|
|
523
|
+
|
|
524
|
+
def save_from_rows(
|
|
525
|
+
self,
|
|
526
|
+
rows: list[dict[str, Any]],
|
|
527
|
+
started_at: datetime,
|
|
528
|
+
uut_serial: str,
|
|
529
|
+
run_id: str,
|
|
530
|
+
file_metadata: dict[bytes, bytes] | None = None,
|
|
531
|
+
) -> Path:
|
|
532
|
+
"""Save pre-built flat row dicts to Parquet.
|
|
533
|
+
|
|
534
|
+
Used by :func:`materialize_run_to_parquet` to write
|
|
535
|
+
accumulated rows without needing a TestRun object.
|
|
536
|
+
"""
|
|
537
|
+
if not rows:
|
|
538
|
+
raise ValueError("save_from_rows() requires at least one row")
|
|
539
|
+
|
|
540
|
+
timestamp = started_at.strftime("%Y%m%dT%H%M%SZ")
|
|
541
|
+
date_str = started_at.strftime("%Y-%m-%d")
|
|
542
|
+
uut_serial = uut_serial.strip() if uut_serial else ""
|
|
543
|
+
|
|
544
|
+
date_dir = self._runs_dir / date_str
|
|
545
|
+
date_dir.mkdir(parents=True, exist_ok=True)
|
|
546
|
+
|
|
547
|
+
filename = _run_parquet_filename(timestamp, run_id, uut_serial)
|
|
548
|
+
|
|
549
|
+
parquet_path = date_dir / filename
|
|
550
|
+
|
|
551
|
+
schema = _build_write_schema(rows)
|
|
552
|
+
table = table_from_rows(rows, schema)
|
|
553
|
+
batch = table.combine_chunks().to_batches()[0]
|
|
554
|
+
|
|
555
|
+
self._writer.write_batch(batch, parquet_path, file_metadata=file_metadata)
|
|
556
|
+
|
|
557
|
+
return parquet_path
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# EventAccumulator is defined in _event_accumulator.py (lightweight,
|
|
561
|
+
# no parquet/subscriber deps) and imported above. The runs daemon
|
|
562
|
+
# holds one EventAccumulator per in-flight run in its AccumulatorPool;
|
|
563
|
+
# materialize_run_to_parquet (below) takes one of those and writes the
|
|
564
|
+
# canonical parquet.
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# Free-standing materializer — accumulator state → parquet file
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
#
|
|
571
|
+
# Called by the runs daemon's event-dispatch loop when ``RunEnded`` lands
|
|
572
|
+
# (real or synthetic-from-orphan-sweep). The daemon's
|
|
573
|
+
# :class:`~testerkit.data.accumulator_pool.AccumulatorPool` already holds
|
|
574
|
+
# the run's :class:`EventAccumulator`; the materializer writes its
|
|
575
|
+
# state to disk via :class:`ParquetBackend`. No subscriber class
|
|
576
|
+
# needed — projection lives on the accumulator, writing lives here.
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _build_unified_rows_from_acc(
|
|
580
|
+
acc: EventAccumulator, run_ended_at: datetime, run_outcome: str
|
|
581
|
+
) -> list[dict[str, Any]]:
|
|
582
|
+
"""Build the per-run unified-rows list from an accumulator's state.
|
|
583
|
+
|
|
584
|
+
Free-standing because the daemon calls it with accumulator instances
|
|
585
|
+
drawn from its pool; not method-on-class because EventAccumulator is
|
|
586
|
+
the pure projection and shouldn't know about output formats.
|
|
587
|
+
|
|
588
|
+
Record order: one ``run`` row, then ``vector`` rows (each carrying its
|
|
589
|
+
nested ``measurements``), then ``step`` rows. No fabricated rows: a
|
|
590
|
+
verify-less vector / assert fail / observation-only execution is
|
|
591
|
+
represented by its vector or step record, never a synthesized
|
|
592
|
+
measurement.
|
|
593
|
+
"""
|
|
594
|
+
rows: list[dict[str, Any]] = []
|
|
595
|
+
run_row = _build_run_row_from_acc(acc, run_ended_at=run_ended_at, run_outcome=run_outcome)
|
|
596
|
+
if run_row is not None:
|
|
597
|
+
rows.append(run_row)
|
|
598
|
+
for entry in acc._build_vector_results_from_events():
|
|
599
|
+
vector_row = _build_vector_row_from_acc(
|
|
600
|
+
acc, entry, run_ended_at=run_ended_at, run_outcome=run_outcome
|
|
601
|
+
)
|
|
602
|
+
if vector_row is not None:
|
|
603
|
+
rows.append(vector_row)
|
|
604
|
+
for entry in acc._build_step_results_from_events():
|
|
605
|
+
step_row = _build_step_row_from_acc(
|
|
606
|
+
acc, entry, run_ended_at=run_ended_at, run_outcome=run_outcome
|
|
607
|
+
)
|
|
608
|
+
if step_row is not None:
|
|
609
|
+
rows.append(step_row)
|
|
610
|
+
return rows
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _build_run_row_from_acc(
|
|
614
|
+
acc: EventAccumulator, *, run_ended_at: datetime, run_outcome: str
|
|
615
|
+
) -> dict[str, Any] | None:
|
|
616
|
+
s = acc._run_started
|
|
617
|
+
if not s:
|
|
618
|
+
return None
|
|
619
|
+
return build_run_row(
|
|
620
|
+
run_context=run_context_from_run_started(s, s, include_env=True),
|
|
621
|
+
run_outcome=run_outcome,
|
|
622
|
+
run_ended_at=run_ended_at,
|
|
623
|
+
instruments=acc._build_instrument_records(),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _build_step_row_from_acc(
|
|
628
|
+
acc: EventAccumulator,
|
|
629
|
+
entry: dict[str, Any],
|
|
630
|
+
*,
|
|
631
|
+
run_ended_at: datetime,
|
|
632
|
+
run_outcome: str,
|
|
633
|
+
) -> dict[str, Any] | None:
|
|
634
|
+
s = acc._run_started
|
|
635
|
+
if not s:
|
|
636
|
+
return None
|
|
637
|
+
return build_step_row(
|
|
638
|
+
run_context=run_context_from_run_started(s, s, include_env=True),
|
|
639
|
+
entry=entry,
|
|
640
|
+
run_outcome=run_outcome,
|
|
641
|
+
run_ended_at=run_ended_at,
|
|
642
|
+
instruments=entry.get("instrument_records") or [],
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _build_vector_row_from_acc(
|
|
647
|
+
acc: EventAccumulator,
|
|
648
|
+
entry: dict[str, Any],
|
|
649
|
+
*,
|
|
650
|
+
run_ended_at: datetime,
|
|
651
|
+
run_outcome: str,
|
|
652
|
+
) -> dict[str, Any] | None:
|
|
653
|
+
s = acc._run_started
|
|
654
|
+
if not s:
|
|
655
|
+
return None
|
|
656
|
+
return build_vector_row(
|
|
657
|
+
run_context=run_context_from_run_started(s, s, include_env=True),
|
|
658
|
+
entry=entry,
|
|
659
|
+
run_outcome=run_outcome,
|
|
660
|
+
run_ended_at=run_ended_at,
|
|
661
|
+
instruments=entry.get("instrument_records") or [],
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _build_file_metadata_from_acc(acc: EventAccumulator) -> dict[bytes, bytes]:
|
|
666
|
+
s = acc._run_started
|
|
667
|
+
if not s:
|
|
668
|
+
return _build_parquet_metadata()
|
|
669
|
+
return _build_parquet_metadata(
|
|
670
|
+
environment_json=s.environment_json,
|
|
671
|
+
custom_metadata=dict(s.custom_metadata) or None,
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def materialize_run_to_parquet(
|
|
676
|
+
acc: EventAccumulator,
|
|
677
|
+
output_dir: Path,
|
|
678
|
+
*,
|
|
679
|
+
outcome: str | None = None,
|
|
680
|
+
run_ended_at: datetime | None = None,
|
|
681
|
+
) -> Path | None:
|
|
682
|
+
"""Materialize an accumulator's state to a per-run parquet file.
|
|
683
|
+
|
|
684
|
+
Returns the parquet path on success, ``None`` when nothing was
|
|
685
|
+
written (no ``RunStarted`` seen, or empty run with no
|
|
686
|
+
measurements / executed steps).
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
acc: The :class:`EventAccumulator` holding the run's events.
|
|
690
|
+
output_dir: Where to write — the runs daemon's data dir.
|
|
691
|
+
outcome: Final run outcome. ``None`` falls back to ``"aborted"``
|
|
692
|
+
(matches the orphan-sweep semantic).
|
|
693
|
+
run_ended_at: Wall-clock time the run ended. Defaults to ``now()``.
|
|
694
|
+
"""
|
|
695
|
+
s = acc._run_started
|
|
696
|
+
if not s:
|
|
697
|
+
return None
|
|
698
|
+
|
|
699
|
+
ended_at = run_ended_at if run_ended_at is not None else _utcnow()
|
|
700
|
+
final_outcome = outcome if outcome is not None else "aborted"
|
|
701
|
+
|
|
702
|
+
rows = _build_unified_rows_from_acc(acc, ended_at, final_outcome)
|
|
703
|
+
if not rows:
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
backend = ParquetBackend(data_dir=output_dir)
|
|
707
|
+
return backend.save_from_rows(
|
|
708
|
+
rows,
|
|
709
|
+
started_at=s.occurred_at,
|
|
710
|
+
uut_serial=s.uut_serial_number,
|
|
711
|
+
run_id=str(s.run_id) if s.run_id else "",
|
|
712
|
+
file_metadata=_build_file_metadata_from_acc(acc),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _resolve_ref_to_path(parquet_path: Path | None, ref: str) -> Path | None:
|
|
717
|
+
"""Resolve a file ref to an on-disk path. Item 1d dual-path.
|
|
718
|
+
|
|
719
|
+
Returns ``None`` for non-file references (channel://, plain
|
|
720
|
+
strings) or unresolvable URIs. Callers decide what to do with
|
|
721
|
+
the un-resolution (typically return the ref as-is).
|
|
722
|
+
|
|
723
|
+
``parquet_path`` is only consulted for legacy
|
|
724
|
+
``file://_ref/{filename}`` URIs (per-parquet sidecar layout).
|
|
725
|
+
New FileStore-shape URIs (``file://{date}/{session_id}/{filename}``)
|
|
726
|
+
resolve without it.
|
|
727
|
+
"""
|
|
728
|
+
raw = ref
|
|
729
|
+
if raw.startswith("file://"):
|
|
730
|
+
raw = raw[len("file://") :]
|
|
731
|
+
|
|
732
|
+
# Legacy: starts with the per-parquet ``_ref/`` prefix.
|
|
733
|
+
if raw.startswith(REF_PATH_PREFIX):
|
|
734
|
+
if parquet_path is None:
|
|
735
|
+
return None
|
|
736
|
+
filename = raw[len(REF_PATH_PREFIX) :]
|
|
737
|
+
return parquet_path.parent / (parquet_path.stem + "_ref") / filename
|
|
738
|
+
|
|
739
|
+
# New (item 1d) FileStore refs (``file://{date}/{session_id}/{filename}``) are
|
|
740
|
+
# NOT path-resolved here — ``load_file`` reads them as bytes through the
|
|
741
|
+
# blob backend (the store owns where they live; no path crosses out).
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def load_file(parquet_path: Path | None, ref: str) -> Any:
|
|
746
|
+
"""Load a file reference (``file://`` URI or legacy ``_ref/`` path).
|
|
747
|
+
|
|
748
|
+
Dual-path post-item-1d:
|
|
749
|
+
|
|
750
|
+
- New: ``file://{date}/{session_id}/{filename}`` — resolves through
|
|
751
|
+
FileStore (canonical home for all artifacts).
|
|
752
|
+
- Legacy: ``file://_ref/{filename}`` or bare ``_ref/{filename}`` —
|
|
753
|
+
resolves to the parquet's sibling ``{stem}_ref/`` directory.
|
|
754
|
+
Stays for the lifetime of pre-1d parquets on disk.
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
parquet_path: Path to the parquet file (used to locate the
|
|
758
|
+
legacy ``_ref/`` sibling dir).
|
|
759
|
+
ref: Reference string — any of the three shapes above.
|
|
760
|
+
|
|
761
|
+
Returns:
|
|
762
|
+
Loaded data in appropriate format:
|
|
763
|
+
- .npz → Waveform model (if has Y, t0, dt) or dict
|
|
764
|
+
- .npy → numpy array
|
|
765
|
+
- .json → dict or Pydantic model
|
|
766
|
+
- .bin → bytes
|
|
767
|
+
- .arrow → Arrow Table
|
|
768
|
+
- .pkl → pickled object
|
|
769
|
+
- Other → raw file path
|
|
770
|
+
"""
|
|
771
|
+
# FileStore artifact ref → read the bytes through the backend (the store
|
|
772
|
+
# owns where they live: local disk or a remote object store). Legacy
|
|
773
|
+
# ``file://_ref/`` / bare ``_ref/`` refs predate FileStore and stay
|
|
774
|
+
# path-based (a per-parquet sibling dir).
|
|
775
|
+
raw = ref[len("file://") :] if ref.startswith("file://") else ref
|
|
776
|
+
if ref.startswith("file://") and not raw.startswith(REF_PATH_PREFIX):
|
|
777
|
+
from testerkit.data.files import get_filestore # noqa: PLC0415
|
|
778
|
+
|
|
779
|
+
payload = get_filestore().read(ref)
|
|
780
|
+
if payload is None:
|
|
781
|
+
return ref # Unresolved / missing artifact
|
|
782
|
+
return _deserialize_ref(
|
|
783
|
+
PurePosixPath(raw).suffix.lower(),
|
|
784
|
+
lambda: io.BytesIO(payload),
|
|
785
|
+
ref,
|
|
786
|
+
fallback=payload,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
path = _resolve_ref_to_path(parquet_path, ref)
|
|
790
|
+
if path is None or not path.exists():
|
|
791
|
+
return ref # Not a file reference, unresolved, or missing
|
|
792
|
+
return _deserialize_ref(path.suffix.lower(), lambda: path.open("rb"), ref, fallback=path)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _deserialize_ref(
|
|
796
|
+
ext: str,
|
|
797
|
+
open_stream: Callable[[], BinaryIO],
|
|
798
|
+
ref: str,
|
|
799
|
+
*,
|
|
800
|
+
fallback: Any,
|
|
801
|
+
) -> Any:
|
|
802
|
+
"""Deserialize a file ref by extension from a binary stream.
|
|
803
|
+
|
|
804
|
+
Source-agnostic: ``open_stream`` yields a fresh readable for either a
|
|
805
|
+
FileStore artifact (``io.BytesIO`` over backend bytes) or a legacy
|
|
806
|
+
on-disk ref (the file handle). ``fallback`` is returned for unknown
|
|
807
|
+
extensions or when numpy is absent (the raw bytes for a FileStore ref,
|
|
808
|
+
the on-disk path for a legacy ref); a decode error returns ``ref``.
|
|
809
|
+
"""
|
|
810
|
+
try:
|
|
811
|
+
if ext == ".npz":
|
|
812
|
+
if not HAS_NUMPY:
|
|
813
|
+
return fallback
|
|
814
|
+
# numpy import is deliberately deferred — top-level numpy imports
|
|
815
|
+
# add ~150ms to every consumer of this module (RunsQuery,
|
|
816
|
+
# MeasurementsQuery, the runs daemon). Readers that never touch
|
|
817
|
+
# .npz blobs should not pay it.
|
|
818
|
+
import numpy as np # noqa: PLC0415
|
|
819
|
+
|
|
820
|
+
with open_stream() as f:
|
|
821
|
+
data = dict(np.load(f, allow_pickle=True))
|
|
822
|
+
if "Y" in data and "t0" in data and "dt" in data:
|
|
823
|
+
attributes = {k: v for k, v in data.items() if k not in ("Y", "t0", "dt")}
|
|
824
|
+
# t0 is stored as ISO-8601 string by the serializer (datetime
|
|
825
|
+
# can't go into np.savez directly). Empty string = "unknown".
|
|
826
|
+
t0_str = str(data["t0"])
|
|
827
|
+
t0_val = datetime.fromisoformat(t0_str) if t0_str else None
|
|
828
|
+
return Waveform(
|
|
829
|
+
Y=data["Y"].tolist(),
|
|
830
|
+
t0=t0_val,
|
|
831
|
+
dt=float(data["dt"]),
|
|
832
|
+
attributes=attributes,
|
|
833
|
+
)
|
|
834
|
+
return data
|
|
835
|
+
elif ext == ".npy":
|
|
836
|
+
if not HAS_NUMPY:
|
|
837
|
+
return fallback
|
|
838
|
+
import numpy as np # noqa: PLC0415
|
|
839
|
+
|
|
840
|
+
with open_stream() as f:
|
|
841
|
+
return np.load(f)
|
|
842
|
+
elif ext == ".json":
|
|
843
|
+
with open_stream() as f:
|
|
844
|
+
return json.loads(f.read())
|
|
845
|
+
elif ext == ".bin":
|
|
846
|
+
with open_stream() as f:
|
|
847
|
+
return f.read()
|
|
848
|
+
elif ext == ".arrow":
|
|
849
|
+
with open_stream() as f:
|
|
850
|
+
return ipc.open_file(f).read_all()
|
|
851
|
+
elif ext == ".pkl":
|
|
852
|
+
with open_stream() as f:
|
|
853
|
+
return pickle.load(f)
|
|
854
|
+
else:
|
|
855
|
+
return fallback
|
|
856
|
+
except (
|
|
857
|
+
OSError,
|
|
858
|
+
ValueError,
|
|
859
|
+
json.JSONDecodeError,
|
|
860
|
+
pickle.UnpicklingError,
|
|
861
|
+
EOFError,
|
|
862
|
+
pa.ArrowInvalid,
|
|
863
|
+
) as exc:
|
|
864
|
+
logger.warning("Failed to load reference %s: %s", ref, exc)
|
|
865
|
+
return ref
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def load_ref(
|
|
869
|
+
value: str,
|
|
870
|
+
*,
|
|
871
|
+
parquet_path: Path | None = None,
|
|
872
|
+
channel_store: Any | None = None,
|
|
873
|
+
) -> Any:
|
|
874
|
+
"""Unified reference loader — dispatches on URI scheme.
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
value: URI string (``channel://...``, ``file://...``, or legacy ``_ref/...``).
|
|
878
|
+
parquet_path: Path to parquet file (needed for ``file://`` refs).
|
|
879
|
+
channel_store: ChannelStore instance (needed for ``channel://`` refs).
|
|
880
|
+
|
|
881
|
+
Returns:
|
|
882
|
+
Loaded data.
|
|
883
|
+
"""
|
|
884
|
+
if not is_ref(value):
|
|
885
|
+
# Legacy _ref/ path without file:// prefix
|
|
886
|
+
if isinstance(value, str) and value.startswith(REF_PATH_PREFIX) and parquet_path:
|
|
887
|
+
return load_file(parquet_path, value)
|
|
888
|
+
return value
|
|
889
|
+
|
|
890
|
+
scheme = ref_scheme(value)
|
|
891
|
+
|
|
892
|
+
if scheme == "file":
|
|
893
|
+
# Item 1d: new FileStore-shape URIs resolve without
|
|
894
|
+
# parquet_path (FileStore walks date dirs itself). Legacy
|
|
895
|
+
# ``file://_ref/...`` URIs still need parquet_path for the
|
|
896
|
+
# per-parquet sibling-dir resolution.
|
|
897
|
+
return load_file(parquet_path, value)
|
|
898
|
+
|
|
899
|
+
if scheme == "channel":
|
|
900
|
+
if channel_store is None:
|
|
901
|
+
return value
|
|
902
|
+
try:
|
|
903
|
+
ticket = parse_channel_uri(value)
|
|
904
|
+
return channel_store.query(
|
|
905
|
+
ticket.channel_id,
|
|
906
|
+
session_id=ticket.session_id or None,
|
|
907
|
+
sample_offset=ticket.sample_offset,
|
|
908
|
+
)
|
|
909
|
+
except Exception: # noqa: BLE001
|
|
910
|
+
# A dangling or unreachable channel degrades to "unavailable" (return
|
|
911
|
+
# the URI) — never crash the caller/UI. Mirrors load_file's missing-
|
|
912
|
+
# artifact behaviour: a clean failure surfaced, not silent corruption.
|
|
913
|
+
logger.debug("Channel ref %r could not be resolved (unavailable)", value)
|
|
914
|
+
return value
|
|
915
|
+
|
|
916
|
+
# Unknown scheme — return as-is
|
|
917
|
+
return value
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def is_file_reference(value: Any) -> bool:
|
|
921
|
+
"""Check if a value is a file reference (``file://`` URI or legacy ``_ref/`` path)."""
|
|
922
|
+
if not isinstance(value, str):
|
|
923
|
+
return False
|
|
924
|
+
if value.startswith(REF_PATH_PREFIX):
|
|
925
|
+
return True
|
|
926
|
+
if is_ref(value) and ref_scheme(value) == "file":
|
|
927
|
+
return True
|
|
928
|
+
return False
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def extract_refs(parquet_path: Path) -> tuple[set[tuple[str, str]], set[str]]:
|
|
932
|
+
"""Channel ``(channel_id, session_id)`` pairs + ``file://`` keys a run references.
|
|
933
|
+
|
|
934
|
+
Scans the run's string columns (outputs lane structs and others) for
|
|
935
|
+
``channel://`` / ``file://`` URIs — the run's full reachable set, both
|
|
936
|
+
schemes. Used by promote (carry a run's data) and retention (reference-aware
|
|
937
|
+
file pruning).
|
|
938
|
+
"""
|
|
939
|
+
channels: set[tuple[str, str]] = set()
|
|
940
|
+
files: set[str] = set()
|
|
941
|
+
try:
|
|
942
|
+
table = pq.read_table(parquet_path)
|
|
943
|
+
except (OSError, pa.ArrowException):
|
|
944
|
+
return channels, files
|
|
945
|
+
for name in table.column_names:
|
|
946
|
+
col = table.column(name)
|
|
947
|
+
if not (pa.types.is_string(col.type) or pa.types.is_large_string(col.type)):
|
|
948
|
+
continue
|
|
949
|
+
for v in col.to_pylist():
|
|
950
|
+
if not is_ref(v):
|
|
951
|
+
continue
|
|
952
|
+
if v.startswith("channel://"):
|
|
953
|
+
# Retention is per-(channel, session): a channel is reachable if
|
|
954
|
+
# any ticket references it, regardless of offset. Drop the offset.
|
|
955
|
+
ticket = parse_channel_uri(v)
|
|
956
|
+
if ticket.channel_id and ticket.session_id:
|
|
957
|
+
channels.add((ticket.channel_id, ticket.session_id))
|
|
958
|
+
else: # file://
|
|
959
|
+
files.add(v[len("file://") :])
|
|
960
|
+
return channels, files
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def reconstruct_test_run_from_file(pq_file: Path) -> TestRun:
|
|
964
|
+
"""Reconstruct a TestRun model from a Parquet file.
|
|
965
|
+
|
|
966
|
+
Groups denormalized rows back into the TestRun → TestStep → TestVector
|
|
967
|
+
→ Measurement hierarchy. Used by exporters for post-hoc conversion
|
|
968
|
+
and by the ``testerkit convert`` CLI.
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
pq_file: Path to the Parquet file.
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
Reconstructed TestRun model.
|
|
975
|
+
|
|
976
|
+
Raises:
|
|
977
|
+
FileNotFoundError: If the file doesn't exist or is empty.
|
|
978
|
+
"""
|
|
979
|
+
if not pq_file.exists():
|
|
980
|
+
raise FileNotFoundError(f"Parquet file not found: {pq_file}")
|
|
981
|
+
|
|
982
|
+
pf = pq.ParquetFile(pq_file)
|
|
983
|
+
table = pf.read()
|
|
984
|
+
rows = table.to_pylist()
|
|
985
|
+
if not rows:
|
|
986
|
+
raise FileNotFoundError(f"Parquet file is empty: {pq_file}")
|
|
987
|
+
|
|
988
|
+
first = rows[0]
|
|
989
|
+
|
|
990
|
+
# Required identity columns — fail fast with a clear error rather
|
|
991
|
+
# than letting bracket-access raise KeyError mid-reconstruction.
|
|
992
|
+
run_id_str = first.get("run_id")
|
|
993
|
+
if not run_id_str:
|
|
994
|
+
raise ValueError(f"Parquet file missing required 'run_id' column: {pq_file}")
|
|
995
|
+
run_started_at = first.get("run_started_at")
|
|
996
|
+
if run_started_at is None:
|
|
997
|
+
raise ValueError(f"Parquet file missing required 'run_started_at' column: {pq_file}")
|
|
998
|
+
|
|
999
|
+
# Read file-level metadata for config snapshots
|
|
1000
|
+
raw_meta = pf.schema_arrow.metadata or {}
|
|
1001
|
+
file_meta = {k.decode(): v.decode() for k, v in raw_meta.items()}
|
|
1002
|
+
|
|
1003
|
+
# Group rows for reconstruction. Vector rows are the carriers: each
|
|
1004
|
+
# holds its conditions (inputs), context (outputs), and nested
|
|
1005
|
+
# ``measurements``. Step rows supply step-level timing, outcome, and the
|
|
1006
|
+
# instrument arrays. Keyed by ``(step_name, step_index)``.
|
|
1007
|
+
step_vector_rows: dict[tuple[str | None, int | None], list[dict]] = defaultdict(list)
|
|
1008
|
+
step_meta_rows: dict[tuple[str | None, int | None], dict] = {}
|
|
1009
|
+
step_timing: dict[tuple[str | None, int | None], dict[str, Any]] = {}
|
|
1010
|
+
|
|
1011
|
+
for row in rows:
|
|
1012
|
+
rt = row.get("record_type")
|
|
1013
|
+
if rt == "run":
|
|
1014
|
+
# Run row carries run-level identity only; doesn't participate
|
|
1015
|
+
# in step/vector grouping. Run-level fields are denormalized
|
|
1016
|
+
# onto every other row, so reconstruction picks them up there.
|
|
1017
|
+
continue
|
|
1018
|
+
sk = (row.get("step_name"), row.get("step_index"))
|
|
1019
|
+
if sk not in step_timing:
|
|
1020
|
+
step_timing[sk] = {
|
|
1021
|
+
"started_at": row.get("step_started_at"),
|
|
1022
|
+
"ended_at": row.get("step_ended_at"),
|
|
1023
|
+
}
|
|
1024
|
+
if rt == "vector":
|
|
1025
|
+
step_vector_rows[sk].append(row)
|
|
1026
|
+
elif rt == "step":
|
|
1027
|
+
step_meta_rows[sk] = row
|
|
1028
|
+
|
|
1029
|
+
# Build steps
|
|
1030
|
+
steps: list[TestStep] = []
|
|
1031
|
+
all_sks = sorted(
|
|
1032
|
+
set(step_vector_rows) | set(step_meta_rows),
|
|
1033
|
+
key=lambda x: (x[1] or 0, x[0] or ""),
|
|
1034
|
+
)
|
|
1035
|
+
for sk in all_sks:
|
|
1036
|
+
vector_rows = step_vector_rows.get(sk, [])
|
|
1037
|
+
step_row = step_meta_rows.get(sk)
|
|
1038
|
+
|
|
1039
|
+
# Instrument records from the step row (fall back to a vector row).
|
|
1040
|
+
instr_source = step_row or (vector_rows[0] if vector_rows else {})
|
|
1041
|
+
step_instr: list[dict[str, Any]] = instr_source.get("instruments") or []
|
|
1042
|
+
|
|
1043
|
+
vectors: list[TestVector] = []
|
|
1044
|
+
for vr in sorted(
|
|
1045
|
+
vector_rows,
|
|
1046
|
+
key=lambda r: (r.get("vector_index") or 0, r.get("vector_retry") or 0),
|
|
1047
|
+
):
|
|
1048
|
+
params: dict[str, Any] = _params_from_inputs(decode_lane_structs(vr.get("inputs")))
|
|
1049
|
+
observations = decode_lane_structs(vr.get("outputs"))
|
|
1050
|
+
measurements: list[Measurement] = []
|
|
1051
|
+
for ms in vr.get("measurements") or []:
|
|
1052
|
+
outcome_str = ms.get("outcome")
|
|
1053
|
+
m = Measurement(
|
|
1054
|
+
name=ms.get("name") or "",
|
|
1055
|
+
value=ms.get("value"),
|
|
1056
|
+
unit=ms.get("unit"),
|
|
1057
|
+
limit_low=ms.get("limit_low"),
|
|
1058
|
+
limit_high=ms.get("limit_high"),
|
|
1059
|
+
limit_nominal=ms.get("limit_nominal"),
|
|
1060
|
+
limit_comparator=ms.get("limit_comparator"),
|
|
1061
|
+
outcome=Outcome(outcome_str) if outcome_str else None,
|
|
1062
|
+
characteristic_id=ms.get("characteristic_id"),
|
|
1063
|
+
spec_ref=ms.get("spec_ref"),
|
|
1064
|
+
uut_pin=ms.get("uut_pin"),
|
|
1065
|
+
instrument_name=ms.get("instrument_name"),
|
|
1066
|
+
instrument_resource=ms.get("instrument_resource"),
|
|
1067
|
+
instrument_channel=ms.get("instrument_channel"),
|
|
1068
|
+
fixture_connection=ms.get("fixture_connection"),
|
|
1069
|
+
)
|
|
1070
|
+
ts = ms.get("timestamp")
|
|
1071
|
+
if ts is not None:
|
|
1072
|
+
m.timestamp = ts
|
|
1073
|
+
measurements.append(m)
|
|
1074
|
+
|
|
1075
|
+
vec_outcome_str = vr.get("vector_outcome")
|
|
1076
|
+
vectors.append(
|
|
1077
|
+
TestVector(
|
|
1078
|
+
index=vr.get("vector_index") or 0,
|
|
1079
|
+
retry=vr.get("vector_retry") or 0,
|
|
1080
|
+
params=params,
|
|
1081
|
+
observations=observations,
|
|
1082
|
+
outcome=Outcome(vec_outcome_str) if vec_outcome_str else Outcome.PASSED,
|
|
1083
|
+
measurements=measurements,
|
|
1084
|
+
started_at=vr.get("vector_started_at") or run_started_at,
|
|
1085
|
+
ended_at=vr.get("vector_ended_at"),
|
|
1086
|
+
)
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
timing = step_timing.get(sk, {})
|
|
1090
|
+
# Prefer the stored step_outcome column (cascade rollup written at
|
|
1091
|
+
# row-build time); fall back to deriving from vector outcomes.
|
|
1092
|
+
step_outcome_str = step_row.get("step_outcome") if step_row else None
|
|
1093
|
+
if step_outcome_str:
|
|
1094
|
+
step_outcome = Outcome(step_outcome_str)
|
|
1095
|
+
elif any(v.outcome == Outcome.FAILED for v in vectors):
|
|
1096
|
+
step_outcome = Outcome.FAILED
|
|
1097
|
+
else:
|
|
1098
|
+
step_outcome = Outcome.PASSED
|
|
1099
|
+
|
|
1100
|
+
steps.append(
|
|
1101
|
+
TestStep(
|
|
1102
|
+
name=sk[0] or "",
|
|
1103
|
+
started_at=timing.get("started_at") or run_started_at,
|
|
1104
|
+
ended_at=timing.get("ended_at"),
|
|
1105
|
+
outcome=step_outcome,
|
|
1106
|
+
vectors=vectors,
|
|
1107
|
+
instrument_records=step_instr if step_instr else None,
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
run_outcome_str = first.get("run_outcome")
|
|
1112
|
+
run_outcome = Outcome(run_outcome_str) if run_outcome_str else Outcome.PASSED
|
|
1113
|
+
|
|
1114
|
+
return TestRun(
|
|
1115
|
+
id=UUID(run_id_str),
|
|
1116
|
+
started_at=run_started_at,
|
|
1117
|
+
ended_at=first.get("run_ended_at"),
|
|
1118
|
+
uut=UUT(
|
|
1119
|
+
serial=first.get("uut_serial_number") or "",
|
|
1120
|
+
part_number=first.get("uut_part_number"),
|
|
1121
|
+
revision=first.get("uut_revision"),
|
|
1122
|
+
lot_number=first.get("uut_lot_number"),
|
|
1123
|
+
),
|
|
1124
|
+
part_id=first.get("part_id"),
|
|
1125
|
+
part_name=first.get("part_name"),
|
|
1126
|
+
part_revision=first.get("part_revision"),
|
|
1127
|
+
station_id=first.get("station_id"),
|
|
1128
|
+
station_name=first.get("station_name"),
|
|
1129
|
+
station_type=first.get("station_type"),
|
|
1130
|
+
station_location=first.get("station_location"),
|
|
1131
|
+
station_hostname=first.get("station_hostname"),
|
|
1132
|
+
fixture_id=first.get("fixture_id"),
|
|
1133
|
+
test_phase=first.get("test_phase"),
|
|
1134
|
+
operator_id=first.get("operator_id"),
|
|
1135
|
+
operator_name=first.get("operator_name"),
|
|
1136
|
+
git_commit=first.get("git_commit"),
|
|
1137
|
+
outcome=run_outcome,
|
|
1138
|
+
steps=steps,
|
|
1139
|
+
environment_json=file_meta.get("environment_json"),
|
|
1140
|
+
custom_metadata=(
|
|
1141
|
+
json.loads(file_meta["custom_metadata"]) if file_meta.get("custom_metadata") else {}
|
|
1142
|
+
),
|
|
1143
|
+
)
|