reader-workbench 1.0.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.
- reader_workbench/__init__.py +22 -0
- reader_workbench/__main__.py +4 -0
- reader_workbench/_version.py +17 -0
- reader_workbench/api/__init__.py +74 -0
- reader_workbench/api/_record_reads.py +75 -0
- reader_workbench/api/artifacts.py +79 -0
- reader_workbench/api/facade.py +538 -0
- reader_workbench/api/models.py +285 -0
- reader_workbench/api/notebooks.py +63 -0
- reader_workbench/contracts/__init__.py +18 -0
- reader_workbench/contracts/builtins/__init__.py +36 -0
- reader_workbench/contracts/builtins/cytometry.py +140 -0
- reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
- reader_workbench/contracts/builtins/generic.py +21 -0
- reader_workbench/contracts/builtins/logic.py +149 -0
- reader_workbench/contracts/builtins/plate_reader.py +47 -0
- reader_workbench/contracts/catalog.py +257 -0
- reader_workbench/contracts/model.py +109 -0
- reader_workbench/domains/__init__.py +1 -0
- reader_workbench/domains/cytometry/__init__.py +3 -0
- reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
- reader_workbench/domains/cytometry/analysis/events.py +182 -0
- reader_workbench/domains/cytometry/analysis/gating.py +175 -0
- reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
- reader_workbench/domains/cytometry/io/__init__.py +3 -0
- reader_workbench/domains/cytometry/io/fcs.py +135 -0
- reader_workbench/domains/cytometry/plots/__init__.py +5 -0
- reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
- reader_workbench/domains/logic/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
- reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
- reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
- reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
- reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
- reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
- reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
- reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
- reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
- reader_workbench/domains/logic/four_state_vector/config.py +214 -0
- reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
- reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
- reader_workbench/domains/logic/four_state_vector/math.py +191 -0
- reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
- reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
- reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
- reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
- reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
- reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
- reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
- reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
- reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
- reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
- reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
- reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
- reader_workbench/domains/logic/treatment_columns.py +42 -0
- reader_workbench/domains/plate_reader/__init__.py +1 -0
- reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
- reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
- reader_workbench/domains/plate_reader/io/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
- reader_workbench/domains/plate_reader/ordering.py +59 -0
- reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
- reader_workbench/domains/plate_reader/plots/_data.py +29 -0
- reader_workbench/domains/plate_reader/plots/common.py +346 -0
- reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
- reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
- reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
- reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
- reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
- reader_workbench/domains/time_series/__init__.py +29 -0
- reader_workbench/domains/time_series/aggregation.py +60 -0
- reader_workbench/domains/time_series/contracts.py +368 -0
- reader_workbench/domains/time_series/reduction.py +395 -0
- reader_workbench/errors.py +55 -0
- reader_workbench/maintenance/__init__.py +6 -0
- reader_workbench/maintenance/docs.py +335 -0
- reader_workbench/maintenance/model.py +28 -0
- reader_workbench/maintenance/release.py +39 -0
- reader_workbench/maintenance/skills.py +124 -0
- reader_workbench/plotting/__init__.py +20 -0
- reader_workbench/plotting/mpl.py +56 -0
- reader_workbench/plotting/sinks.py +69 -0
- reader_workbench/plotting/style.py +175 -0
- reader_workbench/plotting/utils.py +27 -0
- reader_workbench/plugins/__init__.py +1 -0
- reader_workbench/plugins/catalog.py +33 -0
- reader_workbench/plugins/export/__init__.py +0 -0
- reader_workbench/plugins/export/_paths.py +21 -0
- reader_workbench/plugins/export/csv.py +41 -0
- reader_workbench/plugins/export/xlsx.py +44 -0
- reader_workbench/plugins/ingest/__init__.py +0 -0
- reader_workbench/plugins/ingest/_discovery.py +58 -0
- reader_workbench/plugins/ingest/discovery_policy.py +66 -0
- reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
- reader_workbench/plugins/ingest/synergy_h1.py +234 -0
- reader_workbench/plugins/manifests/__init__.py +1 -0
- reader_workbench/plugins/manifests/export.py +29 -0
- reader_workbench/plugins/manifests/ingest.py +29 -0
- reader_workbench/plugins/manifests/plot.py +161 -0
- reader_workbench/plugins/manifests/transform.py +172 -0
- reader_workbench/plugins/manifests/validator.py +18 -0
- reader_workbench/plugins/plot/__init__.py +0 -0
- reader_workbench/plugins/plot/_shared.py +55 -0
- reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
- reader_workbench/plugins/plot/distributions.py +58 -0
- reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
- reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
- reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
- reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
- reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
- reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
- reader_workbench/plugins/plot/logic_symmetry.py +56 -0
- reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
- reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
- reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
- reader_workbench/plugins/plot/time_series.py +114 -0
- reader_workbench/plugins/plot/ts_and_snap.py +210 -0
- reader_workbench/plugins/transform/__init__.py +0 -0
- reader_workbench/plugins/transform/_four_state_vector.py +204 -0
- reader_workbench/plugins/transform/_labeling.py +109 -0
- reader_workbench/plugins/transform/alias.py +70 -0
- reader_workbench/plugins/transform/assay_labels.py +62 -0
- reader_workbench/plugins/transform/blank.py +79 -0
- reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
- reader_workbench/plugins/transform/cytometry_gating.py +120 -0
- reader_workbench/plugins/transform/fold_change.py +79 -0
- reader_workbench/plugins/transform/four_state_event_window.py +93 -0
- reader_workbench/plugins/transform/four_state_vector.py +62 -0
- reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
- reader_workbench/plugins/transform/logic_symmetry.py +67 -0
- reader_workbench/plugins/transform/outlier_filter.py +60 -0
- reader_workbench/plugins/transform/overflow.py +197 -0
- reader_workbench/plugins/transform/ratio.py +237 -0
- reader_workbench/plugins/transform/sample_map.py +170 -0
- reader_workbench/plugins/transform/sample_metadata.py +94 -0
- reader_workbench/plugins/validator/__init__.py +1 -0
- reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
- reader_workbench/protocols/__init__.py +80 -0
- reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
- reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
- reader_workbench/protocols/builtins.py +1656 -0
- reader_workbench/protocols/compiler.py +22 -0
- reader_workbench/protocols/compilers/__init__.py +1 -0
- reader_workbench/protocols/compilers/common.py +100 -0
- reader_workbench/protocols/compilers/cytometry.py +87 -0
- reader_workbench/protocols/compilers/generic.py +14 -0
- reader_workbench/protocols/compilers/logic.py +245 -0
- reader_workbench/protocols/compilers/plate_reader.py +937 -0
- reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
- reader_workbench/protocols/model.py +1486 -0
- reader_workbench/protocols/semantic_coverage.py +234 -0
- reader_workbench/runtime/__init__.py +12 -0
- reader_workbench/runtime/builtin.py +23 -0
- reader_workbench/runtime/model.py +42 -0
- reader_workbench/workbench/__init__.py +60 -0
- reader_workbench/workbench/assets/__init__.py +22 -0
- reader_workbench/workbench/assets/types.py +118 -0
- reader_workbench/workbench/audit/__init__.py +5 -0
- reader_workbench/workbench/audit/experiments.py +307 -0
- reader_workbench/workbench/audit/staging.py +187 -0
- reader_workbench/workbench/cli/__init__.py +51 -0
- reader_workbench/workbench/cli/_lazy.py +9 -0
- reader_workbench/workbench/cli/_records_view.py +150 -0
- reader_workbench/workbench/cli/_surface_execution.py +443 -0
- reader_workbench/workbench/cli/audit.py +95 -0
- reader_workbench/workbench/cli/automation.py +229 -0
- reader_workbench/workbench/cli/demo.py +46 -0
- reader_workbench/workbench/cli/dop.py +91 -0
- reader_workbench/workbench/cli/experiments.py +635 -0
- reader_workbench/workbench/cli/helpers.py +232 -0
- reader_workbench/workbench/cli/main.py +59 -0
- reader_workbench/workbench/cli/maintenance.py +82 -0
- reader_workbench/workbench/cli/notebooks.py +260 -0
- reader_workbench/workbench/cli/pagination.py +117 -0
- reader_workbench/workbench/cli/protocols.py +336 -0
- reader_workbench/workbench/cli/shared.py +309 -0
- reader_workbench/workbench/cli/surfaces.py +534 -0
- reader_workbench/workbench/cli/verification.py +128 -0
- reader_workbench/workbench/commands.py +10 -0
- reader_workbench/workbench/config/__init__.py +47 -0
- reader_workbench/workbench/config/identity.py +13 -0
- reader_workbench/workbench/config/load.py +405 -0
- reader_workbench/workbench/config/model.py +274 -0
- reader_workbench/workbench/context.py +26 -0
- reader_workbench/workbench/decl/__init__.py +31 -0
- reader_workbench/workbench/decl/build.py +190 -0
- reader_workbench/workbench/decl/model.py +81 -0
- reader_workbench/workbench/dop/__init__.py +12 -0
- reader_workbench/workbench/dop/builtins.py +261 -0
- reader_workbench/workbench/dop/model.py +209 -0
- reader_workbench/workbench/engine/__init__.py +42 -0
- reader_workbench/workbench/engine/_shared.py +76 -0
- reader_workbench/workbench/engine/contracts.py +283 -0
- reader_workbench/workbench/engine/execution.py +326 -0
- reader_workbench/workbench/engine/file_outputs.py +260 -0
- reader_workbench/workbench/engine/inputs.py +161 -0
- reader_workbench/workbench/engine/invocations.py +507 -0
- reader_workbench/workbench/engine/planning.py +72 -0
- reader_workbench/workbench/engine/runtime.py +464 -0
- reader_workbench/workbench/engine/setup.py +149 -0
- reader_workbench/workbench/engine/validation.py +684 -0
- reader_workbench/workbench/experiment/__init__.py +47 -0
- reader_workbench/workbench/experiment/model.py +381 -0
- reader_workbench/workbench/experiments.py +133 -0
- reader_workbench/workbench/graph/__init__.py +47 -0
- reader_workbench/workbench/graph/nodes.py +102 -0
- reader_workbench/workbench/graph/normalize.py +177 -0
- reader_workbench/workbench/graph/refs.py +148 -0
- reader_workbench/workbench/input_discovery.py +19 -0
- reader_workbench/workbench/inspection/__init__.py +3 -0
- reader_workbench/workbench/inspection/catalogs.py +128 -0
- reader_workbench/workbench/inspection/common.py +92 -0
- reader_workbench/workbench/inspection/dop.py +64 -0
- reader_workbench/workbench/inspection/experiments.py +449 -0
- reader_workbench/workbench/inspection/inventory.py +68 -0
- reader_workbench/workbench/inspection/protocols.py +368 -0
- reader_workbench/workbench/inspection/readiness.py +333 -0
- reader_workbench/workbench/inspection/reports.py +367 -0
- reader_workbench/workbench/inspection/results.py +166 -0
- reader_workbench/workbench/inspection/runtime.py +287 -0
- reader_workbench/workbench/inspection/semantics.py +192 -0
- reader_workbench/workbench/inspection/validation.py +30 -0
- reader_workbench/workbench/notebooks/__init__.py +17 -0
- reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
- reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
- reader_workbench/workbench/notebooks/components/__init__.py +21 -0
- reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
- reader_workbench/workbench/notebooks/components/overview.py +119 -0
- reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
- reader_workbench/workbench/notebooks/launch.py +274 -0
- reader_workbench/workbench/notebooks/presentation.py +136 -0
- reader_workbench/workbench/notebooks/scaffold.py +60 -0
- reader_workbench/workbench/ontology.py +78 -0
- reader_workbench/workbench/paths.py +44 -0
- reader_workbench/workbench/ports/__init__.py +31 -0
- reader_workbench/workbench/ports/model.py +168 -0
- reader_workbench/workbench/records/__init__.py +44 -0
- reader_workbench/workbench/records/epoch.py +329 -0
- reader_workbench/workbench/records/evidence.py +247 -0
- reader_workbench/workbench/records/identity.py +87 -0
- reader_workbench/workbench/records/locking.py +185 -0
- reader_workbench/workbench/records/model.py +711 -0
- reader_workbench/workbench/records/sources.py +73 -0
- reader_workbench/workbench/records/store.py +1022 -0
- reader_workbench/workbench/records/verification.py +998 -0
- reader_workbench/workbench/registry.py +333 -0
- reader_workbench/workbench/spec_overrides.py +215 -0
- reader_workbench-1.0.0.dist-info/METADATA +91 -0
- reader_workbench-1.0.0.dist-info/RECORD +293 -0
- reader_workbench-1.0.0.dist-info/WHEEL +5 -0
- reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
- reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
- reader_workbench-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.theme import Theme
|
|
11
|
+
|
|
12
|
+
from reader_workbench.errors import ConfigError, ContractError, RecordError
|
|
13
|
+
from reader_workbench.protocols import ProtocolBinding
|
|
14
|
+
from reader_workbench.runtime import ReaderRuntime, builtin_runtime
|
|
15
|
+
from reader_workbench.workbench.config import ReaderSpec
|
|
16
|
+
from reader_workbench.workbench.decl import build_workbench_decl
|
|
17
|
+
from reader_workbench.workbench.engine import normalize_log_level, run_spec, validation_summary
|
|
18
|
+
from reader_workbench.workbench.graph import resolve_workbench, select_workbench_specs
|
|
19
|
+
from reader_workbench.workbench.inspection.catalogs import workbench_surface_specs_payload
|
|
20
|
+
from reader_workbench.workbench.inspection.experiments import (
|
|
21
|
+
experiment_explain_payload,
|
|
22
|
+
experiment_identity_payload,
|
|
23
|
+
experiment_inspect_payload,
|
|
24
|
+
)
|
|
25
|
+
from reader_workbench.workbench.inspection.results import record_catalog_payload
|
|
26
|
+
from reader_workbench.workbench.inspection.runtime import workbench_record_ids, workbench_record_verification_scope
|
|
27
|
+
from reader_workbench.workbench.notebooks import CANONICAL_NOTEBOOK_ID, write_experiment_notebook
|
|
28
|
+
from reader_workbench.workbench.paths import resolve_confined_sink_root
|
|
29
|
+
from reader_workbench.workbench.records import (
|
|
30
|
+
DataFrameArtifactRecord,
|
|
31
|
+
verify_record_store,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from ._record_reads import resolve_record_revision
|
|
35
|
+
from .models import (
|
|
36
|
+
DataFrameRecordResult,
|
|
37
|
+
Experiment,
|
|
38
|
+
ExperimentEvidence,
|
|
39
|
+
ExperimentIdentity,
|
|
40
|
+
InspectionResult,
|
|
41
|
+
NotebookResult,
|
|
42
|
+
PlanResult,
|
|
43
|
+
PluginCatalogResult,
|
|
44
|
+
PluginDescriptorResult,
|
|
45
|
+
PluginPort,
|
|
46
|
+
PluginSummary,
|
|
47
|
+
RecordCatalogResult,
|
|
48
|
+
RecordRevision,
|
|
49
|
+
RunResult,
|
|
50
|
+
SelectedSteps,
|
|
51
|
+
SurfaceCatalogResult,
|
|
52
|
+
ValidationResult,
|
|
53
|
+
VerificationResult,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_SILENT_CONSOLE_THEME = Theme(
|
|
57
|
+
{
|
|
58
|
+
"title": "bold cyan",
|
|
59
|
+
"accent": "cyan",
|
|
60
|
+
"ok": "bold green",
|
|
61
|
+
"warn": "bold yellow",
|
|
62
|
+
"error": "bold red",
|
|
63
|
+
"muted": "dim",
|
|
64
|
+
"path": "magenta",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def open_experiment(path: str | Path, *, runtime: ReaderRuntime | None = None) -> Experiment:
|
|
70
|
+
"""Load and compile one experiment without creating experiment outputs."""
|
|
71
|
+
|
|
72
|
+
config_path = Path(path).expanduser().resolve()
|
|
73
|
+
if config_path.is_dir():
|
|
74
|
+
config_path = config_path / "config.yaml"
|
|
75
|
+
elif config_path.is_file() and config_path.name != "config.yaml":
|
|
76
|
+
config_path = next(
|
|
77
|
+
(base / "config.yaml" for base in config_path.parents if (base / "config.yaml").is_file()),
|
|
78
|
+
config_path,
|
|
79
|
+
)
|
|
80
|
+
if not config_path.exists() or not config_path.is_file():
|
|
81
|
+
raise ConfigError(f"Experiment config not found: {config_path}")
|
|
82
|
+
active_runtime = runtime or builtin_runtime()
|
|
83
|
+
spec = ReaderSpec.load(config_path)
|
|
84
|
+
declaration = build_workbench_decl(spec, source_path=config_path, protocols=active_runtime.protocols)
|
|
85
|
+
return Experiment(
|
|
86
|
+
config_path=config_path,
|
|
87
|
+
_spec=spec,
|
|
88
|
+
_declaration=declaration,
|
|
89
|
+
_runtime=active_runtime,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def inspect(experiment: Experiment) -> InspectionResult:
|
|
94
|
+
payload = experiment_inspect_payload(
|
|
95
|
+
job_path=experiment.config_path,
|
|
96
|
+
spec=experiment._spec,
|
|
97
|
+
decl=experiment._declaration,
|
|
98
|
+
runtime=experiment._runtime,
|
|
99
|
+
)
|
|
100
|
+
return InspectionResult(
|
|
101
|
+
experiment=_identity(payload["experiment"]),
|
|
102
|
+
authoring=deepcopy(payload["authoring"]),
|
|
103
|
+
semantics=deepcopy(payload["semantics"]),
|
|
104
|
+
implementation=deepcopy(payload["implementation"]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate(experiment: Experiment, *, check_files: bool = True) -> ValidationResult:
|
|
109
|
+
summary = validation_summary(
|
|
110
|
+
experiment._declaration,
|
|
111
|
+
check_files=check_files,
|
|
112
|
+
exp_root=experiment._declaration.experiment.root,
|
|
113
|
+
runtime=experiment._runtime,
|
|
114
|
+
)
|
|
115
|
+
summary_copy = deepcopy(summary)
|
|
116
|
+
status = str(summary_copy.pop("status"))
|
|
117
|
+
summary_fields = {
|
|
118
|
+
"checks": summary_copy.pop("checks"),
|
|
119
|
+
"counts": summary_copy.pop("counts"),
|
|
120
|
+
}
|
|
121
|
+
return ValidationResult(
|
|
122
|
+
experiment=experiment.identity,
|
|
123
|
+
check_files=check_files,
|
|
124
|
+
status=status,
|
|
125
|
+
summary=summary_fields,
|
|
126
|
+
validation=summary_copy,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def plan(experiment: Experiment) -> PlanResult:
|
|
131
|
+
payload = experiment_explain_payload(
|
|
132
|
+
job_path=experiment.config_path,
|
|
133
|
+
spec=experiment._spec,
|
|
134
|
+
decl=experiment._declaration,
|
|
135
|
+
runtime=experiment._runtime,
|
|
136
|
+
)
|
|
137
|
+
implementation = payload["implementation"]
|
|
138
|
+
return PlanResult(
|
|
139
|
+
experiment=_identity(payload["experiment"]),
|
|
140
|
+
plan=deepcopy(implementation["plan"]),
|
|
141
|
+
compiled=deepcopy(implementation["compiled"]),
|
|
142
|
+
semantics=deepcopy(payload["semantics"]),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def notebook(
|
|
147
|
+
experiment: Experiment,
|
|
148
|
+
*,
|
|
149
|
+
name: str | None = None,
|
|
150
|
+
overwrite: bool = False,
|
|
151
|
+
) -> NotebookResult:
|
|
152
|
+
"""Generate Reader's canonical notebook inside the experiment outputs."""
|
|
153
|
+
|
|
154
|
+
decl = experiment._declaration
|
|
155
|
+
target_name = name or f"EDA_{datetime.now().strftime('%Y%m%d')}.py"
|
|
156
|
+
candidate = Path(target_name)
|
|
157
|
+
if (
|
|
158
|
+
not target_name
|
|
159
|
+
or target_name != target_name.strip()
|
|
160
|
+
or candidate.is_absolute()
|
|
161
|
+
or len(candidate.parts) != 1
|
|
162
|
+
or candidate.suffix != ".py"
|
|
163
|
+
):
|
|
164
|
+
raise ConfigError("Notebook name must be one non-empty .py filename")
|
|
165
|
+
layout = decl.experiment_semantics.layout
|
|
166
|
+
notebooks_root = (
|
|
167
|
+
layout.outputs_dir
|
|
168
|
+
if layout.notebooks_subdir in {"", ".", "./"}
|
|
169
|
+
else layout.outputs_dir / layout.notebooks_subdir
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
notebooks_root = resolve_confined_sink_root(
|
|
173
|
+
notebooks_root,
|
|
174
|
+
root=decl.experiment.root,
|
|
175
|
+
label="notebooks",
|
|
176
|
+
)
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
raise ConfigError(str(exc)) from exc
|
|
179
|
+
path, created = write_experiment_notebook(
|
|
180
|
+
notebooks_root / target_name,
|
|
181
|
+
experiment_root=decl.experiment.root,
|
|
182
|
+
notebooks_root=notebooks_root,
|
|
183
|
+
overwrite=overwrite,
|
|
184
|
+
)
|
|
185
|
+
return NotebookResult(
|
|
186
|
+
experiment=experiment.identity,
|
|
187
|
+
path=str(path),
|
|
188
|
+
template=CANONICAL_NOTEBOOK_ID,
|
|
189
|
+
created=created,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def plots(
|
|
194
|
+
experiment: Experiment,
|
|
195
|
+
*,
|
|
196
|
+
only: Iterable[str] = (),
|
|
197
|
+
exclude: Iterable[str] = (),
|
|
198
|
+
) -> SurfaceCatalogResult:
|
|
199
|
+
only_ids = list(only)
|
|
200
|
+
exclude_ids = list(exclude)
|
|
201
|
+
workbench = resolve_workbench(experiment._declaration)
|
|
202
|
+
selected = select_workbench_specs(
|
|
203
|
+
list(workbench.plots),
|
|
204
|
+
only=only_ids,
|
|
205
|
+
exclude=exclude_ids,
|
|
206
|
+
kind_label="plot",
|
|
207
|
+
)
|
|
208
|
+
bound_protocol = experiment._runtime.bind_protocol(experiment._declaration.experiment_semantics.protocol)
|
|
209
|
+
payload = workbench_surface_specs_payload(
|
|
210
|
+
job_path=experiment.config_path,
|
|
211
|
+
decl=experiment._declaration,
|
|
212
|
+
runtime=experiment._runtime,
|
|
213
|
+
bound_protocol=bound_protocol,
|
|
214
|
+
selected=selected,
|
|
215
|
+
kind="plot",
|
|
216
|
+
only=only_ids,
|
|
217
|
+
exclude=exclude_ids,
|
|
218
|
+
)
|
|
219
|
+
return SurfaceCatalogResult(
|
|
220
|
+
experiment=_identity(payload["experiment"]),
|
|
221
|
+
kind="plot",
|
|
222
|
+
protocol=str(payload["catalog"]["protocol"]),
|
|
223
|
+
selection=deepcopy(payload["selection"]),
|
|
224
|
+
summary=deepcopy(payload["summary"]),
|
|
225
|
+
entries=tuple(deepcopy(payload["plots"])),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def records(experiment: Experiment, *, include_history: bool = False) -> RecordCatalogResult:
|
|
230
|
+
decl = experiment._declaration
|
|
231
|
+
layout = decl.experiment_semantics.layout
|
|
232
|
+
outputs_dir = layout.outputs_dir
|
|
233
|
+
store = experiment._runtime.record_store(
|
|
234
|
+
outputs_dir,
|
|
235
|
+
plots_subdir=layout.plots_subdir,
|
|
236
|
+
exports_subdir=layout.exports_subdir,
|
|
237
|
+
experiment_root=decl.experiment.root,
|
|
238
|
+
create=False,
|
|
239
|
+
)
|
|
240
|
+
if not store.catalog_exists():
|
|
241
|
+
return RecordCatalogResult(
|
|
242
|
+
experiment=experiment.identity,
|
|
243
|
+
catalog_exists=False,
|
|
244
|
+
catalog={
|
|
245
|
+
"path": str(store.records_path),
|
|
246
|
+
"outputs_root": str(outputs_dir),
|
|
247
|
+
"schema_version": None,
|
|
248
|
+
"provenance_epoch_id": None,
|
|
249
|
+
"active_invocation_ledger": None,
|
|
250
|
+
},
|
|
251
|
+
include_history=include_history,
|
|
252
|
+
summary={
|
|
253
|
+
"records": 0,
|
|
254
|
+
"history": {"included": include_history, "revisions": 0 if include_history else None},
|
|
255
|
+
"by_kind": {},
|
|
256
|
+
"by_producer": {},
|
|
257
|
+
},
|
|
258
|
+
entries=(),
|
|
259
|
+
)
|
|
260
|
+
current_record_ids = frozenset()
|
|
261
|
+
if not include_history:
|
|
262
|
+
workbench = resolve_workbench(decl)
|
|
263
|
+
current_record_ids = workbench_record_ids(workbench, runtime=experiment._runtime)
|
|
264
|
+
payload = record_catalog_payload(
|
|
265
|
+
experiment=experiment_identity_payload(job_path=experiment.config_path, decl=decl),
|
|
266
|
+
store=store,
|
|
267
|
+
outputs_dir=outputs_dir,
|
|
268
|
+
runtime=experiment._runtime,
|
|
269
|
+
include_history=include_history,
|
|
270
|
+
current_config_digest=decl.config_digest,
|
|
271
|
+
declared_record_ids=current_record_ids,
|
|
272
|
+
)
|
|
273
|
+
return RecordCatalogResult(
|
|
274
|
+
experiment=_identity(payload["experiment"]),
|
|
275
|
+
catalog_exists=True,
|
|
276
|
+
catalog=deepcopy(payload["catalog"]),
|
|
277
|
+
include_history=include_history,
|
|
278
|
+
summary=deepcopy(payload["summary"]),
|
|
279
|
+
entries=tuple(deepcopy(payload["records"])),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def read_dataframe(
|
|
284
|
+
experiment: Experiment,
|
|
285
|
+
record_id: str,
|
|
286
|
+
*,
|
|
287
|
+
revision: int | None = None,
|
|
288
|
+
revision_digest: str | None = None,
|
|
289
|
+
row_limit: int | None = None,
|
|
290
|
+
) -> DataFrameRecordResult:
|
|
291
|
+
"""Load and validate an exact dataframe revision, optionally bounded by rows."""
|
|
292
|
+
|
|
293
|
+
store, record, record_revision = resolve_record_revision(
|
|
294
|
+
experiment,
|
|
295
|
+
record_id,
|
|
296
|
+
revision=revision,
|
|
297
|
+
revision_digest=revision_digest,
|
|
298
|
+
missing_label="Dataframe record",
|
|
299
|
+
)
|
|
300
|
+
if not isinstance(record, DataFrameArtifactRecord):
|
|
301
|
+
raise RecordError(f"Record {record_id!r} exists but is not a dataframe artifact")
|
|
302
|
+
dataframe = record.load_dataframe() if row_limit is None else record.load_dataframe(row_limit=row_limit)
|
|
303
|
+
try:
|
|
304
|
+
store.contracts.validate(
|
|
305
|
+
dataframe,
|
|
306
|
+
contract_id=record.contract_id,
|
|
307
|
+
where=f"public-api:{record.record_id}",
|
|
308
|
+
)
|
|
309
|
+
except ContractError as exc:
|
|
310
|
+
raise RecordError(
|
|
311
|
+
f"Dataframe record {record.record_id!r} violates recorded contract {record.contract_id!r}: {exc}"
|
|
312
|
+
) from exc
|
|
313
|
+
return DataFrameRecordResult(
|
|
314
|
+
experiment=experiment.identity,
|
|
315
|
+
record=record_revision,
|
|
316
|
+
contract_id=record.contract_id,
|
|
317
|
+
content_digest=record.content_digest,
|
|
318
|
+
dataframe=dataframe,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def verify(experiment: Experiment) -> VerificationResult:
|
|
323
|
+
"""Verify the current catalog without creating or changing experiment outputs."""
|
|
324
|
+
decl = experiment._declaration
|
|
325
|
+
workbench = resolve_workbench(decl)
|
|
326
|
+
layout = decl.experiment_semantics.layout
|
|
327
|
+
store = experiment._runtime.record_store(
|
|
328
|
+
layout.outputs_dir,
|
|
329
|
+
plots_subdir=layout.plots_subdir,
|
|
330
|
+
exports_subdir=layout.exports_subdir,
|
|
331
|
+
experiment_root=decl.experiment.root,
|
|
332
|
+
create=False,
|
|
333
|
+
)
|
|
334
|
+
report = verify_record_store(
|
|
335
|
+
store,
|
|
336
|
+
experiment_root=decl.experiment.root,
|
|
337
|
+
expected_config_digest=decl.config_digest,
|
|
338
|
+
scope=workbench_record_verification_scope(workbench, runtime=experiment._runtime),
|
|
339
|
+
)
|
|
340
|
+
return VerificationResult(
|
|
341
|
+
experiment=experiment.identity,
|
|
342
|
+
status=str(report["status"]),
|
|
343
|
+
summary=deepcopy(report["summary"]),
|
|
344
|
+
issues=tuple(deepcopy(report["issues"])),
|
|
345
|
+
records=tuple(deepcopy(report["records"])),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def run(
|
|
350
|
+
experiment: Experiment,
|
|
351
|
+
*,
|
|
352
|
+
from_step: str | None = None,
|
|
353
|
+
until_step: str | None = None,
|
|
354
|
+
only: str | None = None,
|
|
355
|
+
reset_records: bool = False,
|
|
356
|
+
dry_run: bool = False,
|
|
357
|
+
log_level: str = "INFO",
|
|
358
|
+
) -> RunResult:
|
|
359
|
+
"""Run an experiment pipeline through the same execution path as the CLI."""
|
|
360
|
+
|
|
361
|
+
from_step = _step_selector(from_step, field="from_step")
|
|
362
|
+
until_step = _step_selector(until_step, field="until_step")
|
|
363
|
+
only = _step_selector(only, field="only")
|
|
364
|
+
if only is not None and (from_step is not None or until_step is not None):
|
|
365
|
+
raise ConfigError("only cannot be combined with from_step or until_step")
|
|
366
|
+
if reset_records and (only is not None or from_step is not None or until_step is not None):
|
|
367
|
+
raise ConfigError("reset_records requires a complete run without from_step, until_step, or only")
|
|
368
|
+
if reset_records and dry_run:
|
|
369
|
+
raise ConfigError("reset_records cannot be combined with dry_run")
|
|
370
|
+
normalize_log_level(log_level)
|
|
371
|
+
if not dry_run and experiment._declaration.experiment.lifecycle != "active":
|
|
372
|
+
lifecycle = experiment._declaration.experiment.lifecycle
|
|
373
|
+
raise ConfigError(f"Experiment lifecycle {lifecycle!r} is not runnable")
|
|
374
|
+
|
|
375
|
+
execution = run_spec(
|
|
376
|
+
experiment._declaration,
|
|
377
|
+
resume_from=only if only is not None else from_step,
|
|
378
|
+
until=only if only is not None else until_step,
|
|
379
|
+
reset_records=reset_records,
|
|
380
|
+
dry_run=dry_run,
|
|
381
|
+
log_level=log_level,
|
|
382
|
+
verbose=False,
|
|
383
|
+
console=Console(quiet=True, theme=_SILENT_CONSOLE_THEME),
|
|
384
|
+
include_pipeline=True,
|
|
385
|
+
include_plots=False,
|
|
386
|
+
include_exports=False,
|
|
387
|
+
show_next_steps=False,
|
|
388
|
+
runtime=experiment._runtime,
|
|
389
|
+
)
|
|
390
|
+
return RunResult(
|
|
391
|
+
experiment=experiment.identity,
|
|
392
|
+
invocation_id=execution.invocation_id,
|
|
393
|
+
provenance_epoch_id=execution.provenance_epoch_id,
|
|
394
|
+
operation=execution.operation,
|
|
395
|
+
status=execution.status,
|
|
396
|
+
dry_run=execution.dry_run,
|
|
397
|
+
selected_steps=SelectedSteps(
|
|
398
|
+
pipeline=execution.selected_steps.pipeline,
|
|
399
|
+
plots=execution.selected_steps.plots,
|
|
400
|
+
exports=execution.selected_steps.exports,
|
|
401
|
+
),
|
|
402
|
+
produced_record_revisions=tuple(
|
|
403
|
+
RecordRevision(
|
|
404
|
+
record_id=revision.record_id,
|
|
405
|
+
revision=revision.revision,
|
|
406
|
+
revision_digest=revision.revision_digest,
|
|
407
|
+
)
|
|
408
|
+
for revision in execution.produced_record_revisions
|
|
409
|
+
),
|
|
410
|
+
ledger_path=str(execution.ledger_path) if execution.ledger_path is not None else None,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _step_selector(value: str | None, *, field: str) -> str | None:
|
|
415
|
+
if value is None:
|
|
416
|
+
return None
|
|
417
|
+
if not isinstance(value, str) or not value.strip():
|
|
418
|
+
raise ConfigError(f"{field} must be a non-empty step id")
|
|
419
|
+
return value.strip()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def plugins(
|
|
423
|
+
*,
|
|
424
|
+
category: str | None = None,
|
|
425
|
+
domain: str | None = None,
|
|
426
|
+
family: str | None = None,
|
|
427
|
+
protocol: str | None = None,
|
|
428
|
+
runtime: ReaderRuntime | None = None,
|
|
429
|
+
) -> PluginCatalogResult:
|
|
430
|
+
active_runtime = runtime or builtin_runtime()
|
|
431
|
+
descriptors = list(active_runtime.plugins.catalog().filter(category=category, domain=domain, family=family))
|
|
432
|
+
if protocol is not None:
|
|
433
|
+
bound_protocol = active_runtime.bind_protocol(ProtocolBinding(id=protocol))
|
|
434
|
+
compiled = bound_protocol.compile()
|
|
435
|
+
allowed = {step.plugin for step in (*compiled.pipeline, *compiled.plots, *compiled.exports)}
|
|
436
|
+
descriptors = [descriptor for descriptor in descriptors if descriptor.plugin in allowed]
|
|
437
|
+
plugin_items = tuple(_plugin_summary(descriptor) for descriptor in descriptors)
|
|
438
|
+
return PluginCatalogResult(
|
|
439
|
+
selection={"category": category, "domain": domain, "family": family, "protocol": protocol},
|
|
440
|
+
summary={
|
|
441
|
+
"plugins": len(plugin_items),
|
|
442
|
+
"by_category": _counts(item.category for item in plugin_items),
|
|
443
|
+
"by_domain": _counts(item.domain for item in plugin_items),
|
|
444
|
+
"by_family": _counts(item.family for item in plugin_items),
|
|
445
|
+
},
|
|
446
|
+
plugins=plugin_items,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def describe_plugin(
|
|
451
|
+
plugin_id: str,
|
|
452
|
+
*,
|
|
453
|
+
runtime: ReaderRuntime | None = None,
|
|
454
|
+
) -> PluginDescriptorResult:
|
|
455
|
+
active_runtime = runtime or builtin_runtime()
|
|
456
|
+
descriptor = active_runtime.plugins.resolve_descriptor(plugin_id)
|
|
457
|
+
plugin_cls = descriptor.cls
|
|
458
|
+
return PluginDescriptorResult(
|
|
459
|
+
plugin=_plugin_summary(descriptor),
|
|
460
|
+
config_schema=deepcopy(plugin_cls.ConfigModel.model_json_schema()),
|
|
461
|
+
input_ports=tuple(
|
|
462
|
+
PluginPort(
|
|
463
|
+
name=port.name,
|
|
464
|
+
kind=port.kind,
|
|
465
|
+
optional=port.optional,
|
|
466
|
+
contract=port.contract,
|
|
467
|
+
)
|
|
468
|
+
for _, port in sorted(plugin_cls.input_ports().items())
|
|
469
|
+
),
|
|
470
|
+
output_ports=tuple(
|
|
471
|
+
PluginPort(
|
|
472
|
+
name=port.name,
|
|
473
|
+
kind=port.kind,
|
|
474
|
+
optional=False,
|
|
475
|
+
contract=port.contract,
|
|
476
|
+
contract_surface=_contract_surface(port),
|
|
477
|
+
)
|
|
478
|
+
for _, port in sorted(plugin_cls.output_ports().items())
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _identity(payload: object) -> ExperimentIdentity:
|
|
484
|
+
if not isinstance(payload, dict):
|
|
485
|
+
raise TypeError("Experiment identity payload must be a mapping")
|
|
486
|
+
evidence_payload = payload.get("evidence")
|
|
487
|
+
evidence = None
|
|
488
|
+
if evidence_payload is not None:
|
|
489
|
+
if not isinstance(evidence_payload, dict):
|
|
490
|
+
raise TypeError("Experiment evidence payload must be a mapping when present")
|
|
491
|
+
evidence = ExperimentEvidence(
|
|
492
|
+
data_class=str(evidence_payload["data_class"]),
|
|
493
|
+
data_class_reason=str(evidence_payload["data_class_reason"]),
|
|
494
|
+
replicate_kind=str(evidence_payload["replicate_kind"]),
|
|
495
|
+
replicate_identity_field=(
|
|
496
|
+
str(evidence_payload["replicate_identity_field"])
|
|
497
|
+
if evidence_payload.get("replicate_identity_field") is not None
|
|
498
|
+
else None
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
return ExperimentIdentity(
|
|
502
|
+
id=str(payload["id"]),
|
|
503
|
+
title=str(payload["title"]),
|
|
504
|
+
lifecycle=str(payload["lifecycle"]),
|
|
505
|
+
protocol=str(payload["protocol"]),
|
|
506
|
+
config=str(payload["config"]),
|
|
507
|
+
root=str(payload["root"]),
|
|
508
|
+
evidence=evidence,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _plugin_summary(descriptor) -> PluginSummary:
|
|
513
|
+
return PluginSummary(
|
|
514
|
+
plugin=descriptor.plugin,
|
|
515
|
+
key=descriptor.key,
|
|
516
|
+
category=str(descriptor.category),
|
|
517
|
+
domain=descriptor.domain,
|
|
518
|
+
family=descriptor.family,
|
|
519
|
+
summary=descriptor.summary,
|
|
520
|
+
implementation=f"{descriptor.cls.__module__}.{descriptor.cls.__name__}",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _contract_surface(port) -> dict[str, object] | None:
|
|
525
|
+
surface = port.contract_surface
|
|
526
|
+
if surface is None:
|
|
527
|
+
return None
|
|
528
|
+
return {
|
|
529
|
+
"minimum": surface.minimum,
|
|
530
|
+
"runtime_mode": surface.runtime_mode,
|
|
531
|
+
"promoted": list(surface.promoted),
|
|
532
|
+
"note": surface.note,
|
|
533
|
+
"rendered": surface.render(),
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _counts(values: Iterable[str]) -> dict[str, int]:
|
|
538
|
+
return dict(sorted(Counter(value for value in values if value).items()))
|