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,664 @@
|
|
|
1
|
+
"""Session-less channel index — the warm DuckDB reader behind ChannelStore.
|
|
2
|
+
|
|
3
|
+
A channel producer *stamps* its ``session_id`` onto every sample it writes; this
|
|
4
|
+
index *reads* session_ids off the rows and has none of its own. It is the
|
|
5
|
+
cross-session corpus: a persistent derived cache (``_index.duckdb`` in the
|
|
6
|
+
channels dir) over the producer IPC segments, plus an ephemeral ``:memory:``
|
|
7
|
+
overlay for live ``do_put`` rows. Producer IPC files remain the durable truth;
|
|
8
|
+
the index survives a daemon restart and is brought current by an incremental
|
|
9
|
+
ledger-gated scan.
|
|
10
|
+
|
|
11
|
+
``ChannelStore`` composes one of these when indexing is enabled — on the daemon
|
|
12
|
+
(it ingests + serves at-rest query) and in warm-index tests (a writer that also
|
|
13
|
+
indexes its own writes). The index never knows which session is "active": every
|
|
14
|
+
read filters by a ``session_id`` query parameter, never an instance field.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from datetime import UTC, datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import duckdb
|
|
29
|
+
import pyarrow as pa
|
|
30
|
+
import pyarrow.ipc as ipc
|
|
31
|
+
|
|
32
|
+
from testerkit.data._daemon_lifecycle import daemon_duckdb_config
|
|
33
|
+
from testerkit.data.channels.models import (
|
|
34
|
+
ChannelDescriptor,
|
|
35
|
+
ChannelSample,
|
|
36
|
+
encode_value,
|
|
37
|
+
)
|
|
38
|
+
from testerkit.data.schema_dispatch import (
|
|
39
|
+
SchemaVersionRefused,
|
|
40
|
+
dispatch,
|
|
41
|
+
report_schema_refusal,
|
|
42
|
+
stamp_from_arrow_metadata,
|
|
43
|
+
)
|
|
44
|
+
from testerkit.data.schema_versions import SchemaStore
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _to_utc(dt: datetime | None) -> datetime | None:
|
|
48
|
+
"""Coerce a datetime to UTC (or pass through ``None``).
|
|
49
|
+
|
|
50
|
+
Naive datetimes are interpreted as already UTC; aware datetimes
|
|
51
|
+
are converted via ``astimezone(UTC)``.
|
|
52
|
+
"""
|
|
53
|
+
if dt is None:
|
|
54
|
+
return None
|
|
55
|
+
if dt.tzinfo is None:
|
|
56
|
+
return dt.replace(tzinfo=UTC)
|
|
57
|
+
return dt.astimezone(UTC)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _lttb_indices(values: Sequence[float], n_out: int) -> list[int]:
|
|
61
|
+
"""Largest Triangle Three Buckets downsampling — return selected indices.
|
|
62
|
+
|
|
63
|
+
Visually lossless: preserves peaks, valleys, and shape better than naive
|
|
64
|
+
stride decimation. Delegates to ``tsdownsample`` (compiled LTTB); first and
|
|
65
|
+
last points are always kept.
|
|
66
|
+
|
|
67
|
+
Reference: Sveinn Steinarsson, "Downsampling Time Series for Visual
|
|
68
|
+
Representation", MSc thesis, University of Iceland, 2013.
|
|
69
|
+
"""
|
|
70
|
+
n = len(values)
|
|
71
|
+
if n <= n_out or n_out < 3:
|
|
72
|
+
return list(range(n))
|
|
73
|
+
# Heavy deps deferred off the module import path — only the decimation
|
|
74
|
+
# (query w/ max_points) path pays numpy/tsdownsample's load.
|
|
75
|
+
import numpy as np # noqa: PLC0415
|
|
76
|
+
from tsdownsample import LTTBDownsampler # noqa: PLC0415
|
|
77
|
+
|
|
78
|
+
indices = LTTBDownsampler().downsample(np.asarray(values, dtype=float), n_out=n_out)
|
|
79
|
+
return [int(i) for i in indices]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _decimate_table(table: pa.Table, max_points: int) -> pa.Table:
|
|
83
|
+
"""Apply LTTB decimation to an Arrow table.
|
|
84
|
+
|
|
85
|
+
Uses the ``value`` column for scalar channels, or row index for
|
|
86
|
+
struct/array channels (where there's no single numeric column).
|
|
87
|
+
"""
|
|
88
|
+
n = len(table)
|
|
89
|
+
if n <= max_points:
|
|
90
|
+
return table
|
|
91
|
+
|
|
92
|
+
# Find best column for LTTB area calculation
|
|
93
|
+
if "value" in table.schema.names:
|
|
94
|
+
col = table.column("value")
|
|
95
|
+
try:
|
|
96
|
+
values = [float(v.as_py()) for v in col]
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
# Non-numeric value column — fall back to stride
|
|
99
|
+
indices = list(range(0, n, max(1, n // max_points)))[:max_points]
|
|
100
|
+
return table.take(indices)
|
|
101
|
+
else:
|
|
102
|
+
# Struct/array channel — use row index as proxy (preserves time density)
|
|
103
|
+
values = list(range(n))
|
|
104
|
+
|
|
105
|
+
indices = _lttb_indices(values, max_points)
|
|
106
|
+
return table.take(indices)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ChannelIndex:
|
|
110
|
+
"""Warm DuckDB index over closed channel segments + a live overlay.
|
|
111
|
+
|
|
112
|
+
Session-less: reads session_ids off the data rows and filters by a query
|
|
113
|
+
parameter. Owns the index connection, the incremental scan ledger, the
|
|
114
|
+
per-(channel, session) registry, and the live-row pending buffer.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
_INDEX_ENVELOPE = frozenset(
|
|
118
|
+
{
|
|
119
|
+
"received_at",
|
|
120
|
+
"sampled_at",
|
|
121
|
+
"source_method",
|
|
122
|
+
"session_id",
|
|
123
|
+
"sample_interval",
|
|
124
|
+
"sample_offset",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
_INDEX_ARROW_SCHEMA = pa.schema(
|
|
129
|
+
[
|
|
130
|
+
("channel_id", pa.utf8()),
|
|
131
|
+
("session_id", pa.utf8()),
|
|
132
|
+
("received_at", pa.timestamp("us", tz="UTC")),
|
|
133
|
+
("sampled_at", pa.timestamp("us", tz="UTC")),
|
|
134
|
+
("source_method", pa.utf8()),
|
|
135
|
+
("sample_interval", pa.float64()),
|
|
136
|
+
("value", pa.utf8()),
|
|
137
|
+
("sample_offset", pa.int64()),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Bound how often a query re-globs the channels dir for newly-closed
|
|
142
|
+
# segments — frequent dashboard polls shouldn't each pay a directory walk.
|
|
143
|
+
_RUNTIME_SCAN_INTERVAL = 1.0
|
|
144
|
+
|
|
145
|
+
_REGISTRY_COLUMNS = (
|
|
146
|
+
"channel_id, session_id, hostname, value_type, instrument_role, "
|
|
147
|
+
"resource, unit, first_seen, last_updated"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __init__(self, channels_dir: Path) -> None:
|
|
151
|
+
self._channels_dir = channels_dir
|
|
152
|
+
# Served descriptor cache (channel-keyed, first-wins) populated by
|
|
153
|
+
# absorb_descriptor as segments/streams are read.
|
|
154
|
+
self._descriptors: dict[str, ChannelDescriptor] = {}
|
|
155
|
+
self._index_db: duckdb.DuckDBPyConnection | None = None
|
|
156
|
+
self._index_local = threading.local()
|
|
157
|
+
self._index_lock = threading.Lock()
|
|
158
|
+
self._pending: list[dict[str, Any]] = []
|
|
159
|
+
self._pending_lock = threading.Lock()
|
|
160
|
+
self._pending_threshold = 100
|
|
161
|
+
self._scan_lock = threading.Lock()
|
|
162
|
+
self._last_scan = 0.0
|
|
163
|
+
|
|
164
|
+
# ---- descriptor cache ----
|
|
165
|
+
|
|
166
|
+
def has(self, channel_id: str) -> bool:
|
|
167
|
+
"""Whether a descriptor has been absorbed for this channel."""
|
|
168
|
+
return channel_id in self._descriptors
|
|
169
|
+
|
|
170
|
+
def descriptor(self, channel_id: str) -> ChannelDescriptor | None:
|
|
171
|
+
"""The absorbed descriptor for a channel, or None."""
|
|
172
|
+
return self._descriptors.get(channel_id)
|
|
173
|
+
|
|
174
|
+
def descriptors(self) -> list[ChannelDescriptor]:
|
|
175
|
+
"""All absorbed descriptors (one per channel)."""
|
|
176
|
+
return list(self._descriptors.values())
|
|
177
|
+
|
|
178
|
+
# ---- lifecycle ----
|
|
179
|
+
|
|
180
|
+
def open(self) -> None:
|
|
181
|
+
"""Open the on-disk index and fold in segments closed since last run.
|
|
182
|
+
|
|
183
|
+
The index is a persistent derived cache (``_index.duckdb`` in the
|
|
184
|
+
channels dir): it survives a daemon restart and is brought current
|
|
185
|
+
by an **incremental** scan — only segments not already in the
|
|
186
|
+
``_ingested`` ledger are read (vs. the old wipe-and-rebuild-from-all
|
|
187
|
+
on every start). Producer IPC files remain the durable truth.
|
|
188
|
+
|
|
189
|
+
Live ``do_put`` rows ride a separate attached ``:memory:`` overlay
|
|
190
|
+
(``live.channel_live``): they are ephemeral (lost on restart, then
|
|
191
|
+
re-derived from their now-closed segments by the incremental scan),
|
|
192
|
+
so they never collide with a segment-scanned row. Mirrors the runs
|
|
193
|
+
daemon's persistent-index + in-memory-overlay split.
|
|
194
|
+
"""
|
|
195
|
+
index_path = self._channels_dir / "_index.duckdb"
|
|
196
|
+
self._index_db = duckdb.connect(str(index_path), config=daemon_duckdb_config())
|
|
197
|
+
self._ensure_schema(self._index_db)
|
|
198
|
+
# Ephemeral live overlay: attached :memory: so it's visible to every
|
|
199
|
+
# child read cursor (a register()'d temp view would not be), yet not
|
|
200
|
+
# persisted — it's a projection of in-flight samples, re-derived from
|
|
201
|
+
# segments on restart.
|
|
202
|
+
self._index_db.execute("ATTACH ':memory:' AS live")
|
|
203
|
+
self._index_db.execute(
|
|
204
|
+
"CREATE TABLE live.channel_live AS SELECT * FROM channel_index LIMIT 0"
|
|
205
|
+
)
|
|
206
|
+
self._scan_disk()
|
|
207
|
+
|
|
208
|
+
def close(self) -> None:
|
|
209
|
+
"""Close the index connection."""
|
|
210
|
+
if self._index_db is not None:
|
|
211
|
+
try:
|
|
212
|
+
self._index_db.close()
|
|
213
|
+
except (OSError, duckdb.Error):
|
|
214
|
+
pass
|
|
215
|
+
self._index_db = None
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
|
219
|
+
"""Create the on-disk index schema if absent.
|
|
220
|
+
|
|
221
|
+
The index is a disposable projection — every row is re-derivable from
|
|
222
|
+
the durable ``.arrow`` segments. ``CREATE TABLE IF NOT EXISTS`` keeps an
|
|
223
|
+
existing table as-is, so a column change is not an in-place migration:
|
|
224
|
+
clear ``data/channels`` and let the next open rebuild the projection.
|
|
225
|
+
"""
|
|
226
|
+
conn.execute(
|
|
227
|
+
"""
|
|
228
|
+
CREATE TABLE IF NOT EXISTS channel_index (
|
|
229
|
+
channel_id VARCHAR,
|
|
230
|
+
session_id VARCHAR,
|
|
231
|
+
received_at TIMESTAMPTZ,
|
|
232
|
+
sampled_at TIMESTAMPTZ,
|
|
233
|
+
source_method VARCHAR,
|
|
234
|
+
sample_interval DOUBLE,
|
|
235
|
+
value VARCHAR,
|
|
236
|
+
sample_offset BIGINT
|
|
237
|
+
)
|
|
238
|
+
"""
|
|
239
|
+
)
|
|
240
|
+
# Ledger of ingested segments — keyed on path alone. A channel
|
|
241
|
+
# segment is written exactly once (one batch, then closed
|
|
242
|
+
# immutable), so a path that's already recorded never needs
|
|
243
|
+
# re-reading. (Events key on (path, mtime, size) because its IPC
|
|
244
|
+
# files grow; channel segments don't.)
|
|
245
|
+
conn.execute(
|
|
246
|
+
"CREATE TABLE IF NOT EXISTS _ingested (path VARCHAR PRIMARY KEY, row_count BIGINT)"
|
|
247
|
+
)
|
|
248
|
+
conn.execute(
|
|
249
|
+
"CREATE INDEX IF NOT EXISTS idx_channel_index_cid_recv "
|
|
250
|
+
"ON channel_index(channel_id, received_at)"
|
|
251
|
+
)
|
|
252
|
+
# Derived registry: one version row per (channel, session). Non-unique on
|
|
253
|
+
# (hostname, channel) — each session that opens a channel appends a row, so
|
|
254
|
+
# current def = latest, history = all rows. ``last_updated`` (coarse) is the
|
|
255
|
+
# freshest received_at seen, for liveness staleness.
|
|
256
|
+
conn.execute(
|
|
257
|
+
"""
|
|
258
|
+
CREATE TABLE IF NOT EXISTS channel_registry (
|
|
259
|
+
channel_id VARCHAR,
|
|
260
|
+
session_id VARCHAR,
|
|
261
|
+
hostname VARCHAR,
|
|
262
|
+
value_type VARCHAR,
|
|
263
|
+
instrument_role VARCHAR,
|
|
264
|
+
resource VARCHAR,
|
|
265
|
+
unit VARCHAR,
|
|
266
|
+
first_seen TIMESTAMPTZ,
|
|
267
|
+
last_updated TIMESTAMPTZ,
|
|
268
|
+
PRIMARY KEY (channel_id, session_id)
|
|
269
|
+
)
|
|
270
|
+
"""
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _cursor(self) -> duckdb.DuckDBPyConnection:
|
|
274
|
+
"""Thread-local read cursor over the shared in-memory index."""
|
|
275
|
+
cur = getattr(self._index_local, "cur", None)
|
|
276
|
+
if cur is None:
|
|
277
|
+
assert self._index_db is not None
|
|
278
|
+
cur = self._index_db.cursor()
|
|
279
|
+
self._index_local.cur = cur
|
|
280
|
+
return cur
|
|
281
|
+
|
|
282
|
+
# ---- segment scanning ----
|
|
283
|
+
|
|
284
|
+
def _scan_disk(self) -> None:
|
|
285
|
+
"""Fold segments closed since last run into the persistent index.
|
|
286
|
+
|
|
287
|
+
Incremental: a segment already in the ``_ingested`` ledger is
|
|
288
|
+
skipped, so a daemon restart only reads new files rather than
|
|
289
|
+
rebuilding the whole index from every segment.
|
|
290
|
+
"""
|
|
291
|
+
if self._index_db is None:
|
|
292
|
+
return
|
|
293
|
+
pattern = re.compile(r"^(.+)_([0-9a-f]{8})(?:_\d+)?$")
|
|
294
|
+
with self._index_lock:
|
|
295
|
+
ingested = {
|
|
296
|
+
row[0] for row in self._index_db.execute("SELECT path FROM _ingested").fetchall()
|
|
297
|
+
}
|
|
298
|
+
for arrow_file in sorted(self._channels_dir.glob("*/*.arrow")):
|
|
299
|
+
path_str = str(arrow_file)
|
|
300
|
+
if path_str in ingested:
|
|
301
|
+
continue
|
|
302
|
+
m = pattern.match(arrow_file.stem)
|
|
303
|
+
if not m:
|
|
304
|
+
continue
|
|
305
|
+
try:
|
|
306
|
+
reader = ipc.open_stream(pa.OSFile(str(arrow_file), "rb"))
|
|
307
|
+
table = reader.read_all()
|
|
308
|
+
except (pa.ArrowInvalid, OSError):
|
|
309
|
+
# Torn / still-open segment — leave it out of the ledger so
|
|
310
|
+
# the next restart re-reads it once it's a complete file.
|
|
311
|
+
continue
|
|
312
|
+
try:
|
|
313
|
+
adapter = dispatch(
|
|
314
|
+
SchemaStore.CHANNELS, stamp_from_arrow_metadata(table.schema.metadata)
|
|
315
|
+
)
|
|
316
|
+
except SchemaVersionRefused as exc:
|
|
317
|
+
# Surface it (permanent → warn-once; deferrable → debug), then skip
|
|
318
|
+
# WITHOUT ledgering so a deferrable (newer) segment is re-read next
|
|
319
|
+
# scan and a newer daemon ingests it (#43). Presence-only ledger →
|
|
320
|
+
# an absent/pre-1.0 segment is re-read too; the warning is deduped.
|
|
321
|
+
report_schema_refusal(exc, path_str)
|
|
322
|
+
continue
|
|
323
|
+
table = adapter(table)
|
|
324
|
+
desc = self.absorb_descriptor(m.group(1), table.schema)
|
|
325
|
+
if desc is not None and "received_at" in table.column_names:
|
|
326
|
+
recv = [t for t in table.column("received_at").to_pylist() if t is not None]
|
|
327
|
+
if recv:
|
|
328
|
+
self.bump_last_updated(desc.channel_id, desc.session_id, max(recv))
|
|
329
|
+
rows = self._segment_rows_to_index(m.group(1), table)
|
|
330
|
+
self._insert_index_rows(rows, "channel_index", ledger_path=path_str)
|
|
331
|
+
|
|
332
|
+
def _maybe_scan_disk(self) -> None:
|
|
333
|
+
"""Fold newly-closed segments into the index before a query (throttled).
|
|
334
|
+
|
|
335
|
+
Closes the restart-recovery gap: a sample the live push dropped under
|
|
336
|
+
overflow is durable in its closed segment and becomes queryable here,
|
|
337
|
+
without bouncing the daemon. Incremental (ledger-gated) and rate-limited;
|
|
338
|
+
the union dedup in ``query`` absorbs any overlap with the overlay.
|
|
339
|
+
"""
|
|
340
|
+
now = time.monotonic()
|
|
341
|
+
with self._scan_lock:
|
|
342
|
+
if now - self._last_scan < self._RUNTIME_SCAN_INTERVAL:
|
|
343
|
+
return
|
|
344
|
+
self._last_scan = now
|
|
345
|
+
self._scan_disk()
|
|
346
|
+
|
|
347
|
+
# ---- registry ----
|
|
348
|
+
|
|
349
|
+
def absorb_descriptor(self, channel_id: str, schema: pa.Schema) -> ChannelDescriptor | None:
|
|
350
|
+
"""Populate the served descriptor map + registry from segment/stream metadata.
|
|
351
|
+
|
|
352
|
+
Producers stamp the ``ChannelDescriptor`` as Arrow schema metadata on
|
|
353
|
+
every segment; the daemon (which never calls ``write()``) reads it here
|
|
354
|
+
so ``list_channel_info`` can serve it. Returns the parsed descriptor (or
|
|
355
|
+
``None`` if absent) so callers can reuse it without re-parsing.
|
|
356
|
+
"""
|
|
357
|
+
meta = (schema.metadata or {}).get(b"testerkit.channel_descriptor")
|
|
358
|
+
if not meta:
|
|
359
|
+
return None
|
|
360
|
+
desc = ChannelDescriptor.model_validate_json(meta)
|
|
361
|
+
# Registry row is per (channel, session): establish it even when the
|
|
362
|
+
# channel-keyed descriptor cache already holds another session's descriptor
|
|
363
|
+
# (its last-write-wins would otherwise drop this session's version row).
|
|
364
|
+
self._register_descriptor_row(desc)
|
|
365
|
+
if channel_id not in self._descriptors:
|
|
366
|
+
self._descriptors[channel_id] = desc
|
|
367
|
+
return desc
|
|
368
|
+
|
|
369
|
+
def _register_descriptor_row(self, desc: ChannelDescriptor) -> None:
|
|
370
|
+
"""Establish a (channel, session) registry version row (idempotent).
|
|
371
|
+
|
|
372
|
+
Carries the per-session hostname/descriptor; ``last_updated`` starts NULL
|
|
373
|
+
and is advanced by :meth:`bump_last_updated`. No-op off the indexed daemon.
|
|
374
|
+
"""
|
|
375
|
+
if self._index_db is None:
|
|
376
|
+
return
|
|
377
|
+
with self._index_lock:
|
|
378
|
+
self._index_db.execute(
|
|
379
|
+
"""
|
|
380
|
+
INSERT INTO channel_registry
|
|
381
|
+
(channel_id, session_id, hostname, value_type, instrument_role,
|
|
382
|
+
resource, unit, first_seen, last_updated)
|
|
383
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
384
|
+
ON CONFLICT (channel_id, session_id) DO NOTHING
|
|
385
|
+
""",
|
|
386
|
+
[
|
|
387
|
+
desc.channel_id,
|
|
388
|
+
desc.session_id,
|
|
389
|
+
desc.hostname,
|
|
390
|
+
desc.value_type,
|
|
391
|
+
desc.instrument_role,
|
|
392
|
+
desc.resource,
|
|
393
|
+
desc.unit,
|
|
394
|
+
desc.first_seen,
|
|
395
|
+
],
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def bump_last_updated(self, channel_id: str, session_id: str, ts: datetime | None) -> None:
|
|
399
|
+
"""Advance a registry row's ``last_updated`` to ``ts`` if newer (NULL-safe)."""
|
|
400
|
+
if self._index_db is None or ts is None or not session_id:
|
|
401
|
+
return
|
|
402
|
+
with self._index_lock:
|
|
403
|
+
self._index_db.execute(
|
|
404
|
+
"""
|
|
405
|
+
UPDATE channel_registry
|
|
406
|
+
SET last_updated = CASE
|
|
407
|
+
WHEN last_updated IS NULL OR ? > last_updated THEN ? ELSE last_updated END
|
|
408
|
+
WHERE channel_id = ? AND session_id = ?
|
|
409
|
+
""",
|
|
410
|
+
[ts, ts, channel_id, session_id],
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def query_registry(self) -> pa.Table:
|
|
414
|
+
"""All ``(hostname, channel, session)`` registry version rows.
|
|
415
|
+
|
|
416
|
+
Daemon-side read of the derived registry (folds any newly-closed segments
|
|
417
|
+
first). Off the indexed daemon this is empty.
|
|
418
|
+
"""
|
|
419
|
+
if self._index_db is None:
|
|
420
|
+
return pa.table({c.strip(): [] for c in self._REGISTRY_COLUMNS.split(",")})
|
|
421
|
+
self._maybe_scan_disk()
|
|
422
|
+
cur = self._cursor()
|
|
423
|
+
result = cur.execute(f"SELECT {self._REGISTRY_COLUMNS} FROM channel_registry")
|
|
424
|
+
return result.arrow().read_all()
|
|
425
|
+
|
|
426
|
+
# ---- row building + insert ----
|
|
427
|
+
|
|
428
|
+
@classmethod
|
|
429
|
+
def _segment_rows_to_index(cls, channel_id: str, table: pa.Table) -> list[dict[str, Any]]:
|
|
430
|
+
"""Convert a typed segment table to index rows (``value`` JSON-encoded).
|
|
431
|
+
|
|
432
|
+
``channel_id`` comes from the filename (segments don't store it);
|
|
433
|
+
``session_id`` from the row column. Scalar/array rows carry a
|
|
434
|
+
``value`` column; dict/struct rows fold their non-envelope columns
|
|
435
|
+
back into one JSON object.
|
|
436
|
+
"""
|
|
437
|
+
out: list[dict[str, Any]] = []
|
|
438
|
+
for r in table.to_pylist():
|
|
439
|
+
if "value" in r:
|
|
440
|
+
payload = r["value"]
|
|
441
|
+
else:
|
|
442
|
+
payload = {k: v for k, v in r.items() if k not in cls._INDEX_ENVELOPE}
|
|
443
|
+
out.append(
|
|
444
|
+
{
|
|
445
|
+
"channel_id": channel_id,
|
|
446
|
+
"session_id": r.get("session_id"),
|
|
447
|
+
"received_at": r.get("received_at"),
|
|
448
|
+
"sampled_at": r.get("sampled_at"),
|
|
449
|
+
"source_method": r.get("source_method") or "",
|
|
450
|
+
"sample_interval": r.get("sample_interval"),
|
|
451
|
+
"value": encode_value(payload),
|
|
452
|
+
# Carry the segment's sample_offset into the index so a scanned row
|
|
453
|
+
# dedups against the same sample in the live overlay (else the
|
|
454
|
+
# runtime fold would double-count it with a null sample_offset).
|
|
455
|
+
"sample_offset": r.get("sample_offset", -1),
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
def _insert_index_rows(
|
|
461
|
+
self,
|
|
462
|
+
rows: list[dict[str, Any]],
|
|
463
|
+
table: str,
|
|
464
|
+
*,
|
|
465
|
+
ledger_path: str | None = None,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Insert index rows under the write lock (single writer).
|
|
468
|
+
|
|
469
|
+
``table`` is ``channel_index`` (durable, segment-scanned) or
|
|
470
|
+
``live.channel_live`` (ephemeral overlay). ``ledger_path``, when
|
|
471
|
+
given, records the source segment in ``_ingested`` in the SAME
|
|
472
|
+
transaction as the insert, so a crash can't half-record a segment.
|
|
473
|
+
"""
|
|
474
|
+
if self._index_db is None:
|
|
475
|
+
return
|
|
476
|
+
if not rows:
|
|
477
|
+
# An empty segment still needs its ledger mark so it isn't
|
|
478
|
+
# re-read on every restart.
|
|
479
|
+
if ledger_path is not None:
|
|
480
|
+
with self._index_lock:
|
|
481
|
+
self._index_db.execute(
|
|
482
|
+
"INSERT OR IGNORE INTO _ingested (path, row_count) VALUES (?, 0)",
|
|
483
|
+
[ledger_path],
|
|
484
|
+
)
|
|
485
|
+
return
|
|
486
|
+
tbl = pa.Table.from_pylist(rows, schema=self._INDEX_ARROW_SCHEMA)
|
|
487
|
+
with self._index_lock:
|
|
488
|
+
self._index_db.register("_incoming", tbl)
|
|
489
|
+
self._index_db.execute(f"INSERT INTO {table} SELECT * FROM _incoming")
|
|
490
|
+
self._index_db.unregister("_incoming")
|
|
491
|
+
if ledger_path is not None:
|
|
492
|
+
self._index_db.execute(
|
|
493
|
+
"INSERT OR IGNORE INTO _ingested (path, row_count) VALUES (?, ?)",
|
|
494
|
+
[ledger_path, len(rows)],
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def insert_live_columnar(self, src_batch: pa.RecordBatch) -> None:
|
|
498
|
+
"""Columnar index insert for the scalar fast path: project the wire batch's
|
|
499
|
+
columns straight into the index schema and INSERT…SELECT — no per-row
|
|
500
|
+
dict build. The wire ``value`` (JSON utf8) is already the index encoding."""
|
|
501
|
+
if self._index_db is None or src_batch.num_rows == 0:
|
|
502
|
+
return
|
|
503
|
+
idx = pa.record_batch(
|
|
504
|
+
{name: src_batch.column(name) for name in self._INDEX_ARROW_SCHEMA.names},
|
|
505
|
+
schema=self._INDEX_ARROW_SCHEMA,
|
|
506
|
+
)
|
|
507
|
+
tbl = pa.Table.from_batches([idx])
|
|
508
|
+
with self._index_lock:
|
|
509
|
+
self._index_db.register("_incoming", tbl)
|
|
510
|
+
self._index_db.execute("INSERT INTO live.channel_live SELECT * FROM _incoming")
|
|
511
|
+
self._index_db.unregister("_incoming")
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def _payload_and_interval(sample: ChannelSample) -> tuple[Any, float | None]:
|
|
515
|
+
"""Split an array sample's ``{value, sample_interval}`` envelope.
|
|
516
|
+
|
|
517
|
+
``_normalize_value`` folds arrays into ``{"value": [...],
|
|
518
|
+
"sample_interval": dt}``, which is what rides on the live sample.
|
|
519
|
+
Segments store the array in the ``value`` column with
|
|
520
|
+
``sample_interval`` alongside, so the index must too — otherwise a
|
|
521
|
+
live-ingested array would encode differently from a disk-scanned one.
|
|
522
|
+
"""
|
|
523
|
+
v = sample.value
|
|
524
|
+
if (
|
|
525
|
+
isinstance(v, dict)
|
|
526
|
+
and set(v.keys()) == {"value", "sample_interval"}
|
|
527
|
+
and isinstance(v.get("value"), list)
|
|
528
|
+
):
|
|
529
|
+
return v["value"], v["sample_interval"]
|
|
530
|
+
return v, sample.sample_interval
|
|
531
|
+
|
|
532
|
+
def index_row(self, channel_id: str, sample: ChannelSample) -> dict[str, Any]:
|
|
533
|
+
"""Build one index row from a sample (``value`` JSON-encoded)."""
|
|
534
|
+
payload, interval = self._payload_and_interval(sample)
|
|
535
|
+
return {
|
|
536
|
+
"channel_id": channel_id,
|
|
537
|
+
"session_id": sample.session_id,
|
|
538
|
+
"received_at": sample.received_at,
|
|
539
|
+
"sampled_at": sample.sampled_at,
|
|
540
|
+
"source_method": sample.source_method or "",
|
|
541
|
+
"sample_interval": interval,
|
|
542
|
+
"value": encode_value(payload),
|
|
543
|
+
"sample_offset": sample.sample_offset,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
def extend_pending(self, rows: list[dict[str, Any]]) -> None:
|
|
547
|
+
"""Append index rows to the pending buffer; flush past the threshold."""
|
|
548
|
+
if not rows:
|
|
549
|
+
return
|
|
550
|
+
with self._pending_lock:
|
|
551
|
+
self._pending.extend(rows)
|
|
552
|
+
overflowed = len(self._pending) >= self._pending_threshold
|
|
553
|
+
if overflowed:
|
|
554
|
+
self._flush_pending()
|
|
555
|
+
|
|
556
|
+
def _flush_pending(self) -> None:
|
|
557
|
+
"""Move pending live rows into the index."""
|
|
558
|
+
with self._pending_lock:
|
|
559
|
+
if not self._pending:
|
|
560
|
+
return
|
|
561
|
+
pending = self._pending
|
|
562
|
+
self._pending = []
|
|
563
|
+
# Live rows land in the ephemeral overlay, NOT the durable index:
|
|
564
|
+
# their durable copy is the producer segment, folded into
|
|
565
|
+
# channel_index by the incremental scan on the next restart.
|
|
566
|
+
self._insert_index_rows(pending, "live.channel_live")
|
|
567
|
+
|
|
568
|
+
# ---- query ----
|
|
569
|
+
|
|
570
|
+
def query(
|
|
571
|
+
self,
|
|
572
|
+
channel_id: str,
|
|
573
|
+
*,
|
|
574
|
+
session_id: str | None,
|
|
575
|
+
start: datetime | None,
|
|
576
|
+
end: datetime | None,
|
|
577
|
+
last_n: int | None,
|
|
578
|
+
max_points: int | None,
|
|
579
|
+
sample_offset: int | None = None,
|
|
580
|
+
) -> pa.Table:
|
|
581
|
+
"""At-rest query served from the warm index (∪ pending buffer)."""
|
|
582
|
+
self._maybe_scan_disk()
|
|
583
|
+
self._flush_pending()
|
|
584
|
+
cur = self._cursor()
|
|
585
|
+
# Union the durable index with the live overlay (same columns). A sample
|
|
586
|
+
# lands in the overlay (push) and/or channel_index (segment scan), and the
|
|
587
|
+
# runtime fold can put it in both — _dedup_on_sample_offset below collapses the
|
|
588
|
+
# overlap on the per-sample cursor (session, sample_offset).
|
|
589
|
+
sql = [
|
|
590
|
+
"SELECT received_at, sampled_at, value, source_method, "
|
|
591
|
+
"session_id, sample_interval, sample_offset FROM ("
|
|
592
|
+
"SELECT * FROM channel_index UNION ALL SELECT * FROM live.channel_live"
|
|
593
|
+
") WHERE channel_id = ?"
|
|
594
|
+
]
|
|
595
|
+
params: list[Any] = [channel_id]
|
|
596
|
+
if session_id:
|
|
597
|
+
sql.append("AND left(session_id, 8) = left(?, 8)")
|
|
598
|
+
params.append(session_id)
|
|
599
|
+
start_utc = _to_utc(start)
|
|
600
|
+
end_utc = _to_utc(end)
|
|
601
|
+
if start_utc is not None:
|
|
602
|
+
sql.append("AND received_at >= ?")
|
|
603
|
+
params.append(start_utc)
|
|
604
|
+
if end_utc is not None:
|
|
605
|
+
sql.append("AND received_at <= ?")
|
|
606
|
+
params.append(end_utc)
|
|
607
|
+
if sample_offset is not None:
|
|
608
|
+
sql.append("AND sample_offset = ?")
|
|
609
|
+
params.append(sample_offset)
|
|
610
|
+
sql.append("ORDER BY received_at")
|
|
611
|
+
table = self._dedup_on_sample_offset(cur.execute(" ".join(sql), params).arrow().read_all())
|
|
612
|
+
|
|
613
|
+
if last_n is not None and table.num_rows > last_n:
|
|
614
|
+
table = table.slice(table.num_rows - last_n)
|
|
615
|
+
table = self._decode_value_column(table)
|
|
616
|
+
if max_points is not None and table.num_rows > max_points:
|
|
617
|
+
table = _decimate_table(table, max_points)
|
|
618
|
+
return table
|
|
619
|
+
|
|
620
|
+
@staticmethod
|
|
621
|
+
def _dedup_on_sample_offset(table: pa.Table) -> pa.Table:
|
|
622
|
+
"""Collapse overlay∪index overlap on the per-sample cursor (session, sample_offset).
|
|
623
|
+
|
|
624
|
+
The runtime segment fold can place a sample in both the durable index and
|
|
625
|
+
the live overlay; both copies are identical, so keep the first (the table
|
|
626
|
+
is already ordered by ``received_at``). A no-op when they don't overlap.
|
|
627
|
+
Rows with an unstamped ``sample_offset`` (< 0, legacy) are never collapsed.
|
|
628
|
+
"""
|
|
629
|
+
if table.num_rows == 0 or "sample_offset" not in table.column_names:
|
|
630
|
+
return table
|
|
631
|
+
sessions = table.column("session_id").to_pylist()
|
|
632
|
+
sample_offsets = table.column("sample_offset").to_pylist()
|
|
633
|
+
seen: set[tuple[Any, int]] = set()
|
|
634
|
+
keep: list[int] = []
|
|
635
|
+
for i, (s, o) in enumerate(zip(sessions, sample_offsets, strict=True)):
|
|
636
|
+
if o is not None and o >= 0:
|
|
637
|
+
key = (s, o)
|
|
638
|
+
if key in seen:
|
|
639
|
+
continue
|
|
640
|
+
seen.add(key)
|
|
641
|
+
keep.append(i)
|
|
642
|
+
return table if len(keep) == table.num_rows else table.take(keep)
|
|
643
|
+
|
|
644
|
+
@staticmethod
|
|
645
|
+
def _decode_value_column(table: pa.Table) -> pa.Table:
|
|
646
|
+
"""JSON-decode the VARCHAR ``value`` column back to typed values.
|
|
647
|
+
|
|
648
|
+
Inverse of ``encode_value``: non-JSON strings pass through (matches
|
|
649
|
+
``batch_row_to_sample``). Values within one channel are homogeneous,
|
|
650
|
+
so Arrow infers a single column type.
|
|
651
|
+
"""
|
|
652
|
+
if "value" not in table.column_names or table.num_rows == 0:
|
|
653
|
+
return table
|
|
654
|
+
decoded: list[Any] = []
|
|
655
|
+
for v in table.column("value").to_pylist():
|
|
656
|
+
if v is None:
|
|
657
|
+
decoded.append(None)
|
|
658
|
+
continue
|
|
659
|
+
try:
|
|
660
|
+
decoded.append(json.loads(v))
|
|
661
|
+
except (json.JSONDecodeError, TypeError):
|
|
662
|
+
decoded.append(v)
|
|
663
|
+
idx = table.column_names.index("value")
|
|
664
|
+
return table.set_column(idx, "value", pa.array(decoded))
|