pycontrol-core 3.0.0a1__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.
- pycontrol/__init__.py +5 -0
- pycontrol/bundles/__init__.py +48 -0
- pycontrol/bundles/collect.py +233 -0
- pycontrol/bundles/legacy.py +102 -0
- pycontrol/bundles/models.py +200 -0
- pycontrol/bundles/protocol.py +140 -0
- pycontrol/bundles/run.py +271 -0
- pycontrol/bundles/zipio.py +194 -0
- pycontrol/bus/__init__.py +31 -0
- pycontrol/bus/enriched.py +22 -0
- pycontrol/bus/events.py +91 -0
- pycontrol/bus/subscriber.py +115 -0
- pycontrol/bus/timestamps.py +16 -0
- pycontrol/cli/__init__.py +7 -0
- pycontrol/cli/commands/__init__.py +5 -0
- pycontrol/cli/commands/board.py +35 -0
- pycontrol/cli/commands/experiment.py +27 -0
- pycontrol/cli/commands/firmware.py +58 -0
- pycontrol/cli/commands/handlers.py +1055 -0
- pycontrol/cli/commands/run.py +68 -0
- pycontrol/cli/commands/setups.py +98 -0
- pycontrol/cli/commands/vars.py +74 -0
- pycontrol/cli/commands/workspace.py +51 -0
- pycontrol/cli/main.py +79 -0
- pycontrol/config/__init__.py +59 -0
- pycontrol/config/io.py +141 -0
- pycontrol/config/models.py +289 -0
- pycontrol/experiment/__init__.py +585 -0
- pycontrol/experiment/fleet_fanin.py +237 -0
- pycontrol/experiment/hooks.py +109 -0
- pycontrol/extension/__init__.py +56 -0
- pycontrol/extension/controls.py +75 -0
- pycontrol/extension/experiment_api.py +137 -0
- pycontrol/extension/loader.py +230 -0
- pycontrol/extension/task_api.py +224 -0
- pycontrol/extension/task_controls_context.py +47 -0
- pycontrol/firmware/__init__.py +29 -0
- pycontrol/firmware/device_helpers.py +261 -0
- pycontrol/firmware/devices/LED_driver.py +6 -0
- pycontrol/firmware/devices/MCP.py +204 -0
- pycontrol/firmware/devices/VL53L4.py +435 -0
- pycontrol/firmware/devices/__init__.py +39 -0
- pycontrol/firmware/devices/analog_LED.py +15 -0
- pycontrol/firmware/devices/audio_board.py +14 -0
- pycontrol/firmware/devices/audio_player.py +39 -0
- pycontrol/firmware/devices/breakout_1_2.py +24 -0
- pycontrol/firmware/devices/breakout_H7.py +46 -0
- pycontrol/firmware/devices/five_poke.py +28 -0
- pycontrol/firmware/devices/frame_logger.py +49 -0
- pycontrol/firmware/devices/frame_trigger.py +34 -0
- pycontrol/firmware/devices/grid_maze.py +231 -0
- pycontrol/firmware/devices/lickometer.py +18 -0
- pycontrol/firmware/devices/load_cell.py +148 -0
- pycontrol/firmware/devices/nine_poke.py +51 -0
- pycontrol/firmware/devices/poke.py +25 -0
- pycontrol/firmware/devices/port_expander.py +34 -0
- pycontrol/firmware/devices/rotary_encoder.py +77 -0
- pycontrol/firmware/devices/schmitt_trigger.py +119 -0
- pycontrol/firmware/devices/solenoid_driver.py +12 -0
- pycontrol/firmware/devices/stepper_motor.py +21 -0
- pycontrol/firmware/devices/uRFID.py +25 -0
- pycontrol/firmware/devices/uart_handler.py +26 -0
- pycontrol/firmware/pyControl/__init__.py +64 -0
- pycontrol/firmware/pyControl/audio.py +117 -0
- pycontrol/firmware/pyControl/framework.py +188 -0
- pycontrol/firmware/pyControl/hardware.py +585 -0
- pycontrol/firmware/pyControl/profiles.py +105 -0
- pycontrol/firmware/pyControl/state_machine.py +98 -0
- pycontrol/firmware/pyControl/timer.py +85 -0
- pycontrol/firmware/pyControl/utility.py +171 -0
- pycontrol/fleet/__init__.py +19 -0
- pycontrol/fleet/envelope.py +94 -0
- pycontrol/integrations/__init__.py +6 -0
- pycontrol/integrations/_queue.py +118 -0
- pycontrol/integrations/notify/__init__.py +24 -0
- pycontrol/integrations/notify/slack.py +72 -0
- pycontrol/integrations/notify/telegram.py +55 -0
- pycontrol/protocol/__init__.py +39 -0
- pycontrol/protocol/analog.py +46 -0
- pycontrol/protocol/decoder.py +268 -0
- pycontrol/protocol/encoder.py +71 -0
- pycontrol/protocol/messages.py +73 -0
- pycontrol/provenance/__init__.py +57 -0
- pycontrol/provenance/archive.py +143 -0
- pycontrol/provenance/collect.py +351 -0
- pycontrol/provenance/hashing.py +92 -0
- pycontrol/provenance/models.py +92 -0
- pycontrol/recording/__init__.py +9 -0
- pycontrol/recording/analog_writer.py +141 -0
- pycontrol/recording/console.py +101 -0
- pycontrol/recording/fleet_aggregator.py +42 -0
- pycontrol/recording/formatting.py +82 -0
- pycontrol/recording/native_event_logger.py +258 -0
- pycontrol/recording/tsv_logger.py +206 -0
- pycontrol/repl/__init__.py +30 -0
- pycontrol/repl/device_helpers.py +27 -0
- pycontrol/repl/file_transfer.py +258 -0
- pycontrol/repl/json_repl.py +63 -0
- pycontrol/repl/raw_repl.py +181 -0
- pycontrol/session/__init__.py +24 -0
- pycontrol/session/board_session.py +1200 -0
- pycontrol/session/commands.py +30 -0
- pycontrol/session/device_resolution.py +237 -0
- pycontrol/session/info.py +100 -0
- pycontrol/session/services/__init__.py +6 -0
- pycontrol/session/services/firmware.py +257 -0
- pycontrol/session/services/setup.py +407 -0
- pycontrol/transport/__init__.py +12 -0
- pycontrol/transport/base.py +57 -0
- pycontrol/transport/mock.py +115 -0
- pycontrol/transport/recording.py +92 -0
- pycontrol/transport/replay.py +91 -0
- pycontrol/transport/serial.py +76 -0
- pycontrol/workspace/__init__.py +307 -0
- pycontrol/workspace/creation.py +263 -0
- pycontrol/workspace/hwdef_inspect.py +107 -0
- pycontrol/workspace/task_inspect.py +30 -0
- pycontrol/workspace_template/__init__.py +21 -0
- pycontrol/workspace_template/devices/__init__.py +11 -0
- pycontrol/workspace_template/devices/example_widget.py +22 -0
- pycontrol/workspace_template/experiments/blink_demo.json +13 -0
- pycontrol/workspace_template/hardware_definitions/encoder.py +19 -0
- pycontrol/workspace_template/hardware_definitions/encoder_h7.py +28 -0
- pycontrol/workspace_template/hardware_definitions/example.py +17 -0
- pycontrol/workspace_template/hardware_definitions/example_h7.py +16 -0
- pycontrol/workspace_template/hardware_definitions/karpova_TOF_h7.py +18 -0
- pycontrol/workspace_template/hardware_definitions/old_board.py +9 -0
- pycontrol/workspace_template/hardware_definitions/usr_button.py +17 -0
- pycontrol/workspace_template/hardware_definitions/usr_button_h7.py +22 -0
- pycontrol/workspace_template/plugins/experiment_extensions/notion_logger.py +105 -0
- pycontrol/workspace_template/plugins/experiment_extensions/s3_data_exporter.py +95 -0
- pycontrol/workspace_template/plugins/task_controls/example_dialog.py +20 -0
- pycontrol/workspace_template/plugins/task_extensions/example_extension.py +32 -0
- pycontrol/workspace_template/plugins/task_extensions/run_notify.py +73 -0
- pycontrol/workspace_template/plugins/task_extensions/trial_summary.py +18 -0
- pycontrol/workspace_template/settings.json +27 -0
- pycontrol/workspace_template/setups.json +1 -0
- pycontrol/workspace_template/tasks/blinker.py +65 -0
- pycontrol/workspace_template/tasks/examples/blinker.py +37 -0
- pycontrol/workspace_template/tasks/examples/button.py +48 -0
- pycontrol_core-3.0.0a1.dist-info/METADATA +226 -0
- pycontrol_core-3.0.0a1.dist-info/RECORD +145 -0
- pycontrol_core-3.0.0a1.dist-info/WHEEL +4 -0
- pycontrol_core-3.0.0a1.dist-info/entry_points.txt +2 -0
- pycontrol_core-3.0.0a1.dist-info/licenses/LICENSE +674 -0
pycontrol/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Self-contained pyControl protocol and run bundle helpers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pycontrol.bundles.models import (
|
|
6
|
+
PROTOCOL_BUNDLE_TYPE,
|
|
7
|
+
RUN_BUNDLE_TYPE,
|
|
8
|
+
ProtocolBundleManifest,
|
|
9
|
+
RunBundleManifest,
|
|
10
|
+
)
|
|
11
|
+
from pycontrol.bundles.zipio import ArchiveError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str) -> Any:
|
|
15
|
+
if name in {"export_legacy_tsv"}:
|
|
16
|
+
from pycontrol.bundles import legacy
|
|
17
|
+
|
|
18
|
+
return getattr(legacy, name)
|
|
19
|
+
if name in {"create_protocol_bundle", "extract_protocol_bundle", "validate_protocol_bundle"}:
|
|
20
|
+
from pycontrol.bundles import protocol
|
|
21
|
+
|
|
22
|
+
return getattr(protocol, name)
|
|
23
|
+
if name in {"collect_task_files", "find_task_variables"}:
|
|
24
|
+
from pycontrol.bundles import collect
|
|
25
|
+
|
|
26
|
+
return getattr(collect, name)
|
|
27
|
+
if name in {"create_run_bundle", "extract_run_bundle", "validate_run_bundle"}:
|
|
28
|
+
from pycontrol.bundles import run
|
|
29
|
+
|
|
30
|
+
return getattr(run, name)
|
|
31
|
+
raise AttributeError(name)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"PROTOCOL_BUNDLE_TYPE",
|
|
35
|
+
"RUN_BUNDLE_TYPE",
|
|
36
|
+
"ArchiveError",
|
|
37
|
+
"ProtocolBundleManifest",
|
|
38
|
+
"RunBundleManifest",
|
|
39
|
+
"collect_task_files",
|
|
40
|
+
"create_protocol_bundle",
|
|
41
|
+
"create_run_bundle",
|
|
42
|
+
"export_legacy_tsv",
|
|
43
|
+
"extract_protocol_bundle",
|
|
44
|
+
"extract_run_bundle",
|
|
45
|
+
"find_task_variables",
|
|
46
|
+
"validate_protocol_bundle",
|
|
47
|
+
"validate_run_bundle",
|
|
48
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Collect workspace-local files needed to share a task setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pycontrol.workspace import Workspace
|
|
10
|
+
|
|
11
|
+
SHARE_FOLDERS: tuple[str, ...] = (
|
|
12
|
+
"tasks",
|
|
13
|
+
"hardware_definitions",
|
|
14
|
+
"devices",
|
|
15
|
+
"plugins/task_extensions",
|
|
16
|
+
"plugins/experiment_extensions",
|
|
17
|
+
"plugins/task_controls",
|
|
18
|
+
"plugins/task_plotters",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
TASK_VARIABLE_TO_FOLDER: dict[str, str] = {
|
|
22
|
+
"task_extension": "plugins/task_extensions",
|
|
23
|
+
"task_controls": "plugins/task_controls",
|
|
24
|
+
"task_plotter": "plugins/task_plotters",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
PLUGIN_ROOTS: dict[str, str] = {
|
|
28
|
+
"experiment_extensions": "plugins/experiment_extensions",
|
|
29
|
+
"task_extensions": "plugins/task_extensions",
|
|
30
|
+
"task_controls": "plugins/task_controls",
|
|
31
|
+
"task_plotters": "plugins/task_plotters",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collect_task_files(
|
|
36
|
+
workspace: Workspace,
|
|
37
|
+
task_stem: str,
|
|
38
|
+
hwdef_stem: str | Path | None = None,
|
|
39
|
+
experiment_extension_stem: str | Path | None = None,
|
|
40
|
+
) -> dict[str, list[Path]]:
|
|
41
|
+
"""Return files needed to share ``task_stem``, grouped by archive folder."""
|
|
42
|
+
|
|
43
|
+
collected: dict[str, set[Path]] = defaultdict(set)
|
|
44
|
+
task_path = workspace.resolve_task(task_stem)
|
|
45
|
+
task_folder, task_rel = _workspace_archive_path(workspace, task_path)
|
|
46
|
+
collected[task_folder].add(task_rel)
|
|
47
|
+
|
|
48
|
+
task_variables = find_task_variables(task_path)
|
|
49
|
+
for variable_name, value in task_variables.items():
|
|
50
|
+
if value is None:
|
|
51
|
+
continue
|
|
52
|
+
_collect_named_plugin(workspace, variable_name, value, collected)
|
|
53
|
+
|
|
54
|
+
class_to_device = _device_class_map(workspace.devices_dir)
|
|
55
|
+
_collect_used_devices(workspace, task_path, class_to_device, collected)
|
|
56
|
+
|
|
57
|
+
if hwdef_stem is not None:
|
|
58
|
+
hwdef_path = workspace.resolve_hwdef(hwdef_stem)
|
|
59
|
+
if hwdef_path is not None:
|
|
60
|
+
folder, rel_path = _workspace_archive_path(workspace, hwdef_path)
|
|
61
|
+
collected[folder].add(rel_path)
|
|
62
|
+
_collect_used_devices(workspace, hwdef_path, class_to_device, collected)
|
|
63
|
+
|
|
64
|
+
if experiment_extension_stem not in (None, ""):
|
|
65
|
+
exp_ext_path = workspace.resolve_experiment_extension(str(experiment_extension_stem))
|
|
66
|
+
if exp_ext_path is not None:
|
|
67
|
+
_collect_plugin_path(workspace, "plugins/experiment_extensions", exp_ext_path, collected)
|
|
68
|
+
|
|
69
|
+
return {folder: sorted(paths, key=lambda p: p.as_posix()) for folder, paths in collected.items() if paths}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_task_variables(task_path: str | Path) -> dict[str, str | None]:
|
|
73
|
+
"""Extract string-valued special ``v.*`` variables from a task file."""
|
|
74
|
+
|
|
75
|
+
tree = _parse_file(Path(task_path))
|
|
76
|
+
out: dict[str, str | None] = {}
|
|
77
|
+
for node in ast.walk(tree):
|
|
78
|
+
if not isinstance(node, ast.Assign):
|
|
79
|
+
continue
|
|
80
|
+
value = node.value.value if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str) else None
|
|
81
|
+
for target in node.targets:
|
|
82
|
+
if (
|
|
83
|
+
isinstance(target, ast.Attribute)
|
|
84
|
+
and isinstance(target.value, ast.Name)
|
|
85
|
+
and target.value.id == "v"
|
|
86
|
+
and target.attr in TASK_VARIABLE_TO_FOLDER
|
|
87
|
+
):
|
|
88
|
+
out[target.attr] = value
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _collect_named_plugin(
|
|
93
|
+
workspace: Workspace, variable_name: str, plugin_stem: str, collected: dict[str, set[Path]]
|
|
94
|
+
) -> None:
|
|
95
|
+
folder = TASK_VARIABLE_TO_FOLDER[variable_name]
|
|
96
|
+
candidates = _plugin_candidates(workspace, folder, plugin_stem)
|
|
97
|
+
for path in candidates:
|
|
98
|
+
if path.exists():
|
|
99
|
+
_collect_plugin_path(workspace, folder, path, collected)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _collect_plugin_path(workspace: Workspace, folder: str, path: Path, collected: dict[str, set[Path]]) -> None:
|
|
104
|
+
folder_key, rel_path = _workspace_archive_path(workspace, path)
|
|
105
|
+
if rel_path in collected[folder_key]:
|
|
106
|
+
return
|
|
107
|
+
collected[folder_key].add(rel_path)
|
|
108
|
+
if path.suffix != ".py":
|
|
109
|
+
return
|
|
110
|
+
for dep_folder, dep_stem in _plugin_imports(path, folder):
|
|
111
|
+
dep_candidates = _plugin_candidates(workspace, dep_folder, dep_stem)
|
|
112
|
+
for dep_path in dep_candidates:
|
|
113
|
+
if dep_path.exists():
|
|
114
|
+
_collect_plugin_path(workspace, dep_folder, dep_path, collected)
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _plugin_candidates(workspace: Workspace, folder: str, stem: str) -> list[Path]:
|
|
119
|
+
base = workspace.root / folder
|
|
120
|
+
if folder == "plugins/task_controls":
|
|
121
|
+
return [base / f"{stem}.json", base / f"{stem}.py"]
|
|
122
|
+
return [base / f"{stem}.py"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _plugin_imports(path: Path, current_folder: str) -> list[tuple[str, str]]:
|
|
126
|
+
tree = _parse_file(path)
|
|
127
|
+
deps: list[tuple[str, str]] = []
|
|
128
|
+
for node in ast.walk(tree):
|
|
129
|
+
if isinstance(node, ast.ImportFrom) and node.module:
|
|
130
|
+
dep = (
|
|
131
|
+
(current_folder, node.module)
|
|
132
|
+
if node.level > 0 and "." not in node.module
|
|
133
|
+
else _plugin_dep_from_module(node.module)
|
|
134
|
+
)
|
|
135
|
+
if dep is not None:
|
|
136
|
+
deps.append(dep)
|
|
137
|
+
elif isinstance(node, ast.Import):
|
|
138
|
+
for alias in node.names:
|
|
139
|
+
dep = _plugin_dep_from_module(alias.name)
|
|
140
|
+
if dep is None and "." not in alias.name and (path.parent / f"{alias.name}.py").exists():
|
|
141
|
+
dep = (current_folder, alias.name)
|
|
142
|
+
if dep is not None:
|
|
143
|
+
deps.append(dep)
|
|
144
|
+
elif isinstance(node, ast.Call):
|
|
145
|
+
dep = _plugin_file_reference(path, current_folder, node)
|
|
146
|
+
if dep is not None:
|
|
147
|
+
deps.append(dep)
|
|
148
|
+
return deps
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _plugin_dep_from_module(module_name: str) -> tuple[str, str] | None:
|
|
152
|
+
parts = module_name.split(".")
|
|
153
|
+
if len(parts) != 3 or parts[0] != "plugins":
|
|
154
|
+
return None
|
|
155
|
+
folder = PLUGIN_ROOTS.get(parts[1])
|
|
156
|
+
if folder is None:
|
|
157
|
+
return None
|
|
158
|
+
return folder, parts[2]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _plugin_file_reference(path: Path, current_folder: str, node: ast.Call) -> tuple[str, str] | None:
|
|
162
|
+
"""Detect dynamic sibling loads like ``Path(__file__).with_name("helper.py")``."""
|
|
163
|
+
|
|
164
|
+
if not isinstance(node.func, ast.Attribute) or node.func.attr != "with_name":
|
|
165
|
+
return None
|
|
166
|
+
if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant) or not isinstance(node.args[0].value, str):
|
|
167
|
+
return None
|
|
168
|
+
filename = node.args[0].value
|
|
169
|
+
if not filename.endswith(".py"):
|
|
170
|
+
return None
|
|
171
|
+
sibling = path.parent / filename
|
|
172
|
+
if not sibling.exists():
|
|
173
|
+
return None
|
|
174
|
+
return current_folder, sibling.stem
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _device_class_map(devices_dir: Path) -> dict[str, Path]:
|
|
178
|
+
out: dict[str, Path] = {}
|
|
179
|
+
if not devices_dir.exists():
|
|
180
|
+
return out
|
|
181
|
+
for path in sorted(devices_dir.glob("*.py")):
|
|
182
|
+
if path.name.startswith("_") or path.name == "__init__.py":
|
|
183
|
+
continue
|
|
184
|
+
tree = _parse_file(path)
|
|
185
|
+
for node in ast.walk(tree):
|
|
186
|
+
if isinstance(node, ast.ClassDef):
|
|
187
|
+
out[node.name] = path
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _collect_used_devices(
|
|
192
|
+
workspace: Workspace, ref_path: Path, class_to_device: dict[str, Path], collected: dict[str, set[Path]]
|
|
193
|
+
) -> None:
|
|
194
|
+
tree = _parse_file(ref_path)
|
|
195
|
+
local_classes = {node.name for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
|
|
196
|
+
referenced_names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
|
|
197
|
+
for class_name in sorted(referenced_names - local_classes):
|
|
198
|
+
device_path = class_to_device.get(class_name)
|
|
199
|
+
if device_path is None:
|
|
200
|
+
continue
|
|
201
|
+
folder, rel_path = _workspace_archive_path(workspace, device_path)
|
|
202
|
+
if rel_path in collected[folder]:
|
|
203
|
+
continue
|
|
204
|
+
collected[folder].add(rel_path)
|
|
205
|
+
_collect_used_devices(workspace, device_path, class_to_device, collected)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _workspace_archive_path(workspace: Workspace, path: Path) -> tuple[str, Path]:
|
|
209
|
+
resolved = path.resolve()
|
|
210
|
+
rel = resolved.relative_to(workspace.root)
|
|
211
|
+
folder = _folder_for_relative_path(rel)
|
|
212
|
+
folder_path = Path(folder)
|
|
213
|
+
return folder, rel.relative_to(folder_path)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _folder_for_relative_path(rel_path: Path) -> str:
|
|
217
|
+
parts = rel_path.parts
|
|
218
|
+
if not parts:
|
|
219
|
+
raise ValueError("empty workspace-relative path")
|
|
220
|
+
if parts[0] == "plugins" and len(parts) >= 3:
|
|
221
|
+
folder = f"plugins/{parts[1]}"
|
|
222
|
+
if folder in SHARE_FOLDERS:
|
|
223
|
+
return folder
|
|
224
|
+
if parts[0] in SHARE_FOLDERS:
|
|
225
|
+
return parts[0]
|
|
226
|
+
raise ValueError(f"path is not in a shareable workspace folder: {rel_path}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _parse_file(path: Path) -> ast.Module:
|
|
230
|
+
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__ = ["SHARE_FOLDERS", "collect_task_files", "find_task_variables"]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Compatibility exports from native run bundles to legacy loose files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import TemporaryDirectory
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pycontrol.recording.formatting import tsv_row
|
|
14
|
+
|
|
15
|
+
from .run import validate_run_bundle
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def export_legacy_tsv(bundle_path: str | Path, out_dir: str | Path) -> Path:
|
|
19
|
+
"""Extract a run bundle into old-style TSV and analog filenames."""
|
|
20
|
+
|
|
21
|
+
bundle = Path(bundle_path).resolve()
|
|
22
|
+
meta = validate_run_bundle(bundle)
|
|
23
|
+
destination = Path(out_dir).expanduser().resolve()
|
|
24
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
stem = _legacy_stem(bundle)
|
|
26
|
+
tsv_path = destination / f"{stem}.tsv"
|
|
27
|
+
with zipfile.ZipFile(bundle, "r") as zf:
|
|
28
|
+
events_text = zf.read(meta.data.events).decode("utf-8")
|
|
29
|
+
_write_legacy_tsv(events_text, tsv_path)
|
|
30
|
+
for analog in meta.data.analog:
|
|
31
|
+
time_path = destination / f"{stem}_{analog.channel}.time.npy"
|
|
32
|
+
data_path = destination / f"{stem}_{analog.channel}.data.npy"
|
|
33
|
+
with TemporaryDirectory() as tmp:
|
|
34
|
+
tmp_root = Path(tmp)
|
|
35
|
+
extracted_times = tmp_root / "times.npy"
|
|
36
|
+
extracted_samples = tmp_root / "samples.npy"
|
|
37
|
+
extracted_times.write_bytes(zf.read(analog.times))
|
|
38
|
+
extracted_samples.write_bytes(zf.read(analog.samples))
|
|
39
|
+
shutil.copy2(extracted_times, time_path)
|
|
40
|
+
shutil.copy2(extracted_samples, data_path)
|
|
41
|
+
return tsv_path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_legacy_tsv(events_text: str, tsv_path: Path) -> None:
|
|
45
|
+
with tsv_path.open("w", encoding="utf-8", newline="\n") as out:
|
|
46
|
+
out.write(tsv_row("type", "time", "subtype", "content"))
|
|
47
|
+
reader = csv.DictReader(events_text.splitlines(), delimiter="\t")
|
|
48
|
+
for row in reader:
|
|
49
|
+
out.write(_legacy_row(row))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _legacy_row(row: dict[str, str]) -> str:
|
|
53
|
+
timestamp_ms = round(float(row["time"]) * 1000)
|
|
54
|
+
kind = row["kind"]
|
|
55
|
+
name = row["name"]
|
|
56
|
+
value = _decode_json(row["value_json"])
|
|
57
|
+
source = row["source"]
|
|
58
|
+
if kind == "info":
|
|
59
|
+
return tsv_row("info", timestamp_ms, name, _legacy_value(value))
|
|
60
|
+
if kind == "state":
|
|
61
|
+
return tsv_row("state", timestamp_ms, content=name)
|
|
62
|
+
if kind == "event":
|
|
63
|
+
return tsv_row("event", timestamp_ms, content=name)
|
|
64
|
+
if kind == "variable":
|
|
65
|
+
payload = {name: value} if name and name not in {"run_start", "run_end"} else value
|
|
66
|
+
return tsv_row("variable", timestamp_ms, name, json.dumps(payload, ensure_ascii=False))
|
|
67
|
+
if kind == "print":
|
|
68
|
+
return tsv_row("print", timestamp_ms, _legacy_print_subtype(source), _legacy_value(value))
|
|
69
|
+
if kind == "warning":
|
|
70
|
+
return tsv_row("warning", timestamp_ms, content=_legacy_value(value))
|
|
71
|
+
if kind == "error":
|
|
72
|
+
return tsv_row("error", timestamp_ms, content=_legacy_value(value))
|
|
73
|
+
return tsv_row(kind, timestamp_ms, name, _legacy_value(value))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _decode_json(value: str) -> Any:
|
|
77
|
+
try:
|
|
78
|
+
return json.loads(value)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _legacy_value(value: Any) -> str:
|
|
84
|
+
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _legacy_print_subtype(source: str) -> str:
|
|
88
|
+
if source == "task_extension":
|
|
89
|
+
return "api"
|
|
90
|
+
if source == "operator":
|
|
91
|
+
return "user"
|
|
92
|
+
return "task"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _legacy_stem(bundle: Path) -> str:
|
|
96
|
+
name = bundle.name
|
|
97
|
+
if name.endswith(".pycontrol.zip"):
|
|
98
|
+
return name.removesuffix(".pycontrol.zip")
|
|
99
|
+
return bundle.stem
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = ["export_legacy_tsv"]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Pydantic models for self-contained pyControl bundle metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from pycontrol.provenance.models import ProtocolManifest, ProvenanceFile, RunManifest
|
|
11
|
+
|
|
12
|
+
from .zipio import validate_archive_member
|
|
13
|
+
|
|
14
|
+
PROTOCOL_BUNDLE_TYPE = "pycontrol.protocol_bundle.v1"
|
|
15
|
+
RUN_BUNDLE_TYPE = "pycontrol.run_bundle.v1"
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
ALLOWED_PROTOCOL_FILE_FOLDERS: frozenset[str] = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"tasks",
|
|
21
|
+
"hardware_definitions",
|
|
22
|
+
"devices",
|
|
23
|
+
"assets",
|
|
24
|
+
"plugins/task_extensions",
|
|
25
|
+
"plugins/experiment_extensions",
|
|
26
|
+
"plugins/task_controls",
|
|
27
|
+
"plugins/task_plotters",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ALLOWED_RUN_BUNDLE_ROOTS: frozenset[str] = frozenset({"data", "files"})
|
|
32
|
+
|
|
33
|
+
_FOLDER_ROLES: dict[str, str] = {
|
|
34
|
+
"tasks": "task",
|
|
35
|
+
"hardware_definitions": "hardware_definition",
|
|
36
|
+
"devices": "workspace_device",
|
|
37
|
+
"assets": "asset",
|
|
38
|
+
"plugins/task_extensions": "task_extension",
|
|
39
|
+
"plugins/experiment_extensions": "experiment_extension",
|
|
40
|
+
"plugins/task_controls": "task_control",
|
|
41
|
+
"plugins/task_plotters": "task_plotter",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BundleInfo(BaseModel):
|
|
46
|
+
"""Common metadata for a pyControl bundle."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
type: Literal["pycontrol.protocol_bundle.v1", "pycontrol.run_bundle.v1"]
|
|
51
|
+
schema_version: Literal[1] = 1
|
|
52
|
+
pycontrol_core_version: str
|
|
53
|
+
pycontrol_gui_version: str | None = None
|
|
54
|
+
created_at: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ProtocolBundleManifest(BaseModel):
|
|
58
|
+
"""Metadata stored at the root of a shareable protocol bundle."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(extra="forbid")
|
|
61
|
+
|
|
62
|
+
bundle: BundleInfo
|
|
63
|
+
task_name: str
|
|
64
|
+
description: str = ""
|
|
65
|
+
files: list[ProvenanceFile] = Field(default_factory=list)
|
|
66
|
+
task_variables: dict[str, str | None] = Field(default_factory=dict)
|
|
67
|
+
provenance: ProtocolManifest | None = None
|
|
68
|
+
|
|
69
|
+
@field_validator("files")
|
|
70
|
+
@classmethod
|
|
71
|
+
def validate_files(cls, files: list[ProvenanceFile]) -> list[ProvenanceFile]:
|
|
72
|
+
for file in files:
|
|
73
|
+
validate_archive_member(file.archive_path, allowed_roots=ALLOWED_PROTOCOL_FILE_FOLDERS)
|
|
74
|
+
return files
|
|
75
|
+
|
|
76
|
+
def files_by_folder(self) -> dict[str, list[str]]:
|
|
77
|
+
grouped: dict[str, list[str]] = {}
|
|
78
|
+
for file in self.files:
|
|
79
|
+
folder, rel_path = split_protocol_archive_path(file.archive_path)
|
|
80
|
+
grouped.setdefault(folder, []).append(rel_path)
|
|
81
|
+
return {folder: sorted(paths) for folder, paths in sorted(grouped.items())}
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def bundle_type(self) -> str:
|
|
85
|
+
return self.bundle.type
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def schema_version(self) -> int:
|
|
89
|
+
return self.bundle.schema_version
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def pycontrol_core_version(self) -> str:
|
|
93
|
+
return self.bundle.pycontrol_core_version
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def pycontrol_gui_version(self) -> str | None:
|
|
97
|
+
return self.bundle.pycontrol_gui_version
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def exported_at(self) -> str:
|
|
101
|
+
return self.bundle.created_at
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AnalogDataFile(BaseModel):
|
|
105
|
+
"""Internal paths for one analog channel payload inside a run bundle."""
|
|
106
|
+
|
|
107
|
+
model_config = ConfigDict(extra="forbid")
|
|
108
|
+
|
|
109
|
+
channel: str
|
|
110
|
+
times: str
|
|
111
|
+
samples: str
|
|
112
|
+
sampling_rate_hz: float | None = None
|
|
113
|
+
dtype: str | None = None
|
|
114
|
+
|
|
115
|
+
@field_validator("times", "samples")
|
|
116
|
+
@classmethod
|
|
117
|
+
def validate_data_path(cls, value: str) -> str:
|
|
118
|
+
validate_archive_member(value, allowed_roots={"data"})
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RunBundleData(BaseModel):
|
|
123
|
+
"""Internal paths for data payloads inside a run bundle."""
|
|
124
|
+
|
|
125
|
+
model_config = ConfigDict(extra="forbid")
|
|
126
|
+
|
|
127
|
+
events: str
|
|
128
|
+
analog: list[AnalogDataFile] = Field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
@field_validator("events")
|
|
131
|
+
@classmethod
|
|
132
|
+
def validate_events_path(cls, value: str) -> str:
|
|
133
|
+
validate_archive_member(value, allowed_roots={"data"})
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RunBundleManifest(BaseModel):
|
|
138
|
+
"""Root metadata for a completed run bundle."""
|
|
139
|
+
|
|
140
|
+
model_config = ConfigDict(extra="forbid")
|
|
141
|
+
|
|
142
|
+
bundle: BundleInfo
|
|
143
|
+
run: RunManifest
|
|
144
|
+
data: RunBundleData
|
|
145
|
+
protocol: ProtocolManifest
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def bundle_type(self) -> str:
|
|
149
|
+
return self.bundle.type
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def schema_version(self) -> int:
|
|
153
|
+
return self.bundle.schema_version
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def pycontrol_core_version(self) -> str:
|
|
157
|
+
return self.bundle.pycontrol_core_version
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def created_at(self) -> str:
|
|
161
|
+
return self.bundle.created_at
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def run_id(self) -> str:
|
|
165
|
+
return self.run.run_id
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def protocol_hash(self) -> str:
|
|
169
|
+
return self.protocol.protocol_hash
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def task_name(self) -> str:
|
|
173
|
+
for file in self.protocol.files:
|
|
174
|
+
if file.role == "task":
|
|
175
|
+
_folder, rel_path = split_protocol_archive_path(file.archive_path)
|
|
176
|
+
return PurePosixPath(rel_path).with_suffix("").as_posix()
|
|
177
|
+
return ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def split_protocol_archive_path(name: str) -> tuple[str, str]:
|
|
181
|
+
for folder in sorted(ALLOWED_PROTOCOL_FILE_FOLDERS, key=len, reverse=True):
|
|
182
|
+
prefix = f"{folder}/"
|
|
183
|
+
if name.startswith(prefix):
|
|
184
|
+
return folder, name.removeprefix(prefix)
|
|
185
|
+
raise ValueError(f"archive member is outside allowed folders: {name}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"ALLOWED_PROTOCOL_FILE_FOLDERS",
|
|
190
|
+
"ALLOWED_RUN_BUNDLE_ROOTS",
|
|
191
|
+
"PROTOCOL_BUNDLE_TYPE",
|
|
192
|
+
"RUN_BUNDLE_TYPE",
|
|
193
|
+
"SCHEMA_VERSION",
|
|
194
|
+
"AnalogDataFile",
|
|
195
|
+
"BundleInfo",
|
|
196
|
+
"ProtocolBundleManifest",
|
|
197
|
+
"RunBundleData",
|
|
198
|
+
"RunBundleManifest",
|
|
199
|
+
"split_protocol_archive_path",
|
|
200
|
+
]
|