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
testerkit/ui/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Custom-UI-builder surface for TesterKit.
|
|
2
|
+
|
|
3
|
+
If you're building a custom NiceGUI page that sits alongside the
|
|
4
|
+
built-in operator pages — a project-specific dashboard, a custom
|
|
5
|
+
fixture-bringup panel, a measurement-comparison view — reach for
|
|
6
|
+
the helpers re-exported here instead of the deep
|
|
7
|
+
``testerkit.ui.shared.*`` paths.
|
|
8
|
+
|
|
9
|
+
Two groups of helpers:
|
|
10
|
+
|
|
11
|
+
**Layout / table primitives** — match the look-and-feel of the
|
|
12
|
+
built-in pages so your custom panel doesn't visually drift::
|
|
13
|
+
|
|
14
|
+
from testerkit.ui import page_layout, page_header, data_table, format_datetime
|
|
15
|
+
|
|
16
|
+
@ui.page("/my-bringup")
|
|
17
|
+
def my_panel():
|
|
18
|
+
with page_layout():
|
|
19
|
+
page_header("Bringup")
|
|
20
|
+
data_table(columns=[...], rows=[...], row_key="id")
|
|
21
|
+
|
|
22
|
+
**Live event bindings** — subscribe to the event store / channel
|
|
23
|
+
store so your page updates push-style as new samples arrive::
|
|
24
|
+
|
|
25
|
+
from testerkit.ui import subscribe, channel_data, bind_channel_store
|
|
26
|
+
|
|
27
|
+
@ui.page("/live-temp")
|
|
28
|
+
def temp_panel():
|
|
29
|
+
data = channel_data("oven.temp_c")
|
|
30
|
+
ui.label().bind_text_from(data, "latest", lambda v: f"{v:.1f} °C")
|
|
31
|
+
|
|
32
|
+
The deep ``testerkit.ui.shared.components`` and
|
|
33
|
+
``testerkit.ui.shared.event_binding`` paths still work — they're the
|
|
34
|
+
contributor form. User code should use this module.
|
|
35
|
+
|
|
36
|
+
Operator pages themselves (``/results``, ``/metrics``, etc.) are
|
|
37
|
+
registered via ``testerkit.ui.pages.*`` on package import and don't
|
|
38
|
+
need to be touched by custom-UI builders.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from testerkit.ui.shared.components import (
|
|
44
|
+
data_table,
|
|
45
|
+
format_datetime,
|
|
46
|
+
info_field,
|
|
47
|
+
page_header,
|
|
48
|
+
page_layout,
|
|
49
|
+
push_url_state,
|
|
50
|
+
)
|
|
51
|
+
from testerkit.ui.shared.event_binding import (
|
|
52
|
+
bind_channel_store,
|
|
53
|
+
)
|
|
54
|
+
from testerkit.ui.shared.event_binding import (
|
|
55
|
+
ui_channel_data as channel_data,
|
|
56
|
+
)
|
|
57
|
+
from testerkit.ui.shared.event_binding import (
|
|
58
|
+
ui_subscribe as subscribe,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"bind_channel_store",
|
|
63
|
+
"channel_data",
|
|
64
|
+
"data_table",
|
|
65
|
+
"format_datetime",
|
|
66
|
+
"info_field",
|
|
67
|
+
"page_header",
|
|
68
|
+
"page_layout",
|
|
69
|
+
"push_url_state",
|
|
70
|
+
"subscribe",
|
|
71
|
+
]
|
testerkit/ui/_asgi.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""ASGI app entry point for uvicorn auto-reload support.
|
|
2
|
+
|
|
3
|
+
When ``testerkit serve --reload`` is used, uvicorn watches for file changes and
|
|
4
|
+
restarts the worker process. On each restart the worker re-imports this module
|
|
5
|
+
which:
|
|
6
|
+
|
|
7
|
+
1. Imports ``nicegui`` (creating a fresh ``core.app``).
|
|
8
|
+
2. Imports all UI pages (``@ui.page`` decorators register routes on ``core.app``).
|
|
9
|
+
3. Adds the FastAPI API router.
|
|
10
|
+
4. Calls ``add_run_config()`` so the NiceGUI lifespan check passes.
|
|
11
|
+
5. Adds standard middleware that ``ui.run()`` normally applies.
|
|
12
|
+
|
|
13
|
+
The exported ``app`` is the NiceGUI ASGI application ready for uvicorn.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _log(msg: str) -> None:
|
|
24
|
+
sys.stderr.write(msg + "\n")
|
|
25
|
+
sys.stderr.flush()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_start = time.perf_counter()
|
|
29
|
+
_log(f"[ASGI] Starting reload at {time.strftime('%H:%M:%S')}")
|
|
30
|
+
|
|
31
|
+
from fastapi.middleware.gzip import GZipMiddleware # noqa: E402
|
|
32
|
+
|
|
33
|
+
_log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - fastapi imported")
|
|
34
|
+
|
|
35
|
+
from nicegui import core # noqa: E402
|
|
36
|
+
|
|
37
|
+
_log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - nicegui.core imported")
|
|
38
|
+
|
|
39
|
+
from nicegui.middlewares import RedirectWithPrefixMiddleware, SetCacheControlMiddleware # noqa: E402, I001
|
|
40
|
+
|
|
41
|
+
_log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - middlewares imported")
|
|
42
|
+
|
|
43
|
+
# Register UI pages on core.app (side-effect imports)
|
|
44
|
+
import testerkit.ui.app # noqa: F401, E402 # pyright: ignore[reportUnusedImport]
|
|
45
|
+
|
|
46
|
+
_log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - testerkit.ui.app imported")
|
|
47
|
+
|
|
48
|
+
from testerkit.api.app import create_api_router # noqa: E402
|
|
49
|
+
|
|
50
|
+
_log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - api router imported")
|
|
51
|
+
|
|
52
|
+
# Add API routes
|
|
53
|
+
core.app.include_router(create_api_router())
|
|
54
|
+
|
|
55
|
+
# Configure NiceGUI the same way ui.run() would
|
|
56
|
+
if not core.app.config.has_run_config:
|
|
57
|
+
core.app.config.add_run_config(
|
|
58
|
+
reload=True,
|
|
59
|
+
title="TesterKit",
|
|
60
|
+
viewport="width=device-width, initial-scale=1",
|
|
61
|
+
favicon=Path(__file__).resolve().parent / "static" / "branding" / "favicon-32.png",
|
|
62
|
+
dark=False,
|
|
63
|
+
language="en-US",
|
|
64
|
+
binding_refresh_interval=0.1,
|
|
65
|
+
reconnect_timeout=3.0,
|
|
66
|
+
message_history_length=1000,
|
|
67
|
+
tailwind=True,
|
|
68
|
+
prod_js=True,
|
|
69
|
+
show_welcome_message=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Middleware that ui.run() normally adds
|
|
73
|
+
core.app.add_middleware(GZipMiddleware)
|
|
74
|
+
core.app.add_middleware(RedirectWithPrefixMiddleware)
|
|
75
|
+
core.app.add_middleware(SetCacheControlMiddleware)
|
|
76
|
+
|
|
77
|
+
app = core.app
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _install_global_exception_handler() -> None:
|
|
81
|
+
"""Toast-on-error so a buggy page never takes the server down.
|
|
82
|
+
|
|
83
|
+
NicegUI dispatches event handler exceptions through
|
|
84
|
+
``app.on_exception``; without a handler they bubble to the
|
|
85
|
+
ASGI worker as 500s and (occasionally) escape to the
|
|
86
|
+
terminal as tracebacks. Either way, a single bad lambda in
|
|
87
|
+
one page should not break navigation to other pages.
|
|
88
|
+
|
|
89
|
+
The handler:
|
|
90
|
+
|
|
91
|
+
* Logs the full traceback to stderr (operators / devs see it).
|
|
92
|
+
* Posts a small ``ui.notify`` "negative" toast to the affected
|
|
93
|
+
browser session — same UX as NiceGUI's built-in
|
|
94
|
+
"Connection lost" banner, just for application errors.
|
|
95
|
+
|
|
96
|
+
The toast itself is best-effort: ``ui.notify`` requires an
|
|
97
|
+
active client context. If the exception fires outside a page
|
|
98
|
+
handler (e.g. background thread, timer callback), the notify
|
|
99
|
+
silently no-ops and we just log.
|
|
100
|
+
"""
|
|
101
|
+
import logging
|
|
102
|
+
import traceback
|
|
103
|
+
|
|
104
|
+
from nicegui import app as nicegui_app
|
|
105
|
+
from nicegui import ui
|
|
106
|
+
|
|
107
|
+
logger = logging.getLogger("testerkit.ui")
|
|
108
|
+
|
|
109
|
+
def _on_exception(exc: Exception | None = None) -> None:
|
|
110
|
+
# ``exc`` may be None depending on dispatcher; pull from
|
|
111
|
+
# ``sys.exc_info`` as a fallback so we always have *something*
|
|
112
|
+
# to log.
|
|
113
|
+
if exc is None:
|
|
114
|
+
import sys
|
|
115
|
+
|
|
116
|
+
_, exc_val, _ = sys.exc_info()
|
|
117
|
+
exc = exc_val if isinstance(exc_val, Exception) else None
|
|
118
|
+
if exc is not None:
|
|
119
|
+
logger.error(
|
|
120
|
+
"UI handler raised %s: %s\n%s",
|
|
121
|
+
type(exc).__name__,
|
|
122
|
+
exc,
|
|
123
|
+
"".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
label = type(exc).__name__ if exc is not None else "Error"
|
|
127
|
+
ui.notify(
|
|
128
|
+
f"Page error: {label} — see server log for details",
|
|
129
|
+
type="negative",
|
|
130
|
+
position="bottom-right",
|
|
131
|
+
timeout=5000,
|
|
132
|
+
)
|
|
133
|
+
except Exception: # noqa: BLE001 — toast is best-effort outside client ctx
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
nicegui_app.on_exception(_on_exception)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_install_global_exception_handler()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _hold_serve_level_daemon_refs() -> None:
|
|
143
|
+
"""Delegate to :func:`testerkit.api.app.warm_data_daemons` — the same call the
|
|
144
|
+
default ``create_app()`` entrypoint makes — so ``testerkit serve --reload`` and
|
|
145
|
+
plain ``testerkit serve`` acquire the daemons and establish the channels
|
|
146
|
+
Flight→UI bridge identically (#59-3). See that function for the rationale.
|
|
147
|
+
"""
|
|
148
|
+
from testerkit.api.app import warm_data_daemons
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
warm_data_daemons()
|
|
152
|
+
except Exception as exc: # noqa: BLE001 — best-effort eager acquire
|
|
153
|
+
_log(f"[ASGI] warm_data_daemons failed: {exc}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ != "__mp_main__":
|
|
157
|
+
# Only hold daemon refs in the supervisor process, not in every
|
|
158
|
+
# reload worker. Workers are spawned fresh on each code change;
|
|
159
|
+
# acquiring daemons in each worker would re-ingest all parquets
|
|
160
|
+
# on every reload cycle.
|
|
161
|
+
_hold_serve_level_daemon_refs()
|
|
162
|
+
_log(f"[ASGI] +{(time.perf_counter() - _start) * 1000:.0f}ms - READY")
|
testerkit/ui/app.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""NiceGUI-based operator UI.
|
|
2
|
+
|
|
3
|
+
This module initializes the NiceGUI app and imports all page modules
|
|
4
|
+
which register their routes via @ui.page decorators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib.resources
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from nicegui import app
|
|
11
|
+
|
|
12
|
+
# Serve static files
|
|
13
|
+
_static_dir = Path(__file__).parent / "static"
|
|
14
|
+
app.add_static_files("/static", _static_dir)
|
|
15
|
+
|
|
16
|
+
# Docs assets (cropped operator-UI screenshots etc.) are referenced from
|
|
17
|
+
# markdown like ```` which the browser resolves
|
|
18
|
+
# to ``/docs/_assets/...``. Mounting the directory here means those
|
|
19
|
+
# requests are served as PNGs instead of falling through to the docs
|
|
20
|
+
# catch-all route (which would render an HTML page-not-found body and
|
|
21
|
+
# break image loading). Match the wheel/editable resolution that
|
|
22
|
+
# docs/page.py uses for ``DOCS_DIR``.
|
|
23
|
+
_pkg_root = Path(str(importlib.resources.files("testerkit")))
|
|
24
|
+
_bundled_docs = _pkg_root / "_docs"
|
|
25
|
+
_docs_root = _bundled_docs if _bundled_docs.exists() else _pkg_root.parent.parent / "docs"
|
|
26
|
+
_docs_assets_dir = _docs_root / "_assets"
|
|
27
|
+
if _docs_assets_dir.exists():
|
|
28
|
+
app.add_static_files("/docs/_assets", _docs_assets_dir)
|
|
29
|
+
|
|
30
|
+
# Import pages module which imports all page submodules and registers routes
|
|
31
|
+
# This must happen after the app is initialized
|
|
32
|
+
from testerkit.ui import pages # noqa: F401, E402
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Reusable UI components for TesterKit live pages."""
|
|
2
|
+
|
|
3
|
+
from testerkit.ui.components.channel_values import create_channel_values_panel
|
|
4
|
+
from testerkit.ui.components.event_timeline import create_event_timeline
|
|
5
|
+
from testerkit.ui.components.instrument_activity import create_instrument_activity
|
|
6
|
+
from testerkit.ui.components.session_table import create_session_table
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"create_channel_values_panel",
|
|
10
|
+
"create_event_timeline",
|
|
11
|
+
"create_instrument_activity",
|
|
12
|
+
"create_session_table",
|
|
13
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""View buttons + dialogs for measurement-output ref artifacts.
|
|
2
|
+
|
|
3
|
+
Walks each measurement's ``outputs`` dict; for any value that is a
|
|
4
|
+
``file://_ref/...`` or ``channel://...`` URI, renders a "View ..."
|
|
5
|
+
button. Clicking opens a NiceGUI dialog with an extension-driven
|
|
6
|
+
viewer:
|
|
7
|
+
|
|
8
|
+
* ``.npz`` (Waveform) — Python-side fetch of the JSON payload,
|
|
9
|
+
rendered as an ECharts line plot.
|
|
10
|
+
* image / video / PDF / SVG / HTML / text / JSON — embed the API
|
|
11
|
+
URL directly in ``<img>`` / ``<video>`` / ``<iframe>``. The
|
|
12
|
+
``/api/runs/{id}/ref`` endpoint serves the right ``Content-Type``
|
|
13
|
+
(image/png, video/mp4, application/pdf, text/plain, …) so the
|
|
14
|
+
browser renders inline without us doing any conversion.
|
|
15
|
+
* anything else — a download link.
|
|
16
|
+
|
|
17
|
+
The endpoint already deduplicates the heavy lifting; this module is
|
|
18
|
+
intentionally thin glue.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from nicegui import ui
|
|
28
|
+
|
|
29
|
+
from testerkit.ui.shared.services import load_artifact_ref
|
|
30
|
+
|
|
31
|
+
# Map from URI extension to ("button label", "viewer kind").
|
|
32
|
+
#
|
|
33
|
+
# `.bin` is the catch-all the write path uses for any ``bytes`` payload
|
|
34
|
+
# whose original MIME type is unknown (operator screenshots, binary
|
|
35
|
+
# captures, …). The endpoint sniffs magic bytes server-side and serves
|
|
36
|
+
# the right ``Content-Type``; the UI just iframes the URL and lets the
|
|
37
|
+
# browser pick a renderer (``image/*`` → image, ``video/*`` → video,
|
|
38
|
+
# ``application/pdf`` → PDF reader, ``text/*`` → text). Same trick for
|
|
39
|
+
# explicit known extensions where the format is unambiguous.
|
|
40
|
+
_VIEWER_BY_EXT: dict[str, tuple[str, str]] = {
|
|
41
|
+
".npz": ("View Waveform", "waveform"),
|
|
42
|
+
".png": ("View Image", "image"),
|
|
43
|
+
".jpg": ("View Image", "image"),
|
|
44
|
+
".jpeg": ("View Image", "image"),
|
|
45
|
+
".gif": ("View Image", "image"),
|
|
46
|
+
".webp": ("View Image", "image"),
|
|
47
|
+
".svg": ("View Image", "image"),
|
|
48
|
+
".mp4": ("View Video", "video"),
|
|
49
|
+
".webm": ("View Video", "video"),
|
|
50
|
+
".pdf": ("View PDF", "pdf"),
|
|
51
|
+
".txt": ("View Text", "text"),
|
|
52
|
+
".log": ("View Text", "text"),
|
|
53
|
+
".html": ("View HTML", "html"),
|
|
54
|
+
".htm": ("View HTML", "html"),
|
|
55
|
+
".json": ("View Data", "json"),
|
|
56
|
+
# ``.bin`` covers raw bytes that the server sniffs into the right
|
|
57
|
+
# Content-Type at request time. We don't know the precise type until
|
|
58
|
+
# the user clicks, so use a generic label and a universal iframe.
|
|
59
|
+
".bin": ("View Artifact", "iframe"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_artifacts(measurement: dict[str, Any]) -> list[tuple[str, str]]:
|
|
64
|
+
"""Return ``[(name, uri)]`` for every ref-typed output on a measurement row.
|
|
65
|
+
|
|
66
|
+
Reads from the ``outputs`` dict (bare names) produced by
|
|
67
|
+
``RunStore.get_measurements``.
|
|
68
|
+
"""
|
|
69
|
+
refs: list[tuple[str, str]] = []
|
|
70
|
+
for name, value in (measurement.get("outputs") or {}).items():
|
|
71
|
+
if isinstance(value, str) and value.startswith(("file://", "channel://")):
|
|
72
|
+
refs.append((name, value))
|
|
73
|
+
return refs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _viewer_for_uri(uri: str) -> tuple[str, str]:
|
|
77
|
+
"""Return ``(label, kind)`` for *uri* based on its filename extension."""
|
|
78
|
+
lowered = uri.lower().split("?", 1)[0]
|
|
79
|
+
for ext, label_kind in _VIEWER_BY_EXT.items():
|
|
80
|
+
if lowered.endswith(ext):
|
|
81
|
+
return label_kind
|
|
82
|
+
return "Download", "download"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _api_url(run_id: str, uri: str) -> str:
|
|
86
|
+
return f"/api/runs/{run_id}/ref?uri={urllib.parse.quote(uri, safe='')}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render_artifact_buttons(run_id: str, measurement: dict[str, Any]) -> None:
|
|
90
|
+
"""Render one View / Download button per artifact on a measurement row.
|
|
91
|
+
|
|
92
|
+
Caller decides where in the layout to drop these (typically a row
|
|
93
|
+
under the measurements table or inline alongside the value).
|
|
94
|
+
"""
|
|
95
|
+
artifacts = list_artifacts(measurement)
|
|
96
|
+
if not artifacts:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
step = measurement.get("step_name", "")
|
|
100
|
+
name = measurement.get("measurement_name", "")
|
|
101
|
+
label_prefix = f"{step}.{name}" if step else name
|
|
102
|
+
|
|
103
|
+
with ui.row().classes("items-center gap-2 flex-wrap"):
|
|
104
|
+
ui.label(f"{label_prefix}:").classes("text-sm font-medium text-slate-700")
|
|
105
|
+
for output_key, uri in artifacts:
|
|
106
|
+
label, kind = _viewer_for_uri(uri)
|
|
107
|
+
api_url = _api_url(run_id, uri)
|
|
108
|
+
button_text = f"{label} ({output_key})"
|
|
109
|
+
if kind == "download":
|
|
110
|
+
ui.link(button_text, api_url, new_tab=True).classes(
|
|
111
|
+
"text-blue-600 hover:underline text-sm"
|
|
112
|
+
)
|
|
113
|
+
continue
|
|
114
|
+
ui.button(
|
|
115
|
+
button_text,
|
|
116
|
+
on_click=_make_open_dialog(run_id, output_key, uri, api_url, kind),
|
|
117
|
+
).props("dense flat color=primary")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _make_open_dialog(run_id: str, output_key: str, uri: str, api_url: str, kind: str):
|
|
121
|
+
"""Closure factory binding loop-iteration values to the click handler."""
|
|
122
|
+
return lambda: _open_dialog(run_id, output_key, uri, api_url, kind)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _open_dialog(run_id: str, output_key: str, uri: str, api_url: str, kind: str) -> None:
|
|
126
|
+
"""Pop a dialog containing the appropriate viewer for *kind*."""
|
|
127
|
+
with ui.dialog() as dialog, ui.card().classes("p-4 w-[min(900px,90vw)]"):
|
|
128
|
+
ui.label(output_key).classes("text-lg font-semibold mb-2")
|
|
129
|
+
_render_viewer_body(run_id, uri, api_url, kind)
|
|
130
|
+
with ui.row().classes("w-full justify-end mt-2"):
|
|
131
|
+
ui.button("Close", on_click=dialog.close).props("flat")
|
|
132
|
+
dialog.open()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _render_viewer_body(run_id: str, uri: str, api_url: str, kind: str) -> None:
|
|
136
|
+
"""Pick the viewer body for *kind*.
|
|
137
|
+
|
|
138
|
+
Browser-rendered viewers (image / video / pdf / iframe) embed the
|
|
139
|
+
HTTP API URL directly — the browser fetches and renders. JSON-shaped
|
|
140
|
+
payloads (waveform / text / dict) skip the HTTP round-trip and call
|
|
141
|
+
``load_ref`` directly: the UI runs in the same Python process as
|
|
142
|
+
the API, so we don't need to go through the network.
|
|
143
|
+
"""
|
|
144
|
+
if kind == "waveform":
|
|
145
|
+
_render_waveform(run_id, uri)
|
|
146
|
+
elif kind == "image":
|
|
147
|
+
ui.image(api_url).classes("w-full")
|
|
148
|
+
elif kind == "video":
|
|
149
|
+
ui.video(api_url, controls=True).classes("w-full")
|
|
150
|
+
elif kind == "pdf":
|
|
151
|
+
_iframe(api_url, sandbox=False, border=False)
|
|
152
|
+
elif kind == "html":
|
|
153
|
+
_iframe(api_url, sandbox=True, border=True)
|
|
154
|
+
elif kind == "text":
|
|
155
|
+
_render_text_or_json(run_id, uri, pretty=False)
|
|
156
|
+
elif kind == "json":
|
|
157
|
+
_render_text_or_json(run_id, uri, pretty=True)
|
|
158
|
+
elif kind == "iframe":
|
|
159
|
+
# Universal: server's magic-byte sniff sets Content-Type; the
|
|
160
|
+
# browser picks a renderer (image / video / pdf / text / download).
|
|
161
|
+
# No sandbox — Chrome blocks the PDF plugin in sandboxed iframes,
|
|
162
|
+
# and the content comes from the user's own results dir (same-origin,
|
|
163
|
+
# no untrusted scripts).
|
|
164
|
+
_iframe(api_url, sandbox=False, border=True)
|
|
165
|
+
else:
|
|
166
|
+
ui.link("Download", api_url, new_tab=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _iframe(api_url: str, *, sandbox: bool, border: bool) -> None:
|
|
170
|
+
sandbox_attr = "sandbox " if sandbox else ""
|
|
171
|
+
border_style = "1px solid #ccc" if border else "0"
|
|
172
|
+
ui.html(
|
|
173
|
+
f'<iframe src="{api_url}" {sandbox_attr}'
|
|
174
|
+
f'style="width:100%;height:70vh;border:{border_style}"></iframe>',
|
|
175
|
+
sanitize=False,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _render_waveform(run_id: str, uri: str) -> None:
|
|
180
|
+
"""Materialize the Waveform in-process and render an ECharts line plot."""
|
|
181
|
+
try:
|
|
182
|
+
wfm = load_artifact_ref(run_id, uri)
|
|
183
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
184
|
+
ui.label(f"Failed to load waveform: {exc}").classes("text-red-600")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
if not hasattr(wfm, "Y"):
|
|
188
|
+
ui.label(f"Expected a Waveform, got {type(wfm).__name__}").classes("text-red-600")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
Y = list(wfm.Y)
|
|
192
|
+
t0 = float(getattr(wfm, "t0", 0.0))
|
|
193
|
+
dt = float(getattr(wfm, "dt", 1.0)) or 1.0
|
|
194
|
+
x_axis = [t0 + i * dt for i in range(len(Y))]
|
|
195
|
+
attrs = getattr(wfm, "attrs", {}) or {}
|
|
196
|
+
unit = attrs.get("unit")
|
|
197
|
+
y_label = f"value ({unit})" if unit else "value"
|
|
198
|
+
|
|
199
|
+
ui.echart(
|
|
200
|
+
{
|
|
201
|
+
"tooltip": {"trigger": "axis"},
|
|
202
|
+
"xAxis": {
|
|
203
|
+
"type": "category",
|
|
204
|
+
"data": [f"{v:.4g}" for v in x_axis],
|
|
205
|
+
"name": "time (s)",
|
|
206
|
+
"nameLocation": "middle",
|
|
207
|
+
"nameGap": 30,
|
|
208
|
+
},
|
|
209
|
+
"yAxis": {"type": "value", "name": y_label},
|
|
210
|
+
"series": [
|
|
211
|
+
{
|
|
212
|
+
"type": "line",
|
|
213
|
+
"data": Y,
|
|
214
|
+
"showSymbol": False,
|
|
215
|
+
"smooth": False,
|
|
216
|
+
}
|
|
217
|
+
],
|
|
218
|
+
"grid": {"left": 60, "right": 30, "top": 30, "bottom": 50},
|
|
219
|
+
}
|
|
220
|
+
).classes("w-full h-96")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _render_text_or_json(run_id: str, uri: str, *, pretty: bool) -> None:
|
|
224
|
+
"""Materialize text/JSON in-process and embed in a ``<pre>`` block."""
|
|
225
|
+
try:
|
|
226
|
+
payload = load_artifact_ref(run_id, uri)
|
|
227
|
+
except (FileNotFoundError, OSError, ValueError) as exc:
|
|
228
|
+
ui.label(f"Failed to load: {exc}").classes("text-red-600")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
if pretty and isinstance(payload, dict):
|
|
232
|
+
content = json.dumps(payload, indent=2, default=str)
|
|
233
|
+
elif isinstance(payload, bytes):
|
|
234
|
+
try:
|
|
235
|
+
content = payload.decode("utf-8")
|
|
236
|
+
except UnicodeDecodeError:
|
|
237
|
+
content = repr(payload)
|
|
238
|
+
elif isinstance(payload, dict):
|
|
239
|
+
content = json.dumps(payload, default=str)
|
|
240
|
+
else:
|
|
241
|
+
content = str(payload)
|
|
242
|
+
|
|
243
|
+
# NiceGUI's ui.html escapes nothing; build a safe pre block manually.
|
|
244
|
+
import html as _html
|
|
245
|
+
|
|
246
|
+
ui.html(
|
|
247
|
+
f'<pre class="text-xs whitespace-pre-wrap break-all" '
|
|
248
|
+
f'style="max-height:60vh;overflow:auto">{_html.escape(content)}</pre>',
|
|
249
|
+
sanitize=False,
|
|
250
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Live channel values table — Position 2 reshape.
|
|
2
|
+
|
|
3
|
+
Subscribes to :class:`~testerkit.data.events.ChannelStarted` for discovery
|
|
4
|
+
(one row per channel that ever wrote in this run) and to the per-channel
|
|
5
|
+
ChannelStore Flight signal for live sample values.
|
|
6
|
+
|
|
7
|
+
Follows the live-UI rule (``docs/_internal/explorations/live-ui-pattern.md``):
|
|
8
|
+
the sample callback writes only a plain per-row holder; a single
|
|
9
|
+
``ui.timer`` is the sole renderer. Discovery (adding a row) and close
|
|
10
|
+
(marking a row) are structural and run on the UI loop via
|
|
11
|
+
:func:`ui_subscribe`. So nothing off the UI loop touches an element, and
|
|
12
|
+
the per-sample path never mutates UI directly.
|
|
13
|
+
|
|
14
|
+
This is the canonical operator view: a stable list of channels with their
|
|
15
|
+
latest reading, no per-sample event flood.
|
|
16
|
+
|
|
17
|
+
Push-based: no polling for data — the timer only paints what already arrived.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from uuid import UUID
|
|
25
|
+
|
|
26
|
+
from nicegui import ui
|
|
27
|
+
|
|
28
|
+
from testerkit.data.channels.models import ChannelSample
|
|
29
|
+
from testerkit.data.event_store import EventStore
|
|
30
|
+
from testerkit.ui.shared.event_binding import ui_channel_data, ui_subscribe
|
|
31
|
+
from testerkit.ui.shared.timestamps import format_time_short
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _ChannelRow:
|
|
36
|
+
"""Per-channel row: label handles + the latest reading the timer paints."""
|
|
37
|
+
|
|
38
|
+
val_lbl: ui.label
|
|
39
|
+
unit_lbl: ui.label
|
|
40
|
+
ts_lbl: ui.label
|
|
41
|
+
value: object = None
|
|
42
|
+
unit: str = ""
|
|
43
|
+
ts: str = ""
|
|
44
|
+
closed: bool = False
|
|
45
|
+
dirty: bool = True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_channel_values_panel(
|
|
49
|
+
store: EventStore,
|
|
50
|
+
*,
|
|
51
|
+
run_id: UUID | None = None,
|
|
52
|
+
) -> tuple[ui.column, Callable[[], None]]:
|
|
53
|
+
"""Auto-discover channels and show a live-values table.
|
|
54
|
+
|
|
55
|
+
``run_id`` filters the ``ChannelStarted`` discovery subscription to the
|
|
56
|
+
matching run — operators on ``/live/{run_id}`` only see channels from
|
|
57
|
+
the run they're looking at. ``None`` shows everything.
|
|
58
|
+
|
|
59
|
+
Live sample values come from :func:`ui_channel_data` (wired by the
|
|
60
|
+
application's :func:`bind_channel_store` at startup). The sample
|
|
61
|
+
callback only records the reading; a ``ui.timer`` paints it.
|
|
62
|
+
|
|
63
|
+
Returns ``(container, unsubscribe)`` so the caller can stop discovery
|
|
64
|
+
updates on page teardown.
|
|
65
|
+
"""
|
|
66
|
+
rows: dict[str, _ChannelRow] = {}
|
|
67
|
+
|
|
68
|
+
container = ui.column().classes("w-full gap-2")
|
|
69
|
+
|
|
70
|
+
with container:
|
|
71
|
+
placeholder = ui.label("No channels yet").classes("text-sm text-slate-400 italic")
|
|
72
|
+
# Table header (hidden until first data)
|
|
73
|
+
header = ui.row().classes("w-full px-3 py-1 border-b border-slate-200 hidden")
|
|
74
|
+
with header:
|
|
75
|
+
ui.label("Channel").classes("w-1/3 text-xs font-semibold text-slate-500")
|
|
76
|
+
ui.label("Value").classes("w-1/4 text-xs font-semibold text-slate-500")
|
|
77
|
+
ui.label("Units").classes("w-1/6 text-xs font-semibold text-slate-500")
|
|
78
|
+
ui.label("Last Update").classes("w-1/4 text-xs font-semibold text-slate-500")
|
|
79
|
+
rows_container = ui.column().classes("w-full gap-0")
|
|
80
|
+
|
|
81
|
+
placeholder_removed = False
|
|
82
|
+
|
|
83
|
+
def _format_value(value: object) -> str:
|
|
84
|
+
if isinstance(value, (int, float)):
|
|
85
|
+
return f"{value:.4g}"
|
|
86
|
+
if isinstance(value, list) and value and isinstance(value[0], (int, float)):
|
|
87
|
+
# Array sample — show length, not the whole array
|
|
88
|
+
return f"[{len(value)} samples]"
|
|
89
|
+
return "—" if value is None else str(value)
|
|
90
|
+
|
|
91
|
+
def _on_channel_started(evt: dict) -> None:
|
|
92
|
+
"""Discovery: add a row + a sample subscription that fills its holder."""
|
|
93
|
+
nonlocal placeholder_removed
|
|
94
|
+
|
|
95
|
+
ch_id = evt.get("channel_id")
|
|
96
|
+
if not ch_id or ch_id in rows:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if not placeholder_removed:
|
|
100
|
+
placeholder_removed = True
|
|
101
|
+
placeholder.delete()
|
|
102
|
+
header.classes(remove="hidden")
|
|
103
|
+
|
|
104
|
+
with rows_container:
|
|
105
|
+
with ui.row().classes("w-full px-3 py-1.5 border-b border-slate-100 items-center"):
|
|
106
|
+
ui.label(ch_id).classes("w-1/3 text-sm font-mono text-slate-700")
|
|
107
|
+
val_lbl = ui.label("—").classes("w-1/4 text-sm font-mono font-semibold")
|
|
108
|
+
unit_lbl = ui.label(evt.get("unit") or "").classes("w-1/6 text-xs text-slate-500")
|
|
109
|
+
ts_lbl = ui.label("").classes("w-1/4 text-xs text-slate-400")
|
|
110
|
+
|
|
111
|
+
row = _ChannelRow(val_lbl, unit_lbl, ts_lbl, unit=evt.get("unit") or "")
|
|
112
|
+
rows[ch_id] = row
|
|
113
|
+
|
|
114
|
+
# Sample callback: record the reading only — no UI mutation here.
|
|
115
|
+
def _on_sample(sample: ChannelSample, _row: _ChannelRow = row) -> None:
|
|
116
|
+
_row.value = sample.value
|
|
117
|
+
if sample.unit:
|
|
118
|
+
_row.unit = sample.unit
|
|
119
|
+
_row.ts = format_time_short(sample.received_at.isoformat())
|
|
120
|
+
_row.dirty = True
|
|
121
|
+
|
|
122
|
+
ui_channel_data(ch_id).subscribe(_on_sample)
|
|
123
|
+
|
|
124
|
+
def _on_channel_closed(evt: dict) -> None:
|
|
125
|
+
"""Lifecycle close — mark the row so the timer italicizes it."""
|
|
126
|
+
ch_id = evt.get("channel_id")
|
|
127
|
+
row = rows.get(ch_id) if ch_id else None
|
|
128
|
+
if row is not None:
|
|
129
|
+
row.closed = True
|
|
130
|
+
row.dirty = True
|
|
131
|
+
|
|
132
|
+
def _render() -> None:
|
|
133
|
+
"""Sole renderer: paint the rows whose holders changed."""
|
|
134
|
+
for row in rows.values():
|
|
135
|
+
if not row.dirty:
|
|
136
|
+
continue
|
|
137
|
+
row.dirty = False
|
|
138
|
+
row.val_lbl.set_text(_format_value(row.value))
|
|
139
|
+
row.unit_lbl.set_text(row.unit)
|
|
140
|
+
if row.closed:
|
|
141
|
+
row.ts_lbl.classes(add="italic")
|
|
142
|
+
row.ts_lbl.set_text(f"closed · {row.ts}" if row.ts else "closed")
|
|
143
|
+
else:
|
|
144
|
+
row.ts_lbl.set_text(row.ts)
|
|
145
|
+
|
|
146
|
+
ui.timer(0.25, _render)
|
|
147
|
+
|
|
148
|
+
unsub_started = ui_subscribe(
|
|
149
|
+
store, _on_channel_started, event_type="channel.started", run_id=run_id
|
|
150
|
+
)
|
|
151
|
+
unsub_closed = ui_subscribe(
|
|
152
|
+
store, _on_channel_closed, event_type="channel.ended", run_id=run_id
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def unsubscribe() -> None:
|
|
156
|
+
unsub_started()
|
|
157
|
+
unsub_closed()
|
|
158
|
+
|
|
159
|
+
return container, unsubscribe
|