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,768 @@
|
|
|
1
|
+
"""Storage-agnostic event API.
|
|
2
|
+
|
|
3
|
+
Callers use ``emit()``, ``events()``, ``on_event()`` — never see paths,
|
|
4
|
+
files, or SQL. Storage details (Arrow IPC files, DuckDB queries)
|
|
5
|
+
are internal.
|
|
6
|
+
|
|
7
|
+
Dual-write pattern: emit() writes Arrow IPC file (crash safety) and
|
|
8
|
+
pushes to in-memory DuckDB daemon via Flight (immediate queryability).
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from testerkit.data.event_store import EventStore
|
|
13
|
+
|
|
14
|
+
store = EventStore() # resolves storage location automatically
|
|
15
|
+
store.emit(some_event) # write + notify in-process subscribers
|
|
16
|
+
store.events(role="dmm") # query
|
|
17
|
+
unsub = store.on_event(cb) # catch-up subscription
|
|
18
|
+
unsub() # stop receiving
|
|
19
|
+
store.close()
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json as json_mod
|
|
25
|
+
import logging
|
|
26
|
+
import threading
|
|
27
|
+
import warnings
|
|
28
|
+
import weakref
|
|
29
|
+
from collections import OrderedDict
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
from uuid import UUID
|
|
35
|
+
|
|
36
|
+
import pyarrow as pa
|
|
37
|
+
import pyarrow.flight as flight
|
|
38
|
+
|
|
39
|
+
from testerkit.data import duckdb_manager
|
|
40
|
+
from testerkit.data._duckdb_flight_server import FlightPutStream
|
|
41
|
+
from testerkit.data._event_filters import event_matches_role
|
|
42
|
+
from testerkit.data._flight_query import FlightQueryClient
|
|
43
|
+
from testerkit.data._sql_helpers import sql_escape as _sql_escape
|
|
44
|
+
from testerkit.data.data_dir import resolve_data_dir
|
|
45
|
+
from testerkit.data.event_log import EventLog
|
|
46
|
+
from testerkit.data.events import EventBase
|
|
47
|
+
from testerkit.data.models import ensure_utc
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_event_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
"""Return the parsed event dict from a DB row, falling back to the raw row.
|
|
54
|
+
|
|
55
|
+
``writer_key`` and ``event_offset`` are column-level (the writer stamps
|
|
56
|
+
them at append; they are not inside the JSON payload), so merge them onto
|
|
57
|
+
the parsed event. Consumers order by ``(writer_key, event_offset)`` for
|
|
58
|
+
emit order — the order survives the do_put/ingest insert race.
|
|
59
|
+
"""
|
|
60
|
+
json_str = row.get("json")
|
|
61
|
+
if json_str:
|
|
62
|
+
try:
|
|
63
|
+
parsed = json_mod.loads(json_str)
|
|
64
|
+
for col in ("writer_key", "event_offset"):
|
|
65
|
+
if col in row:
|
|
66
|
+
parsed[col] = row[col]
|
|
67
|
+
return parsed
|
|
68
|
+
except (json_mod.JSONDecodeError, TypeError):
|
|
69
|
+
pass
|
|
70
|
+
return row
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_timestamp(ts: object) -> datetime | None:
|
|
74
|
+
"""Parse a timestamp value to a UTC-aware datetime. Returns None if unparseable."""
|
|
75
|
+
if isinstance(ts, datetime):
|
|
76
|
+
return ensure_utc(ts)
|
|
77
|
+
if isinstance(ts, str):
|
|
78
|
+
try:
|
|
79
|
+
return ensure_utc(datetime.fromisoformat(ts))
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_before(evt: dict, cutoff: datetime) -> bool:
|
|
86
|
+
"""Return True if the event's timestamp is before ``cutoff``."""
|
|
87
|
+
ts = evt.get("received_at") or evt.get("occurred_at")
|
|
88
|
+
if ts is None:
|
|
89
|
+
return False
|
|
90
|
+
parsed = _parse_timestamp(ts)
|
|
91
|
+
return parsed is not None and parsed < cutoff
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _finalize_event_store(put_stream, watcher_stop, event_logs):
|
|
95
|
+
"""Best-effort teardown for an EventStore that was never close()d.
|
|
96
|
+
Bound via weakref.finalize to the resource objects, never to self."""
|
|
97
|
+
watcher_stop.set() # the daemon watcher thread exits on its next poll
|
|
98
|
+
for log in list(event_logs.values()):
|
|
99
|
+
try:
|
|
100
|
+
log.close()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
event_logs.clear()
|
|
104
|
+
try:
|
|
105
|
+
put_stream.close()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _Subscription:
|
|
111
|
+
"""Internal subscription record."""
|
|
112
|
+
|
|
113
|
+
__slots__ = ("callback", "event_type", "role", "run_id", "session_id", "since")
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
callback: Callable[[dict], None],
|
|
118
|
+
*,
|
|
119
|
+
event_type: str | None = None,
|
|
120
|
+
role: str | None = None,
|
|
121
|
+
session_id: UUID | None = None,
|
|
122
|
+
run_id: UUID | None = None,
|
|
123
|
+
since: datetime | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.callback = callback
|
|
126
|
+
self.event_type = event_type
|
|
127
|
+
self.role = role
|
|
128
|
+
self.session_id = session_id
|
|
129
|
+
self.run_id = run_id
|
|
130
|
+
self.since = since
|
|
131
|
+
|
|
132
|
+
def matches(self, evt: dict) -> bool:
|
|
133
|
+
if self.event_type and evt.get("event_type") != self.event_type:
|
|
134
|
+
return False
|
|
135
|
+
if self.role and not event_matches_role(evt, self.role):
|
|
136
|
+
return False
|
|
137
|
+
if self.session_id and evt.get("session_id") != str(self.session_id):
|
|
138
|
+
return False
|
|
139
|
+
if self.run_id and evt.get("run_id") != str(self.run_id):
|
|
140
|
+
return False
|
|
141
|
+
if self.since and _is_before(evt, self.since):
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class EventStore:
|
|
147
|
+
"""Storage-agnostic event API. Callers never see paths, files, or SQL.
|
|
148
|
+
|
|
149
|
+
Construct once and reuse. ``close()`` / ``with`` are optional — the
|
|
150
|
+
daemon is a separate process that self-manages via PID-ref and idle
|
|
151
|
+
timeout. Use ``get_shared()`` to share the watcher thread across callers
|
|
152
|
+
in the same process.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
_shared: dict[Path, EventStore] = {}
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def get_shared(cls, data_dir: Path | None = None) -> EventStore:
|
|
159
|
+
"""Return a process-wide shared instance for ``data_dir``.
|
|
160
|
+
|
|
161
|
+
Multiple callers for the same directory share one watcher thread
|
|
162
|
+
instead of each spawning their own. UI page handlers should use
|
|
163
|
+
this instead of ``EventStore(...)`` so the thread count stays flat.
|
|
164
|
+
"""
|
|
165
|
+
key = resolve_data_dir(data_dir)
|
|
166
|
+
if key not in cls._shared:
|
|
167
|
+
cls._shared[key] = cls(_data_dir=key)
|
|
168
|
+
return cls._shared[key]
|
|
169
|
+
|
|
170
|
+
def __init__(self, *, _data_dir: Path | str | None = None) -> None:
|
|
171
|
+
self._data_dir = resolve_data_dir(_data_dir)
|
|
172
|
+
self._events_dir = self._data_dir / "events"
|
|
173
|
+
self._events_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
|
|
175
|
+
# Start daemon and get gRPC location for Flight queries
|
|
176
|
+
self._location = duckdb_manager.acquire(self._events_dir)
|
|
177
|
+
|
|
178
|
+
# Capture as a local so the reacquire lambdas close over a Path, not
|
|
179
|
+
# self — breaking the reference cycle that would prevent GC from
|
|
180
|
+
# collecting this EventStore when the user forgets to call close().
|
|
181
|
+
_events_dir = self._events_dir
|
|
182
|
+
|
|
183
|
+
# Flight query client (shared retry logic with RunStore)
|
|
184
|
+
self._flight = FlightQueryClient(
|
|
185
|
+
self._location,
|
|
186
|
+
"events",
|
|
187
|
+
reacquire=lambda: duckdb_manager.acquire(_events_dir),
|
|
188
|
+
label="EventStore",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Persistent do_put stream (for writes). Same reacquire as the query
|
|
192
|
+
# client so the write path self-heals across a daemon restart: the
|
|
193
|
+
# stream reacquires a fresh daemon and resends un-acked batches
|
|
194
|
+
# (idempotent — the events insert is ON CONFLICT (id) DO NOTHING).
|
|
195
|
+
self._put_stream = FlightPutStream(
|
|
196
|
+
self._location,
|
|
197
|
+
"events",
|
|
198
|
+
"events",
|
|
199
|
+
reacquire=lambda: duckdb_manager.acquire(_events_dir),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Internal writer per session (created lazily via get_event_log)
|
|
203
|
+
self._event_logs: dict[UUID, EventLog] = {}
|
|
204
|
+
|
|
205
|
+
# In-process subscriptions
|
|
206
|
+
self._subscriptions: list[_Subscription] = []
|
|
207
|
+
self._lock = threading.RLock()
|
|
208
|
+
|
|
209
|
+
# Track event IDs delivered in-process to avoid duplicate delivery
|
|
210
|
+
# from the cross-process watcher. Bounded LRU (FIFO eviction) so
|
|
211
|
+
# long-running orchestrators don't leak memory; the watcher receives a
|
|
212
|
+
# held-open push stream from an ``event_number`` cursor, so old IDs are
|
|
213
|
+
# never re-delivered and don't need to be retained beyond the window.
|
|
214
|
+
self._delivered_ids: OrderedDict[str, None] = OrderedDict()
|
|
215
|
+
self._delivered_ids_max = 10000
|
|
216
|
+
|
|
217
|
+
# Cross-process watcher
|
|
218
|
+
self._watcher_thread: threading.Thread | None = None
|
|
219
|
+
self._watcher_stop = threading.Event()
|
|
220
|
+
|
|
221
|
+
# weakref.finalize fires on GC OR interpreter exit, so the lazy notebook
|
|
222
|
+
# path (never close()) is covered without a separate atexit.
|
|
223
|
+
self._finalizer = weakref.finalize(
|
|
224
|
+
self, _finalize_event_store, self._put_stream, self._watcher_stop, self._event_logs
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _flight_query(self, sql: str) -> list[dict[str, Any]]:
|
|
228
|
+
"""Execute a SQL query via Flight and return list of dicts.
|
|
229
|
+
|
|
230
|
+
Retry policy lives in :class:`FlightQueryClient` —
|
|
231
|
+
TRANSIENT errors retry with exponential backoff, PERMANENT
|
|
232
|
+
errors (Binder, Catalog, syntax) raise immediately as
|
|
233
|
+
:class:`FlightPermanentError`.
|
|
234
|
+
"""
|
|
235
|
+
return self._flight.query(sql)
|
|
236
|
+
|
|
237
|
+
def _flight_put(self, batch: pa.RecordBatch) -> None:
|
|
238
|
+
"""Push an Arrow batch to the daemon via persistent do_put stream.
|
|
239
|
+
|
|
240
|
+
Does not block for server confirmation — call ``_drain_puts()``
|
|
241
|
+
before querying for read-after-write consistency.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
self._put_stream.write(batch)
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
# Non-fatal: data is in IPC file, daemon will rebuild on restart
|
|
247
|
+
warnings.warn(f"Flight put failed (non-fatal): {exc}", stacklevel=2)
|
|
248
|
+
|
|
249
|
+
# -- Write path ----------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _dispatch_to_subscribers(self, evt_dict: dict) -> None:
|
|
252
|
+
"""Dispatch an event dict to matching subscribers (caller holds lock)."""
|
|
253
|
+
for sub in self._subscriptions:
|
|
254
|
+
if sub.matches(evt_dict):
|
|
255
|
+
try:
|
|
256
|
+
sub.callback(evt_dict)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
warnings.warn(
|
|
259
|
+
f"Event subscriber failed: {exc}",
|
|
260
|
+
stacklevel=2,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _notify_subscribers(self, event: EventBase) -> None:
|
|
264
|
+
"""Notify in-process subscribers about a new event."""
|
|
265
|
+
evt_dict = event.model_dump(mode="json")
|
|
266
|
+
event_id = str(event.id)
|
|
267
|
+
with self._lock:
|
|
268
|
+
self._delivered_ids[event_id] = None
|
|
269
|
+
while len(self._delivered_ids) > self._delivered_ids_max:
|
|
270
|
+
self._delivered_ids.popitem(last=False)
|
|
271
|
+
self._dispatch_to_subscribers(evt_dict)
|
|
272
|
+
|
|
273
|
+
def get_event_log(self, session_id: UUID) -> EventLog:
|
|
274
|
+
"""Get or create an EventLog for a session.
|
|
275
|
+
|
|
276
|
+
The returned EventLog is wired to notify this store's subscribers
|
|
277
|
+
on every emit, bridging per-session writes to store-level subscriptions.
|
|
278
|
+
"""
|
|
279
|
+
if session_id not in self._event_logs:
|
|
280
|
+
self._event_logs[session_id] = EventLog(
|
|
281
|
+
self._events_dir,
|
|
282
|
+
session_id,
|
|
283
|
+
on_emit=self._notify_subscribers,
|
|
284
|
+
on_flush=self._flight_put,
|
|
285
|
+
)
|
|
286
|
+
return self._event_logs[session_id]
|
|
287
|
+
|
|
288
|
+
def emit(self, event: EventBase, *, session_id: UUID | None = None) -> None:
|
|
289
|
+
"""Buffer event for batched IPC write, push to DuckDB on flush.
|
|
290
|
+
|
|
291
|
+
Dual-write: Arrow IPC file for crash safety, Flight do_put for
|
|
292
|
+
queryability. Both writes are batched at flush threshold — same
|
|
293
|
+
pattern as ChannelStore.
|
|
294
|
+
"""
|
|
295
|
+
sid = session_id or getattr(event, "session_id", None)
|
|
296
|
+
if sid is None:
|
|
297
|
+
raise ValueError("Event must have session_id or pass session_id to emit()")
|
|
298
|
+
|
|
299
|
+
log = self.get_event_log(sid)
|
|
300
|
+
log.emit(event)
|
|
301
|
+
|
|
302
|
+
# Note: _notify_subscribers is called by EventLog.on_emit callback,
|
|
303
|
+
# and _flight_put is called by EventLog.on_flush callback.
|
|
304
|
+
|
|
305
|
+
def flush(self) -> None:
|
|
306
|
+
"""Flush all buffered events to IPC files and Flight.
|
|
307
|
+
|
|
308
|
+
Call this after emitting events that must be visible to other
|
|
309
|
+
processes immediately (e.g., sync events). Drains the
|
|
310
|
+
persistent do_put stream too so the events daemon has acked
|
|
311
|
+
every batch before flush returns — without that drain, IPC is
|
|
312
|
+
on disk but the events daemon's table may not yet contain the
|
|
313
|
+
rows that subsequent cross-process queries / subscribers
|
|
314
|
+
depend on.
|
|
315
|
+
"""
|
|
316
|
+
for log in list(self._event_logs.values()):
|
|
317
|
+
log.flush()
|
|
318
|
+
try:
|
|
319
|
+
self._put_stream.drain()
|
|
320
|
+
except Exception as exc: # noqa: BLE001 — drain is best-effort; data is already in IPC
|
|
321
|
+
logger.debug("put-stream drain failed (non-fatal): %s", exc)
|
|
322
|
+
|
|
323
|
+
# -- Read path -----------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def events(
|
|
326
|
+
self,
|
|
327
|
+
*,
|
|
328
|
+
session_id: UUID | None = None,
|
|
329
|
+
run_id: UUID | None = None,
|
|
330
|
+
event_type: str | None = None,
|
|
331
|
+
role: str | None = None,
|
|
332
|
+
since: datetime | None = None,
|
|
333
|
+
until: str | None = None,
|
|
334
|
+
until_event_number: int | None = None,
|
|
335
|
+
limit: int | None = None,
|
|
336
|
+
) -> list[dict]:
|
|
337
|
+
"""Query events from the DuckDB index via Flight.
|
|
338
|
+
|
|
339
|
+
``limit`` pushes the row cap into the SQL so the daemon
|
|
340
|
+
returns at most ``limit`` rows instead of streaming the
|
|
341
|
+
full event log over Flight. Critical for projects with
|
|
342
|
+
large IPC histories — without it, even a "show me the
|
|
343
|
+
latest 100 events" page pulls millions of rows.
|
|
344
|
+
|
|
345
|
+
``limit`` is applied to the **most recent** rows (the SQL
|
|
346
|
+
sorts ``received_at DESC`` under the limit, then re-sorts
|
|
347
|
+
ASC for the caller). ``None`` returns all matching rows.
|
|
348
|
+
"""
|
|
349
|
+
# Flush any buffered events to IPC + Flight before querying
|
|
350
|
+
# (on_flush callback pushes batches to Flight automatically)
|
|
351
|
+
for log in list(self._event_logs.values()):
|
|
352
|
+
log.flush()
|
|
353
|
+
try:
|
|
354
|
+
self._put_stream.drain()
|
|
355
|
+
except Exception as exc: # noqa: BLE001
|
|
356
|
+
logger.debug("put-stream drain failed (non-fatal): %s", exc)
|
|
357
|
+
# Build SQL via f-string — safe because inputs are typed:
|
|
358
|
+
# session_id is UUID (validated by caller), event_type is a known
|
|
359
|
+
# enum string, since is a datetime. sql_escape guards against quotes.
|
|
360
|
+
# Flight do_get does not support parameterized queries.
|
|
361
|
+
conditions: list[str] = []
|
|
362
|
+
if session_id:
|
|
363
|
+
conditions.append(f"session_id = '{_sql_escape(str(session_id))}'")
|
|
364
|
+
if run_id:
|
|
365
|
+
conditions.append(f"run_id = '{_sql_escape(str(run_id))}'")
|
|
366
|
+
if event_type:
|
|
367
|
+
conditions.append(f"event_type = '{_sql_escape(event_type)}'")
|
|
368
|
+
if since:
|
|
369
|
+
conditions.append(f"received_at >= '{_sql_escape(since.isoformat())}'")
|
|
370
|
+
if until:
|
|
371
|
+
conditions.append(f"received_at <= '{_sql_escape(until)}'")
|
|
372
|
+
if until_event_number is not None:
|
|
373
|
+
conditions.append(f"event_number <= {int(until_event_number)}")
|
|
374
|
+
if role:
|
|
375
|
+
# Mirrors :func:`event_matches_role` in SQL: ``role``,
|
|
376
|
+
# ``instrument_role``, and the ``channel_id`` prefix all
|
|
377
|
+
# qualify. All three columns are promoted typed columns
|
|
378
|
+
# so the daemon plans an index/columnar scan on each
|
|
379
|
+
# branch — no per-row Python parse.
|
|
380
|
+
r = _sql_escape(role)
|
|
381
|
+
conditions.append(
|
|
382
|
+
f"(role = '{r}' OR instrument_role = '{r}' OR channel_id LIKE '{r}.%')"
|
|
383
|
+
)
|
|
384
|
+
where = f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
385
|
+
|
|
386
|
+
# Narrow projection — the typed payload columns exist purely
|
|
387
|
+
# to push down WHERE filters; their values are duplicates of
|
|
388
|
+
# fields inside ``json``, and parsing JSON reconstitutes the
|
|
389
|
+
# full event. ``SELECT *`` would ship every typed column
|
|
390
|
+
# back over Flight (mostly NULL) and inflate row size by 22
|
|
391
|
+
# extra strings. Envelope-only keeps the network payload
|
|
392
|
+
# constant regardless of how many typed columns we add later.
|
|
393
|
+
projection = (
|
|
394
|
+
"id, event_type, event_number, occurred_at, received_at, "
|
|
395
|
+
"session_id, run_id, writer_key, event_offset, json"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Latest-N pushdown: SQL sorts received_at DESC under the
|
|
399
|
+
# LIMIT (cheap with the index on received_at), then re-sorts
|
|
400
|
+
# ASC in the outer SELECT so the caller still sees
|
|
401
|
+
# chronological order. Without this, "show me the last 100
|
|
402
|
+
# events" pulls every event over Flight.
|
|
403
|
+
if limit is not None and limit > 0:
|
|
404
|
+
sql = f"""
|
|
405
|
+
SELECT * FROM (
|
|
406
|
+
SELECT {projection}
|
|
407
|
+
FROM events
|
|
408
|
+
{where}
|
|
409
|
+
ORDER BY received_at DESC
|
|
410
|
+
LIMIT {int(limit)}
|
|
411
|
+
)
|
|
412
|
+
ORDER BY received_at ASC
|
|
413
|
+
"""
|
|
414
|
+
else:
|
|
415
|
+
sql = f"""
|
|
416
|
+
SELECT {projection}
|
|
417
|
+
FROM events
|
|
418
|
+
{where}
|
|
419
|
+
ORDER BY received_at ASC
|
|
420
|
+
"""
|
|
421
|
+
rows = self._flight_query(sql)
|
|
422
|
+
|
|
423
|
+
return [_parse_event_row(row) for row in rows]
|
|
424
|
+
|
|
425
|
+
def sessions(self) -> list[dict]:
|
|
426
|
+
"""List known sessions with metadata from SessionStarted events."""
|
|
427
|
+
return self.events(event_type="session.started")
|
|
428
|
+
|
|
429
|
+
def events_for_unmaterialized_runs(
|
|
430
|
+
self,
|
|
431
|
+
*,
|
|
432
|
+
since: datetime | None = None,
|
|
433
|
+
until: str | None = None,
|
|
434
|
+
until_event_number: int | None = None,
|
|
435
|
+
) -> list[dict]:
|
|
436
|
+
"""Return events for runs that have ``RunStarted`` but no ``RunMaterialized``.
|
|
437
|
+
|
|
438
|
+
The dual of "runs whose derived view is durable in a query-optimized
|
|
439
|
+
backend." A run is either:
|
|
440
|
+
|
|
441
|
+
* **Materialized** — a ``RunMaterialized`` event exists. The run is
|
|
442
|
+
durably stored somewhere (today: parquet ingested into the runs
|
|
443
|
+
daemon's ``runs_materialized`` table; future: Postgres, Snowflake,
|
|
444
|
+
etc.). The materializer pool no longer needs to track it.
|
|
445
|
+
* **Unmaterialized** — no ``RunMaterialized`` yet. The run's events
|
|
446
|
+
are persisted in the EventStore but no derived view exists. The
|
|
447
|
+
materializer pool tracks it via per-run accumulator state.
|
|
448
|
+
|
|
449
|
+
Called by the runs daemon on attach to replay the unmaterialized set
|
|
450
|
+
into its in-memory accumulator pool. The replay set is naturally
|
|
451
|
+
bounded by the rate at which runs are materialized; in production
|
|
452
|
+
steady state, it's the count of currently-in-flight runs plus a
|
|
453
|
+
small tail of just-finished runs awaiting materialization.
|
|
454
|
+
|
|
455
|
+
``since`` further bounds the result to events received after that
|
|
456
|
+
timestamp.
|
|
457
|
+
"""
|
|
458
|
+
for log in list(self._event_logs.values()):
|
|
459
|
+
log.flush()
|
|
460
|
+
try:
|
|
461
|
+
self._put_stream.drain()
|
|
462
|
+
except Exception as exc: # noqa: BLE001
|
|
463
|
+
logger.debug("put-stream drain failed (non-fatal): %s", exc)
|
|
464
|
+
clauses: list[str] = []
|
|
465
|
+
if since:
|
|
466
|
+
clauses.append(f"received_at >= '{_sql_escape(since.isoformat())}'")
|
|
467
|
+
if until:
|
|
468
|
+
clauses.append(f"received_at <= '{_sql_escape(until)}'")
|
|
469
|
+
if until_event_number is not None:
|
|
470
|
+
clauses.append(f"event_number <= {int(until_event_number)}")
|
|
471
|
+
where_extra = (" AND " + " AND ".join(clauses)) if clauses else ""
|
|
472
|
+
# Envelope-only projection — see ``events()`` for the rationale.
|
|
473
|
+
sql = f"""
|
|
474
|
+
SELECT id, event_type, event_number, occurred_at, received_at,
|
|
475
|
+
session_id, run_id, writer_key, event_offset, json
|
|
476
|
+
FROM events
|
|
477
|
+
WHERE run_id IS NOT NULL
|
|
478
|
+
AND run_id IN (
|
|
479
|
+
SELECT DISTINCT run_id FROM events WHERE event_type = 'run.started'
|
|
480
|
+
EXCEPT
|
|
481
|
+
SELECT DISTINCT run_id FROM events WHERE event_type = 'run.materialized'
|
|
482
|
+
){where_extra}
|
|
483
|
+
ORDER BY writer_key, event_offset
|
|
484
|
+
"""
|
|
485
|
+
rows = self._flight_query(sql)
|
|
486
|
+
return [_parse_event_row(row) for row in rows]
|
|
487
|
+
|
|
488
|
+
# -- Watch path ----------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def on_event(
|
|
491
|
+
self,
|
|
492
|
+
callback: Callable[[dict], None],
|
|
493
|
+
*,
|
|
494
|
+
event_type: str | None = None,
|
|
495
|
+
role: str | None = None,
|
|
496
|
+
session_id: UUID | None = None,
|
|
497
|
+
run_id: UUID | None = None,
|
|
498
|
+
since: datetime | None = None,
|
|
499
|
+
replay: str = "matching",
|
|
500
|
+
) -> Callable[[], None]:
|
|
501
|
+
"""Catch-up subscription.
|
|
502
|
+
|
|
503
|
+
Replays past events on attach, then pushes new ones as they arrive.
|
|
504
|
+
|
|
505
|
+
``replay`` controls the catch-up strategy:
|
|
506
|
+
|
|
507
|
+
* ``"matching"`` (default) — replay every event matching the
|
|
508
|
+
``event_type`` / ``role`` / ``session_id`` / ``since`` filters.
|
|
509
|
+
* ``"unmaterialized_runs"`` — replay events for runs with
|
|
510
|
+
``RunStarted`` but no ``RunMaterialized``. The materializer's
|
|
511
|
+
replay set: every run still tracked in-memory because its
|
|
512
|
+
derived view hasn't been written yet. See
|
|
513
|
+
:meth:`events_for_unmaterialized_runs`.
|
|
514
|
+
* ``"none"`` — skip replay entirely; deliver only future events.
|
|
515
|
+
|
|
516
|
+
In-process: instant dispatch on ``emit()``.
|
|
517
|
+
Cross-process: a held-open push stream delivers new events as they land.
|
|
518
|
+
|
|
519
|
+
Returns an unsubscribe callable.
|
|
520
|
+
"""
|
|
521
|
+
# Capture the watcher's cursor BEFORE running replay. The cursor
|
|
522
|
+
# is a monotonic ``event_number`` (insert-order sequence stamped
|
|
523
|
+
# by the events daemon's ``nextval('event_seq')`` under the
|
|
524
|
+
# same lock as the INSERT). Replay covers
|
|
525
|
+
# ``event_number <= snapshot``; the watcher receives
|
|
526
|
+
# ``event_number > snapshot`` live. ``event_number`` is bulletproof
|
|
527
|
+
# against the wall-clock race ``received_at`` had — two
|
|
528
|
+
# put-hook batches under the same lock can finish out of order
|
|
529
|
+
# vs. their transaction-start timestamps, but ``nextval()``
|
|
530
|
+
# advances under the lock and is strictly monotonic with
|
|
531
|
+
# commit order.
|
|
532
|
+
try:
|
|
533
|
+
cursor_rows = self._flight_query("SELECT MAX(event_number) AS m FROM events")
|
|
534
|
+
cursor_event_number = (
|
|
535
|
+
int(cursor_rows[0]["m"]) if cursor_rows and cursor_rows[0].get("m") else 0
|
|
536
|
+
)
|
|
537
|
+
except Exception as exc: # noqa: BLE001
|
|
538
|
+
logger.debug("Cursor snapshot before replay failed: %s", exc)
|
|
539
|
+
cursor_event_number = 0
|
|
540
|
+
|
|
541
|
+
# Register the subscription and start the watcher BEFORE running
|
|
542
|
+
# replay. New events arriving from the cursor onwards flow through
|
|
543
|
+
# the watcher into the callback immediately. Historical (pre-cursor)
|
|
544
|
+
# events flow through replay in a background thread.
|
|
545
|
+
#
|
|
546
|
+
# Why async replay: when the events DB has accumulated many
|
|
547
|
+
# unmaterialized runs (e.g., a long-running dev environment with
|
|
548
|
+
# historical test cruft), synchronous replay can take seconds-to-
|
|
549
|
+
# minutes before the watcher gets started. New events emitted
|
|
550
|
+
# during that window aren't visible to the subscriber until
|
|
551
|
+
# replay completes — which can cause test timeouts and operator
|
|
552
|
+
# UI staleness on cold daemon start. Decoupling lets the live
|
|
553
|
+
# path keep up with real-time events while the materializer
|
|
554
|
+
# catches up on backlog in the background.
|
|
555
|
+
#
|
|
556
|
+
# ``_delivered_ids`` deduplicates between the two paths: each
|
|
557
|
+
# dispatched event id is stamped, and both paths check before
|
|
558
|
+
# delivering, so each event reaches the callback exactly once
|
|
559
|
+
# regardless of which path saw it first.
|
|
560
|
+
sub = _Subscription(
|
|
561
|
+
callback,
|
|
562
|
+
event_type=event_type,
|
|
563
|
+
role=role,
|
|
564
|
+
session_id=session_id,
|
|
565
|
+
run_id=run_id,
|
|
566
|
+
since=since,
|
|
567
|
+
)
|
|
568
|
+
with self._lock:
|
|
569
|
+
self._subscriptions.append(sub)
|
|
570
|
+
|
|
571
|
+
self._ensure_watcher(initial_cursor=cursor_event_number)
|
|
572
|
+
|
|
573
|
+
if replay != "none":
|
|
574
|
+
# Replay covers event_number <= cursor (inclusive); watcher
|
|
575
|
+
# covers event_number > cursor. No boundary overlap (replay
|
|
576
|
+
# is ``<=``, watcher is ``>``) — ``_delivered_ids`` still
|
|
577
|
+
# dedups any in-process emit that raced replay.
|
|
578
|
+
replay_until_event_number = cursor_event_number
|
|
579
|
+
|
|
580
|
+
def _replay_in_background() -> None:
|
|
581
|
+
if replay == "unmaterialized_runs":
|
|
582
|
+
existing = self.events_for_unmaterialized_runs(
|
|
583
|
+
until_event_number=replay_until_event_number,
|
|
584
|
+
)
|
|
585
|
+
else:
|
|
586
|
+
existing = self.events(
|
|
587
|
+
session_id=session_id,
|
|
588
|
+
run_id=run_id,
|
|
589
|
+
event_type=event_type,
|
|
590
|
+
role=role,
|
|
591
|
+
since=since,
|
|
592
|
+
until_event_number=replay_until_event_number,
|
|
593
|
+
)
|
|
594
|
+
# Dispatch each event to the new subscriber's callback,
|
|
595
|
+
# then stamp ``_delivered_ids`` so the watcher dedups
|
|
596
|
+
# against the boundary on the first pushed batch. Mirrors the
|
|
597
|
+
# sync-replay behaviour, just in a background thread so
|
|
598
|
+
# the watcher can serve real-time events concurrently.
|
|
599
|
+
for evt in existing:
|
|
600
|
+
try:
|
|
601
|
+
callback(evt)
|
|
602
|
+
evt_id = str(evt.get("id") or "")
|
|
603
|
+
if evt_id:
|
|
604
|
+
with self._lock:
|
|
605
|
+
self._delivered_ids[evt_id] = None
|
|
606
|
+
while len(self._delivered_ids) > self._delivered_ids_max:
|
|
607
|
+
self._delivered_ids.popitem(last=False)
|
|
608
|
+
except Exception as exc: # noqa: BLE001
|
|
609
|
+
warnings.warn(
|
|
610
|
+
f"Event subscriber failed during replay: {exc}",
|
|
611
|
+
stacklevel=2,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
threading.Thread(
|
|
615
|
+
target=_replay_in_background,
|
|
616
|
+
daemon=True,
|
|
617
|
+
name=f"event-replay-{replay}",
|
|
618
|
+
).start()
|
|
619
|
+
|
|
620
|
+
def unsubscribe() -> None:
|
|
621
|
+
with self._lock:
|
|
622
|
+
try:
|
|
623
|
+
self._subscriptions.remove(sub)
|
|
624
|
+
except ValueError:
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
return unsubscribe
|
|
628
|
+
|
|
629
|
+
def _ensure_watcher(self, *, initial_cursor: int = 0) -> None:
|
|
630
|
+
"""Start the cross-process file watcher thread if not already running.
|
|
631
|
+
|
|
632
|
+
``initial_cursor`` seeds ``_watch_loop`` to a known
|
|
633
|
+
``event_number`` from the caller — the monotonic insert-order
|
|
634
|
+
sequence stamped by the events daemon under the put-hook lock.
|
|
635
|
+
The watcher streams ``WHERE event_number > initial_cursor`` so
|
|
636
|
+
any event with a higher position is picked up. Position values
|
|
637
|
+
are strictly monotonic with commit order, so there's no
|
|
638
|
+
boundary race against wall-clock ordering.
|
|
639
|
+
"""
|
|
640
|
+
if self._watcher_thread is not None and self._watcher_thread.is_alive():
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
self._watcher_stop.clear()
|
|
644
|
+
self._watcher_thread = threading.Thread(
|
|
645
|
+
target=self._watch_loop,
|
|
646
|
+
args=(initial_cursor,),
|
|
647
|
+
daemon=True,
|
|
648
|
+
name="testerkit-event-watcher",
|
|
649
|
+
)
|
|
650
|
+
self._watcher_thread.start()
|
|
651
|
+
|
|
652
|
+
def _watch_loop(self, initial_cursor: int = 0) -> None:
|
|
653
|
+
"""Receive cross-process events via a held-open push stream (no poll).
|
|
654
|
+
|
|
655
|
+
Opens a ``__SUBSCRIBE__`` do_get on the events daemon: the server
|
|
656
|
+
replays every row past the cursor from the warm index, then pushes
|
|
657
|
+
each new row as it lands — the blocking iterator is woken by the
|
|
658
|
+
server, so there is no 500ms floor.
|
|
659
|
+
|
|
660
|
+
Each delivered row carries ``event_number``; the highest seen is
|
|
661
|
+
the reconnection cursor. If the daemon dies or restarts, the
|
|
662
|
+
reader re-acquires the daemon location and re-subscribes from that
|
|
663
|
+
cursor — the server's replay fills the downtime gap, so delivery
|
|
664
|
+
is lossless across restarts (overlap deduped by ``_delivered_ids``).
|
|
665
|
+
|
|
666
|
+
``initial_cursor`` is the ``on_event`` snapshot: history at or
|
|
667
|
+
below it is delivered by the background replay there; this stream
|
|
668
|
+
carries everything strictly past it.
|
|
669
|
+
"""
|
|
670
|
+
# Ticket: ``events\0__SUBSCRIBE__\0cursor=<n>`` — see DuckDBFlightServer.
|
|
671
|
+
last_event_number: int = initial_cursor
|
|
672
|
+
location = self._location
|
|
673
|
+
backoff = 0.1
|
|
674
|
+
while not self._watcher_stop.is_set():
|
|
675
|
+
client: flight.FlightClient | None = None
|
|
676
|
+
try:
|
|
677
|
+
conn = flight.connect(location)
|
|
678
|
+
client = conn # retained for the finally-block close
|
|
679
|
+
ticket = flight.Ticket(
|
|
680
|
+
f"events\0__SUBSCRIBE__\0cursor={int(last_event_number)}".encode()
|
|
681
|
+
)
|
|
682
|
+
reader = conn.do_get(ticket)
|
|
683
|
+
backoff = 0.1 # connected — reset backoff
|
|
684
|
+
for chunk in reader:
|
|
685
|
+
if self._watcher_stop.is_set():
|
|
686
|
+
break
|
|
687
|
+
for row in chunk.data.to_pylist():
|
|
688
|
+
last_event_number = self._deliver_watched_row(row, last_event_number)
|
|
689
|
+
except Exception as exc: # noqa: BLE001 — reconnect on any stream error
|
|
690
|
+
if self._watcher_stop.is_set():
|
|
691
|
+
break
|
|
692
|
+
logger.debug("Event subscription dropped (will reconnect): %s", exc)
|
|
693
|
+
try:
|
|
694
|
+
location = duckdb_manager.acquire(self._events_dir)
|
|
695
|
+
except Exception as racq: # noqa: BLE001 — keep old location, retry
|
|
696
|
+
logger.debug("Re-acquire events daemon failed: %s", racq)
|
|
697
|
+
self._watcher_stop.wait(timeout=backoff)
|
|
698
|
+
backoff = min(backoff * 2, 5.0)
|
|
699
|
+
finally:
|
|
700
|
+
if client is not None:
|
|
701
|
+
try:
|
|
702
|
+
client.close()
|
|
703
|
+
except Exception: # noqa: BLE001 — best-effort close
|
|
704
|
+
pass
|
|
705
|
+
|
|
706
|
+
def _deliver_watched_row(self, row: dict, last_event_number: int) -> int:
|
|
707
|
+
"""Dispatch one pushed/replayed event row; return the advanced cursor.
|
|
708
|
+
|
|
709
|
+
Deduped against in-process delivery (``_notify_subscribers``) and
|
|
710
|
+
replay by event id, so each event reaches subscribers once.
|
|
711
|
+
"""
|
|
712
|
+
evt = _parse_event_row(row)
|
|
713
|
+
event_id = str(evt.get("id") or row.get("id", ""))
|
|
714
|
+
try:
|
|
715
|
+
with self._lock:
|
|
716
|
+
if event_id not in self._delivered_ids:
|
|
717
|
+
self._delivered_ids[event_id] = None
|
|
718
|
+
while len(self._delivered_ids) > self._delivered_ids_max:
|
|
719
|
+
self._delivered_ids.popitem(last=False)
|
|
720
|
+
self._dispatch_to_subscribers(evt)
|
|
721
|
+
except Exception as exc: # noqa: BLE001 — never let one bad dispatch kill the watcher
|
|
722
|
+
logger.debug("Watcher dispatch failed for event id=%s: %s", event_id, exc)
|
|
723
|
+
en = row.get("event_number")
|
|
724
|
+
if en is not None:
|
|
725
|
+
return max(last_event_number, int(en))
|
|
726
|
+
return last_event_number
|
|
727
|
+
|
|
728
|
+
# -- Lifecycle -----------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
def close(self) -> None:
|
|
731
|
+
"""Stop watchers, release resources. Safe to call multiple times."""
|
|
732
|
+
self._finalizer.detach()
|
|
733
|
+
self._watcher_stop.set()
|
|
734
|
+
if self._watcher_thread is not None:
|
|
735
|
+
self._watcher_thread.join(timeout=2.0)
|
|
736
|
+
self._watcher_thread = None
|
|
737
|
+
|
|
738
|
+
# Close event logs — their on_flush callback pushes final batches to Flight
|
|
739
|
+
for log in list(self._event_logs.values()):
|
|
740
|
+
try:
|
|
741
|
+
log.close()
|
|
742
|
+
except Exception as exc:
|
|
743
|
+
warnings.warn(f"EventLog close failed: {exc}", stacklevel=2)
|
|
744
|
+
self._event_logs.clear()
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
self._put_stream.close()
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
warnings.warn(f"FlightPutStream close failed: {exc}", stacklevel=2)
|
|
750
|
+
|
|
751
|
+
with self._lock:
|
|
752
|
+
self._subscriptions.clear()
|
|
753
|
+
self._delivered_ids.clear()
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
self._flight.close()
|
|
757
|
+
except Exception as exc:
|
|
758
|
+
warnings.warn(f"FlightQueryClient close failed: {exc}", stacklevel=2)
|
|
759
|
+
try:
|
|
760
|
+
duckdb_manager.release(self._events_dir)
|
|
761
|
+
except Exception as exc:
|
|
762
|
+
warnings.warn(f"duckdb_manager.release failed: {exc}", stacklevel=2)
|
|
763
|
+
|
|
764
|
+
def __enter__(self) -> EventStore:
|
|
765
|
+
return self
|
|
766
|
+
|
|
767
|
+
def __exit__(self, *_: object) -> None:
|
|
768
|
+
self.close()
|