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,535 @@
|
|
|
1
|
+
"""Ref-counted daemon lifecycle base class.
|
|
2
|
+
|
|
3
|
+
``DaemonManager`` handles the common pattern shared by ``duckdb_manager``
|
|
4
|
+
and ``flight_manager``:
|
|
5
|
+
|
|
6
|
+
- Acquire / release with PID-list state files and file locks
|
|
7
|
+
- Detached process spawning with readiness polling
|
|
8
|
+
- atexit / signal cleanup
|
|
9
|
+
- Daemon-side ref monitoring with idle timeout
|
|
10
|
+
|
|
11
|
+
Subclasses set class-level file names and override ``_spawn_cmd()``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import atexit
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import signal
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import warnings
|
|
26
|
+
import zlib
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from filelock import FileLock
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# 300s (5 min) so daemons survive routine UI page switches. The
|
|
35
|
+
# previous 10s was timed to die exactly between operator
|
|
36
|
+
# navigations, causing every page click to wait for a fresh spawn
|
|
37
|
+
# (~1s python startup + parquet rediscovery, observed as ~10s
|
|
38
|
+
# loads with multi-daemon pages). Override via env var if a
|
|
39
|
+
# constrained dev environment needs more aggressive shutdown.
|
|
40
|
+
_IDLE_TIMEOUT = int(os.environ.get("TESTERKIT_DAEMON_IDLE_TIMEOUT", "300"))
|
|
41
|
+
_POLL_INTERVAL = 2 # seconds between daemon ref-count checks
|
|
42
|
+
|
|
43
|
+
# Spawn timeout — how long a client waits for a freshly-spawned
|
|
44
|
+
# daemon to write its ready file. The python interpreter startup
|
|
45
|
+
# alone is ~500ms; loading the testerkit package + DuckDB + Flight
|
|
46
|
+
# adds another second; on slow CI runners or under contention
|
|
47
|
+
# (multiple tests spawning daemons concurrently), this can stretch
|
|
48
|
+
# past the previous 10s ceiling. 30s is a safer ceiling that still
|
|
49
|
+
# surfaces real spawn failures (corrupt index, unbindable port).
|
|
50
|
+
_SPAWN_TIMEOUT = float(os.environ.get("TESTERKIT_DAEMON_SPAWN_TIMEOUT", "30"))
|
|
51
|
+
|
|
52
|
+
# Default number of CPUs a daemon process pins itself to. gRPC's pollers +
|
|
53
|
+
# EventEngine workers, Arrow's thread pool, and DuckDB's executor all size
|
|
54
|
+
# themselves to the visible core count, so an unpinned Flight daemon on a
|
|
55
|
+
# 24-core box spawns ~96 threads. Since every store × directory is its own
|
|
56
|
+
# daemon, the aggregate trips WSL's pids cgroup at ~30 daemons. Query daemons
|
|
57
|
+
# are I/O-bound, so a small core budget costs no meaningful throughput.
|
|
58
|
+
# Override (or disable with <= 0) via ``TESTERKIT_DAEMON_CPU_CAP``.
|
|
59
|
+
_CPU_CAP = int(os.environ.get("TESTERKIT_DAEMON_CPU_CAP", "4"))
|
|
60
|
+
|
|
61
|
+
# Track acquired managers so atexit/signal can release them all
|
|
62
|
+
_acquired: dict[str, DaemonManager] = {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _taskset_prefix(dir_key: str) -> list[str]:
|
|
66
|
+
"""Return a ``taskset -c <cpus>`` command prefix that pins a spawned daemon
|
|
67
|
+
to a small core set, or ``[]`` when capping is off/unsupported/unneeded.
|
|
68
|
+
|
|
69
|
+
Prepended to the spawn command rather than set from inside the daemon:
|
|
70
|
+
gRPC, Arrow, and DuckDB all read the core count when they are *imported*,
|
|
71
|
+
and importing ``testerkit`` already pulls in pyarrow — so affinity has to be
|
|
72
|
+
set before the Python process starts. ``taskset`` does that and, unlike a
|
|
73
|
+
fork-time ``preexec_fn``, is safe to use from a multithreaded parent. It
|
|
74
|
+
``exec``s into the command, so the ``-m <module> <dir>`` tail (and thus
|
|
75
|
+
daemon process-matching) is preserved.
|
|
76
|
+
|
|
77
|
+
Each Flight daemon otherwise sizes its gRPC pollers + EventEngine workers,
|
|
78
|
+
Arrow pool, and DuckDB executor to the full core count (~96 threads on a
|
|
79
|
+
24-core box); since every store × directory is its own daemon, the
|
|
80
|
+
aggregate trips WSL's pids cgroup at ~30 daemons. Query daemons are
|
|
81
|
+
I/O-bound, so a small core budget costs no meaningful throughput.
|
|
82
|
+
|
|
83
|
+
``dir_key`` (the daemon's directory) offsets the chosen core window by a
|
|
84
|
+
stable hash so different projects' daemons spread across cores instead of
|
|
85
|
+
all piling onto cpus 0..cap-1. ``TESTERKIT_DAEMON_CPU_CAP`` overrides the cap;
|
|
86
|
+
<= 0 disables it.
|
|
87
|
+
"""
|
|
88
|
+
if _CPU_CAP <= 0 or not hasattr(os, "sched_getaffinity"):
|
|
89
|
+
return []
|
|
90
|
+
taskset = shutil.which("taskset")
|
|
91
|
+
if taskset is None:
|
|
92
|
+
return []
|
|
93
|
+
try:
|
|
94
|
+
available = sorted(os.sched_getaffinity(0))
|
|
95
|
+
except OSError:
|
|
96
|
+
return []
|
|
97
|
+
n = len(available)
|
|
98
|
+
if _CPU_CAP >= n:
|
|
99
|
+
return [] # already at/under the cap — don't restrict
|
|
100
|
+
offset = zlib.crc32(dir_key.encode()) % n
|
|
101
|
+
chosen = sorted({available[(offset + i) % n] for i in range(_CPU_CAP)})
|
|
102
|
+
return [taskset, "-c", ",".join(str(c) for c in chosen)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def daemon_duckdb_config() -> dict[str, Any]:
|
|
106
|
+
"""DuckDB ``connect(config=...)`` sizing the executor pool to the process's
|
|
107
|
+
*allowed* cores — for a daemon's long-lived index connection.
|
|
108
|
+
|
|
109
|
+
DuckDB defaults its thread pool to ``hardware_concurrency()`` (the whole
|
|
110
|
+
machine), ignoring CPU affinity — so a daemon pinned by :func:`_taskset_prefix`
|
|
111
|
+
to a few cores otherwise spawns ~24 oversubscribed executor threads on those
|
|
112
|
+
cores. Sizing to ``sched_getaffinity`` drops that to the pin with no
|
|
113
|
+
throughput loss (you can't outrun the cores you're pinned to). It also
|
|
114
|
+
auto-scopes: a client that opens the same index unpinned reads its full core
|
|
115
|
+
count, so only daemons shrink. No-op on platforms without ``sched_getaffinity``.
|
|
116
|
+
"""
|
|
117
|
+
if not hasattr(os, "sched_getaffinity"):
|
|
118
|
+
return {}
|
|
119
|
+
try:
|
|
120
|
+
return {"threads": max(1, len(os.sched_getaffinity(0)))}
|
|
121
|
+
except OSError:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _daemon_log_tail(log_path: Path, n: int = 25) -> str:
|
|
126
|
+
"""Return the last ``n`` significant lines of a daemon log for error context.
|
|
127
|
+
|
|
128
|
+
Drops the high-volume Arrow Acero alignment warnings so a real traceback
|
|
129
|
+
(e.g. a DuckDB BinderException from a schema-drifted index) isn't buried.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
lines = log_path.read_text(errors="replace").splitlines()
|
|
133
|
+
except OSError:
|
|
134
|
+
return "(no daemon log)"
|
|
135
|
+
significant = [ln for ln in lines if "poorly aligned" not in ln]
|
|
136
|
+
tail = (significant or lines)[-n:]
|
|
137
|
+
return "--- daemon log tail ---\n" + "\n".join(tail)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _installed_version() -> str:
|
|
141
|
+
"""Return the installed testerkit version string."""
|
|
142
|
+
from testerkit import __version__
|
|
143
|
+
|
|
144
|
+
return __version__
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _version_tuple(v: str) -> tuple[int, ...]:
|
|
148
|
+
"""Parse a version string into a comparable tuple of ints."""
|
|
149
|
+
parts: list[int] = []
|
|
150
|
+
for segment in v.split("."):
|
|
151
|
+
digits = ""
|
|
152
|
+
for ch in segment:
|
|
153
|
+
if ch.isdigit():
|
|
154
|
+
digits += ch
|
|
155
|
+
else:
|
|
156
|
+
break
|
|
157
|
+
parts.append(int(digits) if digits else 0)
|
|
158
|
+
return tuple(parts)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _pid_alive(pid: int) -> bool:
|
|
162
|
+
"""Check if a process is alive (cross-platform)."""
|
|
163
|
+
try:
|
|
164
|
+
os.kill(pid, 0)
|
|
165
|
+
return True
|
|
166
|
+
except (OSError, PermissionError):
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DaemonManager:
|
|
171
|
+
"""Base class for ref-counted daemon lifecycle management.
|
|
172
|
+
|
|
173
|
+
Subclasses set the four ``_*_name`` class variables. The simple
|
|
174
|
+
case (DuckDB events / runs daemons) sets ``_daemon_module`` and
|
|
175
|
+
``_port_file`` and inherits the default ``_spawn_cmd`` /
|
|
176
|
+
``_post_spawn_state`` shown below. Daemons that need extra
|
|
177
|
+
arguments (e.g. ``FlightDaemonManager`` passes host/port) override
|
|
178
|
+
those methods directly.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
_state_name: str
|
|
182
|
+
_lock_name: str
|
|
183
|
+
_ready_name: str
|
|
184
|
+
_pid_name: str
|
|
185
|
+
# Optional class-level shortcuts for the common DuckDB-style daemons.
|
|
186
|
+
# Set by subclasses; ``None`` means the subclass overrides
|
|
187
|
+
# ``_spawn_cmd`` / ``_post_spawn_state`` directly.
|
|
188
|
+
_daemon_module: str | None = None
|
|
189
|
+
_port_file: str | None = None
|
|
190
|
+
|
|
191
|
+
def __init__(self, daemon_dir: Path) -> None:
|
|
192
|
+
self._dir = daemon_dir
|
|
193
|
+
|
|
194
|
+
# -- Client-side API -----------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def acquire(self) -> None:
|
|
197
|
+
"""Acquire a reference to the daemon, starting it if needed."""
|
|
198
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
lock = FileLock(self._dir / self._lock_name, timeout=10)
|
|
200
|
+
state = self._dir / self._state_name
|
|
201
|
+
|
|
202
|
+
with lock:
|
|
203
|
+
if state.exists():
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(state.read_text())
|
|
206
|
+
if _pid_alive(data["pid"]):
|
|
207
|
+
if self._can_reuse(data):
|
|
208
|
+
refs: list[int] = data.get("refs", [])
|
|
209
|
+
if os.getpid() not in refs:
|
|
210
|
+
refs.append(os.getpid())
|
|
211
|
+
data["refs"] = refs
|
|
212
|
+
state.write_text(json.dumps(data))
|
|
213
|
+
self._register_cleanup()
|
|
214
|
+
return
|
|
215
|
+
else:
|
|
216
|
+
warnings.warn(
|
|
217
|
+
f"Restarting {type(self).__name__} daemon "
|
|
218
|
+
"(running instance is incompatible with this client)",
|
|
219
|
+
stacklevel=2,
|
|
220
|
+
)
|
|
221
|
+
self._kill_daemon(data["pid"])
|
|
222
|
+
state.unlink(missing_ok=True)
|
|
223
|
+
(self._dir / self._ready_name).unlink(missing_ok=True)
|
|
224
|
+
(self._dir / self._pid_name).unlink(missing_ok=True)
|
|
225
|
+
except (json.JSONDecodeError, OSError, KeyError, TypeError) as exc:
|
|
226
|
+
warnings.warn(
|
|
227
|
+
f"Stale daemon state, respawning: {exc}",
|
|
228
|
+
stacklevel=2,
|
|
229
|
+
)
|
|
230
|
+
state.unlink(missing_ok=True)
|
|
231
|
+
|
|
232
|
+
self._spawn()
|
|
233
|
+
pid = self._read_pid()
|
|
234
|
+
|
|
235
|
+
data = {"pid": pid, "refs": [os.getpid()], **self._daemon_identity()}
|
|
236
|
+
data.update(self._post_spawn_state())
|
|
237
|
+
state.write_text(json.dumps(data))
|
|
238
|
+
|
|
239
|
+
self._register_cleanup()
|
|
240
|
+
|
|
241
|
+
def release(self) -> None:
|
|
242
|
+
"""No-op. The daemon prunes dead client PIDs itself via
|
|
243
|
+
monitor_refs() every poll cycle — no blocking lock needed on
|
|
244
|
+
the caller's exit path. Ctrl+C on ``testerkit serve`` is instant."""
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
def read_state(self) -> dict:
|
|
248
|
+
"""Read the current state file. Returns empty dict if missing."""
|
|
249
|
+
state = self._dir / self._state_name
|
|
250
|
+
if not state.exists():
|
|
251
|
+
return {}
|
|
252
|
+
try:
|
|
253
|
+
return json.loads(state.read_text())
|
|
254
|
+
except (json.JSONDecodeError, OSError):
|
|
255
|
+
return {}
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _kill_daemon(pid: int) -> None:
|
|
259
|
+
"""Kill a daemon process and wait for it to exit."""
|
|
260
|
+
try:
|
|
261
|
+
os.kill(pid, signal.SIGTERM)
|
|
262
|
+
for _ in range(20):
|
|
263
|
+
time.sleep(0.1)
|
|
264
|
+
if not _pid_alive(pid):
|
|
265
|
+
return
|
|
266
|
+
os.kill(pid, signal.SIGKILL)
|
|
267
|
+
except (OSError, PermissionError):
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
def force_restart(self) -> None:
|
|
271
|
+
"""Kill a running daemon unconditionally so it rebuilds on next access."""
|
|
272
|
+
lock = FileLock(self._dir / self._lock_name, timeout=10)
|
|
273
|
+
state = self._dir / self._state_name
|
|
274
|
+
|
|
275
|
+
with lock:
|
|
276
|
+
if state.exists():
|
|
277
|
+
try:
|
|
278
|
+
data = json.loads(state.read_text())
|
|
279
|
+
pid = data.get("pid")
|
|
280
|
+
if pid and _pid_alive(pid):
|
|
281
|
+
self._kill_daemon(pid)
|
|
282
|
+
except (json.JSONDecodeError, OSError, KeyError, TypeError):
|
|
283
|
+
pass
|
|
284
|
+
state.unlink(missing_ok=True)
|
|
285
|
+
(self._dir / self._ready_name).unlink(missing_ok=True)
|
|
286
|
+
(self._dir / self._pid_name).unlink(missing_ok=True)
|
|
287
|
+
|
|
288
|
+
# -- Subclass hooks ------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def _daemon_identity(self) -> dict[str, Any]:
|
|
291
|
+
"""The compatibility identity stamped into the state file on spawn —
|
|
292
|
+
what ``_can_reuse`` later compares against.
|
|
293
|
+
|
|
294
|
+
Default: the testerkit version (the version-ratchet). A subclass with a
|
|
295
|
+
projection fingerprint overrides this to add e.g. ``{"fingerprint":
|
|
296
|
+
...}`` so reuse can key on it.
|
|
297
|
+
"""
|
|
298
|
+
return {"testerkit_version": _installed_version()}
|
|
299
|
+
|
|
300
|
+
def _can_reuse(self, running_state: dict[str, Any]) -> bool:
|
|
301
|
+
"""Whether THIS client may reuse the running daemon described by
|
|
302
|
+
``running_state`` (its state-file dict).
|
|
303
|
+
|
|
304
|
+
Default reproduces the version ratchet exactly: reuse iff the
|
|
305
|
+
running daemon's version is >= ours (an older-or-equal client rides
|
|
306
|
+
a newer daemon; a newer client does not reuse an older one — it
|
|
307
|
+
respawns/upgrades). A subclass keying on a fingerprint overrides
|
|
308
|
+
this to require an exact match instead.
|
|
309
|
+
"""
|
|
310
|
+
daemon_version = running_state.get("testerkit_version", "0.0.0")
|
|
311
|
+
return _version_tuple(daemon_version) >= _version_tuple(_installed_version())
|
|
312
|
+
|
|
313
|
+
def _spawn_cmd(self) -> list[str]:
|
|
314
|
+
"""Command to spawn the daemon process.
|
|
315
|
+
|
|
316
|
+
Default uses ``_daemon_module`` if set: ``python -m <module>
|
|
317
|
+
<dir>``. Subclasses with extra args override this method.
|
|
318
|
+
"""
|
|
319
|
+
if self._daemon_module is None:
|
|
320
|
+
raise NotImplementedError(
|
|
321
|
+
f"{type(self).__name__} must set _daemon_module or override _spawn_cmd"
|
|
322
|
+
)
|
|
323
|
+
return [sys.executable, "-m", self._daemon_module, str(self._dir)]
|
|
324
|
+
|
|
325
|
+
def _post_spawn_state(self) -> dict:
|
|
326
|
+
"""Extra fields to store in state file after spawn.
|
|
327
|
+
|
|
328
|
+
Default uses ``_port_file`` if set: read the port file the
|
|
329
|
+
daemon writes before signalling ready, return ``{"location":
|
|
330
|
+
...}``. Subclasses without a port file override or fall back
|
|
331
|
+
to the empty default.
|
|
332
|
+
"""
|
|
333
|
+
if self._port_file is None:
|
|
334
|
+
return {}
|
|
335
|
+
return {"location": (self._dir / self._port_file).read_text().strip()}
|
|
336
|
+
|
|
337
|
+
# -- Daemon-side API -----------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def write_ready(self) -> None:
|
|
340
|
+
"""Write PID and ready files, update state. Called from daemon.
|
|
341
|
+
|
|
342
|
+
If the ready file was already written (e.g. Flight writes its port
|
|
343
|
+
file with actual content), this will not overwrite it — subclasses
|
|
344
|
+
that pre-write the ready file should call this after.
|
|
345
|
+
"""
|
|
346
|
+
(self._dir / self._pid_name).write_text(str(os.getpid()))
|
|
347
|
+
ready = self._dir / self._ready_name
|
|
348
|
+
if not ready.exists():
|
|
349
|
+
ready.write_text("ok")
|
|
350
|
+
|
|
351
|
+
lock = FileLock(self._dir / self._lock_name, timeout=10)
|
|
352
|
+
state = self._dir / self._state_name
|
|
353
|
+
with lock:
|
|
354
|
+
if state.exists():
|
|
355
|
+
try:
|
|
356
|
+
data = json.loads(state.read_text())
|
|
357
|
+
data["pid"] = os.getpid()
|
|
358
|
+
state.write_text(json.dumps(data))
|
|
359
|
+
except (json.JSONDecodeError, OSError, TypeError) as exc:
|
|
360
|
+
warnings.warn(
|
|
361
|
+
f"Failed to update daemon state: {exc}",
|
|
362
|
+
stacklevel=2,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def update_state(self, **extra: object) -> None:
|
|
366
|
+
"""Merge extra fields into the state file. Called from daemon."""
|
|
367
|
+
lock = FileLock(self._dir / self._lock_name, timeout=10)
|
|
368
|
+
state = self._dir / self._state_name
|
|
369
|
+
with lock:
|
|
370
|
+
if state.exists():
|
|
371
|
+
try:
|
|
372
|
+
data = json.loads(state.read_text())
|
|
373
|
+
data.update(extra)
|
|
374
|
+
state.write_text(json.dumps(data))
|
|
375
|
+
except (json.JSONDecodeError, OSError, TypeError) as exc:
|
|
376
|
+
warnings.warn(
|
|
377
|
+
f"Failed to update daemon state: {exc}",
|
|
378
|
+
stacklevel=2,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def monitor_refs(
|
|
382
|
+
self,
|
|
383
|
+
*,
|
|
384
|
+
idle_timeout: float = _IDLE_TIMEOUT,
|
|
385
|
+
poll_interval: float = _POLL_INTERVAL,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Block until refs drop to zero and idle timeout expires.
|
|
388
|
+
|
|
389
|
+
Called from daemon processes to know when to shut down.
|
|
390
|
+
"""
|
|
391
|
+
state = self._dir / self._state_name
|
|
392
|
+
lock = FileLock(self._dir / self._lock_name, timeout=10)
|
|
393
|
+
idle_since: float | None = None
|
|
394
|
+
|
|
395
|
+
while True:
|
|
396
|
+
time.sleep(poll_interval)
|
|
397
|
+
|
|
398
|
+
with lock:
|
|
399
|
+
if not state.exists():
|
|
400
|
+
break
|
|
401
|
+
try:
|
|
402
|
+
data = json.loads(state.read_text())
|
|
403
|
+
refs: list[int] = data.get("refs", [])
|
|
404
|
+
live = [p for p in refs if _pid_alive(p)]
|
|
405
|
+
if live != refs:
|
|
406
|
+
data["refs"] = live
|
|
407
|
+
state.write_text(json.dumps(data))
|
|
408
|
+
refs = live
|
|
409
|
+
except (json.JSONDecodeError, OSError):
|
|
410
|
+
break
|
|
411
|
+
|
|
412
|
+
if not refs:
|
|
413
|
+
if idle_since is None:
|
|
414
|
+
idle_since = time.monotonic()
|
|
415
|
+
elif time.monotonic() - idle_since >= idle_timeout:
|
|
416
|
+
break
|
|
417
|
+
else:
|
|
418
|
+
idle_since = None
|
|
419
|
+
|
|
420
|
+
def cleanup_state_files(self) -> None:
|
|
421
|
+
"""Remove state, ready, and PID files. Called from daemon on shutdown."""
|
|
422
|
+
with FileLock(self._dir / self._lock_name, timeout=5):
|
|
423
|
+
(self._dir / self._state_name).unlink(missing_ok=True)
|
|
424
|
+
(self._dir / self._ready_name).unlink(missing_ok=True)
|
|
425
|
+
(self._dir / self._pid_name).unlink(missing_ok=True)
|
|
426
|
+
|
|
427
|
+
# -- Internal ------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
def _spawn(self) -> None:
|
|
430
|
+
"""Spawn daemon as a detached process, wait for ready file.
|
|
431
|
+
|
|
432
|
+
stdout/stderr are appended to ``_daemon.log`` in the daemon's
|
|
433
|
+
directory. Without a log sink the daemon's warnings (ingest
|
|
434
|
+
failures, schema-drift rebuilds, exceptions) vanish into
|
|
435
|
+
``/dev/null`` and a misbehaving daemon looks identical to a
|
|
436
|
+
healthy one. With it, ``tail -f`` on the file shows the
|
|
437
|
+
actual reason a query is empty / slow / wrong.
|
|
438
|
+
"""
|
|
439
|
+
ready_file = self._dir / self._ready_name
|
|
440
|
+
ready_file.unlink(missing_ok=True)
|
|
441
|
+
|
|
442
|
+
# Prefix with ``taskset`` (when available) so gRPC/Arrow/DuckDB size
|
|
443
|
+
# their thread pools to a small core budget — they read the core count
|
|
444
|
+
# at import, before any daemon code (or even ``import testerkit``) runs.
|
|
445
|
+
cmd = _taskset_prefix(str(self._dir)) + self._spawn_cmd()
|
|
446
|
+
log_path = self._dir / "_daemon.log"
|
|
447
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
log_handle = open(log_path, "ab", buffering=0)
|
|
449
|
+
kwargs: dict = {
|
|
450
|
+
"stdout": log_handle,
|
|
451
|
+
"stderr": subprocess.STDOUT,
|
|
452
|
+
"close_fds": True,
|
|
453
|
+
}
|
|
454
|
+
if sys.platform == "win32":
|
|
455
|
+
kwargs["creationflags"] = (
|
|
456
|
+
subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
457
|
+
)
|
|
458
|
+
else:
|
|
459
|
+
kwargs["start_new_session"] = True
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
463
|
+
finally:
|
|
464
|
+
# Daemon inherits the file descriptor; we close ours so
|
|
465
|
+
# the parent process doesn't keep the log file open.
|
|
466
|
+
log_handle.close()
|
|
467
|
+
|
|
468
|
+
deadline = time.monotonic() + _SPAWN_TIMEOUT
|
|
469
|
+
while time.monotonic() < deadline:
|
|
470
|
+
if ready_file.exists():
|
|
471
|
+
return
|
|
472
|
+
if proc.poll() is not None:
|
|
473
|
+
raise RuntimeError(
|
|
474
|
+
f"Daemon exited (code {proc.returncode}) before signaling ready. "
|
|
475
|
+
f"dir={self._dir}, cmd={cmd}\n{_daemon_log_tail(log_path)}"
|
|
476
|
+
)
|
|
477
|
+
time.sleep(0.05)
|
|
478
|
+
|
|
479
|
+
proc.kill()
|
|
480
|
+
proc.wait(timeout=2)
|
|
481
|
+
raise RuntimeError(
|
|
482
|
+
f"Daemon failed to start within {_SPAWN_TIMEOUT}s. dir={self._dir}, "
|
|
483
|
+
f"cmd={cmd}\n{_daemon_log_tail(log_path)}"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def _read_pid(self) -> int:
|
|
487
|
+
"""Read the daemon PID from its PID file."""
|
|
488
|
+
pid_file = self._dir / self._pid_name
|
|
489
|
+
deadline = time.monotonic() + 5
|
|
490
|
+
while time.monotonic() < deadline:
|
|
491
|
+
if pid_file.exists():
|
|
492
|
+
try:
|
|
493
|
+
return int(pid_file.read_text().strip())
|
|
494
|
+
except ValueError:
|
|
495
|
+
pass
|
|
496
|
+
time.sleep(0.05)
|
|
497
|
+
raise RuntimeError("Daemon did not write PID file")
|
|
498
|
+
|
|
499
|
+
def _register_cleanup(self) -> None:
|
|
500
|
+
key = str(self._dir) + ":" + self._state_name
|
|
501
|
+
if key in _acquired:
|
|
502
|
+
return
|
|
503
|
+
_acquired[key] = self
|
|
504
|
+
|
|
505
|
+
def _cleanup() -> None:
|
|
506
|
+
mgr = _acquired.pop(key, None)
|
|
507
|
+
if mgr is not None:
|
|
508
|
+
try:
|
|
509
|
+
mgr.release()
|
|
510
|
+
except Exception: # noqa: BLE001 — deliberately broad for atexit cleanup
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
atexit.register(_cleanup)
|
|
514
|
+
|
|
515
|
+
# No signal handlers — release() is a no-op so there is nothing
|
|
516
|
+
# to do on SIGINT/SIGTERM. pytest and uvicorn both manage their
|
|
517
|
+
# own signal handling; installing a handler here only interferes.
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def wait_for_location(mgr: DaemonManager, store_dir: Path, label: str) -> str:
|
|
521
|
+
"""Poll the daemon's state file until it writes its Flight location (up to 5s).
|
|
522
|
+
|
|
523
|
+
Shared by every store's manager (events / runs / files). ``label`` names
|
|
524
|
+
the store in the timeout error.
|
|
525
|
+
"""
|
|
526
|
+
deadline = time.monotonic() + 5.0
|
|
527
|
+
while True:
|
|
528
|
+
location = mgr.read_state().get("location")
|
|
529
|
+
if location:
|
|
530
|
+
return location
|
|
531
|
+
if time.monotonic() >= deadline:
|
|
532
|
+
raise RuntimeError(
|
|
533
|
+
f"{label} daemon started but no location in state after 5s: {store_dir}"
|
|
534
|
+
)
|
|
535
|
+
time.sleep(0.05)
|