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,367 @@
|
|
|
1
|
+
"""RunStore — query API for parquet test run data.
|
|
2
|
+
|
|
3
|
+
Mirrors EventStore's pattern: parquet files are the source of truth,
|
|
4
|
+
a DuckDB daemon indexes them, and RunStore provides a clean query API.
|
|
5
|
+
ParquetBackend keeps the write path; RunStore owns reads + ref management.
|
|
6
|
+
|
|
7
|
+
Construct once and reuse across notebook cells or long-running scripts.
|
|
8
|
+
The analytical daemon is a separate process with its own PID-ref and idle
|
|
9
|
+
timeout, so forgetting to call ``close()`` does not leak it. ``close()``
|
|
10
|
+
and ``with`` are optional.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import warnings
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pyarrow as pa
|
|
21
|
+
import pyarrow.flight as flight
|
|
22
|
+
import pyarrow.parquet as pq
|
|
23
|
+
|
|
24
|
+
from testerkit.data import runs_duckdb_manager
|
|
25
|
+
from testerkit.data._flight_query import FlightQueryClient, call_options
|
|
26
|
+
from testerkit.data._sql_helpers import sql_escape as _sql_escape
|
|
27
|
+
from testerkit.data.backends._row_helpers import _decode_io_maps
|
|
28
|
+
from testerkit.data.data_dir import resolve_data_dir
|
|
29
|
+
from testerkit.data.models import RunSummary
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DataUnavailable(Exception):
|
|
35
|
+
"""Raised when a run's parquet data is not locally accessible.
|
|
36
|
+
|
|
37
|
+
The run's metadata (outcome, started_at, measurement stats) remains
|
|
38
|
+
queryable from the index. Raw scalars and ref data require the file.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, file_path: str, *, status: str = "missing") -> None:
|
|
42
|
+
self.file_path = file_path
|
|
43
|
+
self.status = status
|
|
44
|
+
super().__init__(f"Run data {status}: {file_path}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunStore:
|
|
48
|
+
"""Query API for parquet test run data.
|
|
49
|
+
|
|
50
|
+
Uses a ref-counted in-memory DuckDB daemon for indexed queries — same
|
|
51
|
+
lifecycle pattern as EventStore. Queries go via Arrow Flight (gRPC).
|
|
52
|
+
|
|
53
|
+
Construct once and reuse across calls. ``close()`` / ``with`` are optional
|
|
54
|
+
— the daemon is a separate process that self-manages via PID-ref and idle
|
|
55
|
+
timeout, so forgetting to close does not leak it.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, _data_dir: Path | str | None = None) -> None:
|
|
59
|
+
data_dir = resolve_data_dir(_data_dir)
|
|
60
|
+
|
|
61
|
+
self._runs_dir = data_dir / "runs"
|
|
62
|
+
self._runs_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
# Start daemon and get gRPC location
|
|
65
|
+
self._location = runs_duckdb_manager.acquire(self._runs_dir)
|
|
66
|
+
|
|
67
|
+
# Flight query client (shared retry logic with EventStore)
|
|
68
|
+
self._flight = FlightQueryClient(
|
|
69
|
+
self._location,
|
|
70
|
+
"runs",
|
|
71
|
+
reacquire=lambda: runs_duckdb_manager.acquire(self._runs_dir),
|
|
72
|
+
label="RunStore",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _flight_query(self, sql: str) -> list[dict[str, Any]]:
|
|
76
|
+
"""Execute a SQL query via Flight and return list of dicts."""
|
|
77
|
+
return self._flight.query(sql)
|
|
78
|
+
|
|
79
|
+
# --- Query API ---
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _id_prefix(run_id: str, length: int = 8) -> str:
|
|
83
|
+
"""Return an 8-char (or shorter) prefix for LIKE matching against the index."""
|
|
84
|
+
return run_id[:length] if len(run_id) >= length else run_id
|
|
85
|
+
|
|
86
|
+
def list_runs(self, limit: int = 50) -> list[RunSummary]:
|
|
87
|
+
"""List recent test runs, most recent first."""
|
|
88
|
+
rows = self._flight_query(f"""
|
|
89
|
+
SELECT file_path, run_id, session_id, uut_serial_number, station_id,
|
|
90
|
+
outcome, started_at, num_measurements,
|
|
91
|
+
test_phase, part_id, operator_id,
|
|
92
|
+
project_name
|
|
93
|
+
FROM runs
|
|
94
|
+
ORDER BY started_at DESC
|
|
95
|
+
LIMIT {limit}
|
|
96
|
+
""")
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
RunSummary(
|
|
100
|
+
test_run_id=r["run_id"],
|
|
101
|
+
session_id=r.get("session_id"),
|
|
102
|
+
started_at=r.get("started_at"),
|
|
103
|
+
uut_serial_number=r.get("uut_serial_number"),
|
|
104
|
+
station_id=r.get("station_id"),
|
|
105
|
+
outcome=r.get("outcome"),
|
|
106
|
+
total_measurements=r.get("num_measurements", 0),
|
|
107
|
+
test_phase=r.get("test_phase"),
|
|
108
|
+
part_id=r.get("part_id"),
|
|
109
|
+
operator=r.get("operator_id"),
|
|
110
|
+
project_name=r.get("project_name"),
|
|
111
|
+
file_path=r.get("file_path"),
|
|
112
|
+
)
|
|
113
|
+
for r in rows
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
def find_run_file(self, run_id: str) -> Path | None:
|
|
117
|
+
"""Find the measurements parquet for a run_id (prefix match).
|
|
118
|
+
|
|
119
|
+
Returns ``None`` when the runs row has no measurements
|
|
120
|
+
parquet (measurement-less run — only setup / action /
|
|
121
|
+
skipped steps) or when the file is missing from disk.
|
|
122
|
+
"""
|
|
123
|
+
prefix = self._id_prefix(run_id)
|
|
124
|
+
rows = self._flight_query(f"""
|
|
125
|
+
SELECT file_path FROM runs
|
|
126
|
+
WHERE run_id LIKE '{_sql_escape(prefix)}%'
|
|
127
|
+
LIMIT 1
|
|
128
|
+
""")
|
|
129
|
+
|
|
130
|
+
if not rows or rows[0].get("file_path") is None:
|
|
131
|
+
return None
|
|
132
|
+
p = Path(rows[0]["file_path"])
|
|
133
|
+
return p if p.exists() else None
|
|
134
|
+
|
|
135
|
+
def get_run(self, run_id: str) -> RunSummary | None:
|
|
136
|
+
"""Get a run summary by ID (prefix match), runs-table-first.
|
|
137
|
+
|
|
138
|
+
The runs TABLE (or, for a not-yet-materialized run, the
|
|
139
|
+
in-flight overlay row — see ``runs`` view) carries every
|
|
140
|
+
run-level field needed to render the summary card, including
|
|
141
|
+
``site_index`` / ``site_name`` (denormalized onto every row at
|
|
142
|
+
ingest, per ``_RUNS_PERSISTED_COLUMNS``). Only ``station_type``
|
|
143
|
+
isn't denormalized onto the runs table — we crack open the
|
|
144
|
+
measurements parquet just for that one, skipped silently when
|
|
145
|
+
``file_path`` is NULL (a not-yet-materialized / in-flight run).
|
|
146
|
+
"""
|
|
147
|
+
prefix = self._id_prefix(run_id)
|
|
148
|
+
rows = self._flight_query(f"""
|
|
149
|
+
SELECT * FROM runs
|
|
150
|
+
WHERE run_id LIKE '{_sql_escape(prefix)}%'
|
|
151
|
+
LIMIT 1
|
|
152
|
+
""")
|
|
153
|
+
if not rows:
|
|
154
|
+
return None
|
|
155
|
+
r = rows[0]
|
|
156
|
+
run_id_val = r.get("run_id")
|
|
157
|
+
if not run_id_val:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
summary = RunSummary(
|
|
161
|
+
test_run_id=run_id_val,
|
|
162
|
+
session_id=r.get("session_id"),
|
|
163
|
+
site_index=r.get("site_index"),
|
|
164
|
+
site_name=r.get("site_name"),
|
|
165
|
+
started_at=r.get("started_at"),
|
|
166
|
+
ended_at=r.get("ended_at"),
|
|
167
|
+
uut_serial_number=r.get("uut_serial_number"),
|
|
168
|
+
uut_part_number=r.get("uut_part_number"),
|
|
169
|
+
part_id=r.get("part_id"),
|
|
170
|
+
station_id=r.get("station_id"),
|
|
171
|
+
station_name=r.get("station_name"),
|
|
172
|
+
station_hostname=r.get("station_hostname"),
|
|
173
|
+
fixture_id=r.get("fixture_id"),
|
|
174
|
+
test_phase=r.get("test_phase"),
|
|
175
|
+
project_name=r.get("project_name"),
|
|
176
|
+
operator=r.get("operator_id"),
|
|
177
|
+
outcome=r.get("outcome"),
|
|
178
|
+
total_measurements=r.get("num_measurements", 0) or 0,
|
|
179
|
+
file_path=r.get("file_path"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# station_type isn't in the runs table — sourced from the
|
|
183
|
+
# measurements parquet when available. Skipped silently for
|
|
184
|
+
# not-yet-materialized (in-flight) runs.
|
|
185
|
+
pq_path_str = r.get("file_path")
|
|
186
|
+
if pq_path_str:
|
|
187
|
+
pq_path = Path(pq_path_str)
|
|
188
|
+
if pq_path.exists():
|
|
189
|
+
try:
|
|
190
|
+
table = pq.read_table(pq_path)
|
|
191
|
+
if table.num_rows > 0:
|
|
192
|
+
first = table.to_pylist()[0]
|
|
193
|
+
summary.station_type = first.get("station_type")
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
logger.debug("Failed to enrich run %s from parquet: %s", run_id, exc)
|
|
196
|
+
return summary
|
|
197
|
+
|
|
198
|
+
def get_measurements(self, run_id: str) -> list[dict[str, Any]]:
|
|
199
|
+
"""Get all measurements for a specific test run.
|
|
200
|
+
|
|
201
|
+
Goes through the daemon's ``measurements`` view (a parquet glob
|
|
202
|
+
with ``union_by_name=true``) instead of reading the parquet file
|
|
203
|
+
directly — DuckDB does the multi-file scan in C++ with predicate
|
|
204
|
+
pushdown on ``run_id`` and avoids client-side parquet decoding.
|
|
205
|
+
|
|
206
|
+
Per-row ``inputs``/``outputs`` are derived at query time from the
|
|
207
|
+
``inputs``/``outputs`` tables (projection-normalization, 0.3.1) —
|
|
208
|
+
LEFT JOINed and aggregated into a VARCHAR map per (role, vector
|
|
209
|
+
coordinate), same key the daemon's EAV joins have always used
|
|
210
|
+
(``run_id, step_index, step_path, vector_index, vector_outer_index,
|
|
211
|
+
vector_retry`` — no ``step_retry``: this is the measurement's own
|
|
212
|
+
FK, matching historical behavior exactly). ``None`` when a run has no
|
|
213
|
+
materialized rows for that (role, coordinate) — decodes to ``{}``.
|
|
214
|
+
"""
|
|
215
|
+
prefix = self._id_prefix(run_id)
|
|
216
|
+
io_join = """
|
|
217
|
+
LEFT JOIN (
|
|
218
|
+
SELECT run_id, step_index, step_path, step_retry, vector_index,
|
|
219
|
+
vector_outer_index, vector_retry,
|
|
220
|
+
MAP(list(name), list(
|
|
221
|
+
CASE value_type
|
|
222
|
+
WHEN 'scalar:bool'
|
|
223
|
+
THEN CASE WHEN value_bool THEN 'true' ELSE 'false' END
|
|
224
|
+
WHEN 'scalar:int' THEN CAST(value_int AS VARCHAR)
|
|
225
|
+
WHEN 'scalar:float' THEN CAST(value_double AS VARCHAR)
|
|
226
|
+
WHEN 'scalar:datetime' THEN CAST(value_timestamp AS VARCHAR)
|
|
227
|
+
WHEN 'list' THEN value_json
|
|
228
|
+
WHEN 'dict' THEN value_json
|
|
229
|
+
ELSE value_text
|
|
230
|
+
END
|
|
231
|
+
)) AS {map_col}
|
|
232
|
+
FROM {table}
|
|
233
|
+
GROUP BY run_id, step_index, step_path, step_retry, vector_index,
|
|
234
|
+
vector_outer_index, vector_retry
|
|
235
|
+
) AS {alias} ON {alias}.run_id = m.run_id
|
|
236
|
+
AND {alias}.step_index = m.step_index
|
|
237
|
+
AND {alias}.step_path IS NOT DISTINCT FROM m.step_path
|
|
238
|
+
AND {alias}.step_retry IS NOT DISTINCT FROM m.step_retry
|
|
239
|
+
AND {alias}.vector_index IS NOT DISTINCT FROM m.vector_index
|
|
240
|
+
AND {alias}.vector_outer_index IS NOT DISTINCT FROM m.vector_outer_index
|
|
241
|
+
AND {alias}.vector_retry IS NOT DISTINCT FROM m.vector_retry
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
rows = self._flight_query(f"""
|
|
245
|
+
SELECT m.*, inputs_agg.inputs_map, outputs_agg.outputs_map
|
|
246
|
+
FROM measurements m
|
|
247
|
+
{io_join.format(alias="inputs_agg", table="inputs", map_col="inputs_map")}
|
|
248
|
+
{io_join.format(alias="outputs_agg", table="outputs", map_col="outputs_map")}
|
|
249
|
+
WHERE m.run_id LIKE '{_sql_escape(prefix)}%'
|
|
250
|
+
ORDER BY m.step_index, m.measurement_name
|
|
251
|
+
""")
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.debug("Failed to query measurements for %s: %s", run_id, exc)
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
for row in rows:
|
|
257
|
+
# pop removes the raw maps from the dict before returning it
|
|
258
|
+
row["inputs"], row["outputs"] = _decode_io_maps(
|
|
259
|
+
row.pop("inputs_map", None), row.pop("outputs_map", None)
|
|
260
|
+
)
|
|
261
|
+
return rows
|
|
262
|
+
|
|
263
|
+
# --- Ref management (for materialize) ---
|
|
264
|
+
|
|
265
|
+
def get_steps(self, run_id: str) -> list[dict[str, Any]]:
|
|
266
|
+
"""Get steps for a run from the daemon's ``steps`` view."""
|
|
267
|
+
prefix = self._id_prefix(run_id)
|
|
268
|
+
return self._flight_query(f"""
|
|
269
|
+
SELECT step_index, step_name, step_path, outcome,
|
|
270
|
+
CAST(started_at AT TIME ZONE 'UTC' AS VARCHAR) AS started_at,
|
|
271
|
+
CAST(ended_at AT TIME ZONE 'UTC' AS VARCHAR) AS ended_at,
|
|
272
|
+
duration_s, measurement_count, step_retry, markers
|
|
273
|
+
FROM steps
|
|
274
|
+
WHERE run_id LIKE '{_sql_escape(prefix)}%'
|
|
275
|
+
ORDER BY step_index, step_retry, vector_index
|
|
276
|
+
""")
|
|
277
|
+
|
|
278
|
+
def find_channel_refs(self, session_shorts: set[str]) -> list[dict[str, Any]]:
|
|
279
|
+
"""Query measurement_refs WHERE session_short IN (...)."""
|
|
280
|
+
if not session_shorts:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
quoted = ", ".join(f"'{_sql_escape(s)}'" for s in session_shorts)
|
|
284
|
+
return self._flight_query(f"""
|
|
285
|
+
SELECT file_path, step_index, measurement_name, col_name,
|
|
286
|
+
role, row_idx, uri, channel_id, session_short, session_id
|
|
287
|
+
FROM measurement_refs
|
|
288
|
+
WHERE session_short IN ({quoted})
|
|
289
|
+
""")
|
|
290
|
+
|
|
291
|
+
def get_measurement(
|
|
292
|
+
self,
|
|
293
|
+
file_path: str | Path,
|
|
294
|
+
measurement_name: str,
|
|
295
|
+
*,
|
|
296
|
+
step_index: int | None = None,
|
|
297
|
+
) -> list[dict[str, Any]]:
|
|
298
|
+
"""Get rows for a specific measurement name, with predicate pushdown.
|
|
299
|
+
|
|
300
|
+
Uses pyarrow row-group filters so only matching row groups are read.
|
|
301
|
+
Raises DataUnavailable if the file is missing or marked non-ok in the index.
|
|
302
|
+
Returns [] if the parquet file exists but lacks a measurement_name column
|
|
303
|
+
(e.g. a file written by an older schema version).
|
|
304
|
+
"""
|
|
305
|
+
pq_file = Path(file_path)
|
|
306
|
+
if not pq_file.exists():
|
|
307
|
+
status_rows = self._flight_query(
|
|
308
|
+
f"SELECT status FROM _ingested WHERE path = '{_sql_escape(str(pq_file))}'"
|
|
309
|
+
)
|
|
310
|
+
status = status_rows[0]["status"] if status_rows else "missing"
|
|
311
|
+
raise DataUnavailable(str(pq_file), status=status)
|
|
312
|
+
|
|
313
|
+
filters: list[tuple] = [("measurement_name", "=", measurement_name)]
|
|
314
|
+
if step_index is not None:
|
|
315
|
+
filters.append(("step_index", "=", step_index))
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
table = pq.read_table(pq_file, filters=filters)
|
|
319
|
+
return table.to_pylist()
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
logger.debug(
|
|
322
|
+
"Failed to read measurement %r from %s: %s", measurement_name, pq_file, exc
|
|
323
|
+
)
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def ref_dir_for(file_path: Path) -> Path:
|
|
328
|
+
"""Return _ref/ sidecar dir path for a parquet file."""
|
|
329
|
+
return file_path.parent / (file_path.stem + "_ref")
|
|
330
|
+
|
|
331
|
+
def notify_new_run(self, parquet_path: Path) -> None:
|
|
332
|
+
"""Notify the daemon of a new unified per-run parquet via do_put."""
|
|
333
|
+
paths = [str(parquet_path)]
|
|
334
|
+
try:
|
|
335
|
+
client = self._flight.get_client()
|
|
336
|
+
descriptor = flight.FlightDescriptor.for_command(b"runs\0runs")
|
|
337
|
+
table = pa.table({"file_path": paths})
|
|
338
|
+
batches = table.to_batches()
|
|
339
|
+
writer, reader = client.do_put(descriptor, table.schema, options=call_options())
|
|
340
|
+
for batch in batches:
|
|
341
|
+
writer.write_batch(batch)
|
|
342
|
+
# Drain ACKs — each ACK confirms the server committed one batch,
|
|
343
|
+
# so by the time the last ACK arrives the daemon has fully ingested
|
|
344
|
+
# the file into all index tables including measurements_materialized.
|
|
345
|
+
for _ in batches:
|
|
346
|
+
reader.read()
|
|
347
|
+
writer.close()
|
|
348
|
+
except Exception:
|
|
349
|
+
logger.debug("Failed to notify runs daemon of new run %s", parquet_path, exc_info=True)
|
|
350
|
+
self._flight.reset()
|
|
351
|
+
|
|
352
|
+
def close(self) -> None:
|
|
353
|
+
"""Close Flight client and release daemon ref."""
|
|
354
|
+
try:
|
|
355
|
+
self._flight.close()
|
|
356
|
+
except Exception as exc:
|
|
357
|
+
warnings.warn(f"FlightQueryClient close failed: {exc}", stacklevel=2)
|
|
358
|
+
try:
|
|
359
|
+
runs_duckdb_manager.release(self._runs_dir)
|
|
360
|
+
except Exception as exc:
|
|
361
|
+
warnings.warn(f"runs_duckdb_manager.release failed: {exc}", stacklevel=2)
|
|
362
|
+
|
|
363
|
+
def __enter__(self) -> RunStore:
|
|
364
|
+
return self
|
|
365
|
+
|
|
366
|
+
def __exit__(self, *_: object) -> None:
|
|
367
|
+
self.close()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""DuckDB run index daemon manager.
|
|
2
|
+
|
|
3
|
+
Subclasses ``DaemonManager`` for the runs DuckDB daemon.
|
|
4
|
+
Clients call ``acquire()`` / ``release()``.
|
|
5
|
+
|
|
6
|
+
Parquet files are the source of truth. The in-memory DuckDB index
|
|
7
|
+
is rebuilt from parquet on every daemon start.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import warnings
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from testerkit.data._daemon_lifecycle import DaemonManager, _installed_version, wait_for_location
|
|
17
|
+
from testerkit.data._flight_query import probe_sql
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunsDuckDBManager(DaemonManager):
|
|
21
|
+
"""Manages the DuckDB runs index daemon."""
|
|
22
|
+
|
|
23
|
+
_state_name = "_runs_duckdb.json"
|
|
24
|
+
_lock_name = "_runs_duckdb.lock"
|
|
25
|
+
_ready_name = "_runs_duckdb_ready"
|
|
26
|
+
_pid_name = "_runs_duckdb_pid"
|
|
27
|
+
_daemon_module = "testerkit.data._runs_duckdb_daemon"
|
|
28
|
+
_port_file = "_runs_duckdb_flight_port"
|
|
29
|
+
|
|
30
|
+
# Runs keys daemon reuse on the projection FINGERPRINT, not just the testerkit
|
|
31
|
+
# version (the base-class default). The fingerprint is a content-address of
|
|
32
|
+
# the read path (projection DDL + adapters + whitelist), so a daemon serves
|
|
33
|
+
# exactly one index shape + SQL. Keying reuse on it means a client never
|
|
34
|
+
# sends a daemon SQL for a different projection — the coexistence law of the
|
|
35
|
+
# index-epoch design (derived-index-versioning.md §11.1). ``_projection_
|
|
36
|
+
# fingerprint`` is imported lazily inside the methods to break the import
|
|
37
|
+
# cycle (the daemon module imports this manager).
|
|
38
|
+
|
|
39
|
+
def _daemon_identity(self) -> dict[str, Any]:
|
|
40
|
+
"""Stamp the projection fingerprint (plus the version, for provenance)
|
|
41
|
+
into the state file, so ``_can_reuse`` can compare it. A projection
|
|
42
|
+
change — even within one testerkit version (a dev edit / branch switch,
|
|
43
|
+
invisible to the version ratchet) — yields a new fingerprint and thus a
|
|
44
|
+
fresh daemon on the matching epoch."""
|
|
45
|
+
from testerkit.data._runs_duckdb_daemon import _projection_fingerprint
|
|
46
|
+
|
|
47
|
+
return {"testerkit_version": _installed_version(), "fingerprint": _projection_fingerprint()}
|
|
48
|
+
|
|
49
|
+
def _can_reuse(self, running_state: dict[str, Any]) -> bool:
|
|
50
|
+
"""Reuse only a daemon whose projection fingerprint matches ours exactly.
|
|
51
|
+
A different — or missing (a pre-fingerprint daemon) — fingerprint means it
|
|
52
|
+
serves a different-shaped index/SQL, so respawn rather than send it queries
|
|
53
|
+
it can't answer. Fingerprint equality subsumes the version ratchet: a
|
|
54
|
+
version bump that changes the projection changes the fingerprint (respawn);
|
|
55
|
+
one that doesn't keeps it (reuse)."""
|
|
56
|
+
from testerkit.data._runs_duckdb_daemon import _projection_fingerprint
|
|
57
|
+
|
|
58
|
+
return running_state.get("fingerprint") == _projection_fingerprint()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Module-level convenience — RunStore uses these directly.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def acquire(runs_dir: Path) -> str:
|
|
65
|
+
"""Acquire a reference to the runs DuckDB daemon, starting it if needed.
|
|
66
|
+
|
|
67
|
+
Returns the gRPC location string for Flight queries.
|
|
68
|
+
|
|
69
|
+
The location is written to state file by either
|
|
70
|
+
:meth:`DaemonManager.acquire` (fresh spawn — reads the port file via
|
|
71
|
+
``_post_spawn_state``) or by the daemon itself
|
|
72
|
+
(:meth:`DaemonManager.update_state` after ``write_ready``). Under
|
|
73
|
+
load on slow CI runners we've seen reuse hit a state file the
|
|
74
|
+
daemon hasn't finished filling in yet — poll briefly so transient
|
|
75
|
+
"missing location" cases don't fail the test.
|
|
76
|
+
|
|
77
|
+
After getting the location, a lightweight Flight connectivity probe
|
|
78
|
+
is attempted. If the Flight server isn't responding (e.g. the daemon
|
|
79
|
+
process is alive but its Flight thread crashed), the daemon is killed
|
|
80
|
+
and respawned so callers always get a working connection.
|
|
81
|
+
"""
|
|
82
|
+
mgr = RunsDuckDBManager(runs_dir)
|
|
83
|
+
mgr.acquire()
|
|
84
|
+
location = wait_for_location(mgr, runs_dir, "runs")
|
|
85
|
+
|
|
86
|
+
# Verify the Flight server is actually responding. The PID may be alive
|
|
87
|
+
# but the Flight thread may have crashed (e.g. port conflict, OOM).
|
|
88
|
+
# One cheap probe avoids leaving callers with a silent dead connection.
|
|
89
|
+
if not probe_sql(location, "runs"):
|
|
90
|
+
warnings.warn(
|
|
91
|
+
f"Runs daemon at {location} is not responding — killing and respawning.",
|
|
92
|
+
stacklevel=2,
|
|
93
|
+
)
|
|
94
|
+
mgr.force_restart()
|
|
95
|
+
mgr.acquire()
|
|
96
|
+
location = wait_for_location(mgr, runs_dir, "runs")
|
|
97
|
+
|
|
98
|
+
return location
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def release(runs_dir: Path) -> None:
|
|
102
|
+
"""Release our reference to the runs DuckDB daemon."""
|
|
103
|
+
RunsDuckDBManager(runs_dir).release()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Read-time schema-version dispatch — the seam where a durable artifact's
|
|
2
|
+
stamp selects the adapter that projects it to the current shape.
|
|
3
|
+
|
|
4
|
+
Called exactly once per store, at that store's ``durable-artifact -> rows``
|
|
5
|
+
read boundary (§1/§4 of ``schema-versioning-migration.md``). The whole
|
|
6
|
+
versioning surface reduces to:
|
|
7
|
+
|
|
8
|
+
stamp --dispatch--> adapter(source_version -> current) --> current rows
|
|
9
|
+
|
|
10
|
+
Three outcomes when a file's stamp is read:
|
|
11
|
+
|
|
12
|
+
- **present and whitelisted** (in :data:`KNOWN_SCHEMA_VERSIONS`) -> return that
|
|
13
|
+
version's adapter (identity today: ``0.1`` == current, no transform).
|
|
14
|
+
- **present but unknown** (a future ``5.0``, or an abandoned version) -> refuse
|
|
15
|
+
with :class:`SchemaVersionRefused` ("unsupported schema version").
|
|
16
|
+
- **absent** (unstamped) -> refuse ("regenerate"). The current version is always
|
|
17
|
+
stamped at write, so absence can only mean a pre-stamp file.
|
|
18
|
+
|
|
19
|
+
Each store's read boundary catches :class:`SchemaVersionRefused` and routes it
|
|
20
|
+
into that store's existing skip/quarantine path, so one bad-version file is
|
|
21
|
+
isolated and the good ones still land.
|
|
22
|
+
|
|
23
|
+
The adapter registry ships **current-identity only** — no speculative
|
|
24
|
+
transforms. A real ``vN -> current`` adapter is registered here the same commit
|
|
25
|
+
its source version joins :data:`KNOWN_SCHEMA_VERSIONS`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import threading
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from testerkit.data.schema_versions import KNOWN_SCHEMA_VERSIONS, SchemaStore
|
|
36
|
+
|
|
37
|
+
_logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# An adapter is a pure transform from a source-version artifact to the current
|
|
40
|
+
# shape. For the Arrow stores (runs / events / channels) it is
|
|
41
|
+
# ``pa.Table -> pa.Table``; for the files sidecar it is
|
|
42
|
+
# ``FileArtifactMetadata -> FileArtifactMetadata``. Kept structurally typed
|
|
43
|
+
# (``Any -> Any``) because the registry spans both.
|
|
44
|
+
Adapter = Callable[[Any], Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _schema_version_tuple(v: str) -> tuple[int, ...] | None:
|
|
48
|
+
"""Parse a dotted schema version into a comparable int tuple; ``None`` if
|
|
49
|
+
unparseable. Named distinctly from ``_daemon_lifecycle._version_tuple``,
|
|
50
|
+
which *leniently* parses PACKAGE versions (``.dev``/``.rc`` suffixes); schema
|
|
51
|
+
versions are clean ``major.minor`` and parse strictly."""
|
|
52
|
+
try:
|
|
53
|
+
return tuple(int(part) for part in v.split("."))
|
|
54
|
+
except (ValueError, AttributeError):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SchemaVersionRefused(Exception):
|
|
59
|
+
"""A durable artifact carries a schema version this store cannot read.
|
|
60
|
+
|
|
61
|
+
``deferrable`` splits the two refusal classes that need different fates at
|
|
62
|
+
the read boundary (see #43):
|
|
63
|
+
|
|
64
|
+
- **deferrable=True** — the stamp is a version *newer* than anything this
|
|
65
|
+
daemon knows. The machine's singleton daemon trends newest, so a newer
|
|
66
|
+
daemon WILL read it — the file must stay re-attemptable (never permanently
|
|
67
|
+
ledgered), else it goes invisible forever after the daemon is upgraded.
|
|
68
|
+
- **deferrable=False** — absent (unstamped) or an unparseable / older-unknown
|
|
69
|
+
stamp. No future daemon will read it: permanent skip (regenerate).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self, store: SchemaStore, version: str | None, reason: str, *, deferrable: bool
|
|
74
|
+
) -> None:
|
|
75
|
+
self.store = store
|
|
76
|
+
self.version = version
|
|
77
|
+
self.deferrable = deferrable
|
|
78
|
+
super().__init__(f"[{store.value}] schema version {version!r}: {reason}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Adapter registry ────────────────────────────────────────────────────────
|
|
82
|
+
# One transform per (store, source_version) -> current. Empty today (every
|
|
83
|
+
# known version IS current, so dispatch returns identity). ``register_adapter``
|
|
84
|
+
# is how a future epoch's transform is added; ``_identity`` is the current no-op.
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _identity(rows: Any) -> Any:
|
|
88
|
+
"""Identity adapter (the current-version no-op). Adapter contract for future
|
|
89
|
+
authors: an adapter MUST NOT mutate its input in place — return the value.
|
|
90
|
+
Returning the input unchanged is correct for this no-op (Arrow tables are
|
|
91
|
+
immutable anyway; the migrate sink calls ``.replace_schema_metadata()`` on the
|
|
92
|
+
result, which copies). A real transform returns a new table / copied model."""
|
|
93
|
+
return rows
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_ADAPTERS: dict[SchemaStore, dict[str, Adapter]] = {store: {} for store in SchemaStore}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def register_adapter(store: SchemaStore, source_version: str, adapter: Adapter) -> None:
|
|
100
|
+
"""Register a ``source_version -> current`` transform for *store*, and make
|
|
101
|
+
that version readable in the same step.
|
|
102
|
+
|
|
103
|
+
Registering an adapter also adds ``source_version`` to
|
|
104
|
+
``KNOWN_SCHEMA_VERSIONS[store]`` — otherwise the adapter would be dead code:
|
|
105
|
+
:func:`dispatch` refuses an unknown version *before* it ever reaches the
|
|
106
|
+
adapter. Self-wiring removes that two-edit footgun.
|
|
107
|
+
"""
|
|
108
|
+
_ADAPTERS[store][source_version] = adapter
|
|
109
|
+
KNOWN_SCHEMA_VERSIONS[store] = KNOWN_SCHEMA_VERSIONS[store] | {source_version}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def dispatch(store: SchemaStore, version: str | None) -> Adapter:
|
|
113
|
+
"""Whitelist-dispatch *version* for *store*; return its adapter or refuse.
|
|
114
|
+
|
|
115
|
+
Returns the registered adapter, or :func:`_identity` when the version is
|
|
116
|
+
known but current-shaped (the current-version case). Raises
|
|
117
|
+
:class:`SchemaVersionRefused` for an unknown or absent version.
|
|
118
|
+
"""
|
|
119
|
+
if version is None:
|
|
120
|
+
raise SchemaVersionRefused(
|
|
121
|
+
store,
|
|
122
|
+
None,
|
|
123
|
+
"unstamped artifact — unsupported by design; regenerate",
|
|
124
|
+
deferrable=False,
|
|
125
|
+
)
|
|
126
|
+
if version not in KNOWN_SCHEMA_VERSIONS[store]:
|
|
127
|
+
known = ", ".join(sorted(KNOWN_SCHEMA_VERSIONS[store]))
|
|
128
|
+
# Deferrable iff strictly NEWER than every known version — a newer daemon
|
|
129
|
+
# will read it. Older / unparseable-unknown is pre-baseline or garbage: permanent.
|
|
130
|
+
vt = _schema_version_tuple(version)
|
|
131
|
+
known_tuples = [
|
|
132
|
+
t for t in (_schema_version_tuple(k) for k in KNOWN_SCHEMA_VERSIONS[store]) if t
|
|
133
|
+
]
|
|
134
|
+
deferrable = bool(vt and known_tuples and vt > max(known_tuples))
|
|
135
|
+
raise SchemaVersionRefused(
|
|
136
|
+
store,
|
|
137
|
+
version,
|
|
138
|
+
f"unsupported schema version (known: {known})",
|
|
139
|
+
deferrable=deferrable,
|
|
140
|
+
)
|
|
141
|
+
return _ADAPTERS[store].get(version, _identity)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Refusal reporting ────────────────────────────────────────────────────────
|
|
145
|
+
# Process-local dedup so the presence-only stores (channels / files), which
|
|
146
|
+
# re-read an un-ledgered refusal every scan, don't re-warn each time. Not
|
|
147
|
+
# backend-coupled — plain stdlib logging over a set of already-warned paths.
|
|
148
|
+
# Capped so a pathological flood of distinct bad-version files can't grow it
|
|
149
|
+
# without bound; clearing at the cap just lets each path warn once more.
|
|
150
|
+
_MAX_WARNED_PATHS = 4096
|
|
151
|
+
_warned_permanent_refusals: set[str] = set()
|
|
152
|
+
# A daemon may report refusals from more than one ingest thread; the lock keeps
|
|
153
|
+
# the check-and-add atomic so a path warns exactly once (else two threads racing
|
|
154
|
+
# the same path each emit the warning).
|
|
155
|
+
_warned_lock = threading.Lock()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def report_schema_refusal(exc: SchemaVersionRefused, path: str) -> None:
|
|
159
|
+
"""Surface a refusal uniformly across all four store boundaries: a
|
|
160
|
+
*deferrable* (newer) refusal is a transient debug note (a newer daemon will
|
|
161
|
+
ingest it); a *permanent* (absent / unstamped) refusal is warned once per path
|
|
162
|
+
so the operator knows the file needs regenerating.
|
|
163
|
+
"""
|
|
164
|
+
if exc.deferrable:
|
|
165
|
+
_logger.debug("Deferring newer-version artifact %s: %s", path, exc)
|
|
166
|
+
return
|
|
167
|
+
with _warned_lock:
|
|
168
|
+
if path in _warned_permanent_refusals:
|
|
169
|
+
return
|
|
170
|
+
if len(_warned_permanent_refusals) >= _MAX_WARNED_PATHS:
|
|
171
|
+
_warned_permanent_refusals.clear()
|
|
172
|
+
_warned_permanent_refusals.add(path)
|
|
173
|
+
# Log outside the lock (I/O): only the winning thread reaches here per path.
|
|
174
|
+
_logger.warning("Skipping unsupported (unstamped/unknown) schema in %s: %s", path, exc)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── Stamp extraction ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def stamp_from_arrow_metadata(
|
|
181
|
+
metadata: dict[bytes, bytes] | None, key: bytes = b"schema_version"
|
|
182
|
+
) -> str | None:
|
|
183
|
+
"""Read a schema-version stamp from Arrow/parquet file-level metadata.
|
|
184
|
+
|
|
185
|
+
Returns ``None`` if the metadata is absent or lacks *key* — which
|
|
186
|
+
:func:`dispatch` treats as an unstamped artifact.
|
|
187
|
+
"""
|
|
188
|
+
if not metadata:
|
|
189
|
+
return None
|
|
190
|
+
raw = metadata.get(key)
|
|
191
|
+
return raw.decode() if raw is not None else None
|