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.
Files changed (145) hide show
  1. pycontrol/__init__.py +5 -0
  2. pycontrol/bundles/__init__.py +48 -0
  3. pycontrol/bundles/collect.py +233 -0
  4. pycontrol/bundles/legacy.py +102 -0
  5. pycontrol/bundles/models.py +200 -0
  6. pycontrol/bundles/protocol.py +140 -0
  7. pycontrol/bundles/run.py +271 -0
  8. pycontrol/bundles/zipio.py +194 -0
  9. pycontrol/bus/__init__.py +31 -0
  10. pycontrol/bus/enriched.py +22 -0
  11. pycontrol/bus/events.py +91 -0
  12. pycontrol/bus/subscriber.py +115 -0
  13. pycontrol/bus/timestamps.py +16 -0
  14. pycontrol/cli/__init__.py +7 -0
  15. pycontrol/cli/commands/__init__.py +5 -0
  16. pycontrol/cli/commands/board.py +35 -0
  17. pycontrol/cli/commands/experiment.py +27 -0
  18. pycontrol/cli/commands/firmware.py +58 -0
  19. pycontrol/cli/commands/handlers.py +1055 -0
  20. pycontrol/cli/commands/run.py +68 -0
  21. pycontrol/cli/commands/setups.py +98 -0
  22. pycontrol/cli/commands/vars.py +74 -0
  23. pycontrol/cli/commands/workspace.py +51 -0
  24. pycontrol/cli/main.py +79 -0
  25. pycontrol/config/__init__.py +59 -0
  26. pycontrol/config/io.py +141 -0
  27. pycontrol/config/models.py +289 -0
  28. pycontrol/experiment/__init__.py +585 -0
  29. pycontrol/experiment/fleet_fanin.py +237 -0
  30. pycontrol/experiment/hooks.py +109 -0
  31. pycontrol/extension/__init__.py +56 -0
  32. pycontrol/extension/controls.py +75 -0
  33. pycontrol/extension/experiment_api.py +137 -0
  34. pycontrol/extension/loader.py +230 -0
  35. pycontrol/extension/task_api.py +224 -0
  36. pycontrol/extension/task_controls_context.py +47 -0
  37. pycontrol/firmware/__init__.py +29 -0
  38. pycontrol/firmware/device_helpers.py +261 -0
  39. pycontrol/firmware/devices/LED_driver.py +6 -0
  40. pycontrol/firmware/devices/MCP.py +204 -0
  41. pycontrol/firmware/devices/VL53L4.py +435 -0
  42. pycontrol/firmware/devices/__init__.py +39 -0
  43. pycontrol/firmware/devices/analog_LED.py +15 -0
  44. pycontrol/firmware/devices/audio_board.py +14 -0
  45. pycontrol/firmware/devices/audio_player.py +39 -0
  46. pycontrol/firmware/devices/breakout_1_2.py +24 -0
  47. pycontrol/firmware/devices/breakout_H7.py +46 -0
  48. pycontrol/firmware/devices/five_poke.py +28 -0
  49. pycontrol/firmware/devices/frame_logger.py +49 -0
  50. pycontrol/firmware/devices/frame_trigger.py +34 -0
  51. pycontrol/firmware/devices/grid_maze.py +231 -0
  52. pycontrol/firmware/devices/lickometer.py +18 -0
  53. pycontrol/firmware/devices/load_cell.py +148 -0
  54. pycontrol/firmware/devices/nine_poke.py +51 -0
  55. pycontrol/firmware/devices/poke.py +25 -0
  56. pycontrol/firmware/devices/port_expander.py +34 -0
  57. pycontrol/firmware/devices/rotary_encoder.py +77 -0
  58. pycontrol/firmware/devices/schmitt_trigger.py +119 -0
  59. pycontrol/firmware/devices/solenoid_driver.py +12 -0
  60. pycontrol/firmware/devices/stepper_motor.py +21 -0
  61. pycontrol/firmware/devices/uRFID.py +25 -0
  62. pycontrol/firmware/devices/uart_handler.py +26 -0
  63. pycontrol/firmware/pyControl/__init__.py +64 -0
  64. pycontrol/firmware/pyControl/audio.py +117 -0
  65. pycontrol/firmware/pyControl/framework.py +188 -0
  66. pycontrol/firmware/pyControl/hardware.py +585 -0
  67. pycontrol/firmware/pyControl/profiles.py +105 -0
  68. pycontrol/firmware/pyControl/state_machine.py +98 -0
  69. pycontrol/firmware/pyControl/timer.py +85 -0
  70. pycontrol/firmware/pyControl/utility.py +171 -0
  71. pycontrol/fleet/__init__.py +19 -0
  72. pycontrol/fleet/envelope.py +94 -0
  73. pycontrol/integrations/__init__.py +6 -0
  74. pycontrol/integrations/_queue.py +118 -0
  75. pycontrol/integrations/notify/__init__.py +24 -0
  76. pycontrol/integrations/notify/slack.py +72 -0
  77. pycontrol/integrations/notify/telegram.py +55 -0
  78. pycontrol/protocol/__init__.py +39 -0
  79. pycontrol/protocol/analog.py +46 -0
  80. pycontrol/protocol/decoder.py +268 -0
  81. pycontrol/protocol/encoder.py +71 -0
  82. pycontrol/protocol/messages.py +73 -0
  83. pycontrol/provenance/__init__.py +57 -0
  84. pycontrol/provenance/archive.py +143 -0
  85. pycontrol/provenance/collect.py +351 -0
  86. pycontrol/provenance/hashing.py +92 -0
  87. pycontrol/provenance/models.py +92 -0
  88. pycontrol/recording/__init__.py +9 -0
  89. pycontrol/recording/analog_writer.py +141 -0
  90. pycontrol/recording/console.py +101 -0
  91. pycontrol/recording/fleet_aggregator.py +42 -0
  92. pycontrol/recording/formatting.py +82 -0
  93. pycontrol/recording/native_event_logger.py +258 -0
  94. pycontrol/recording/tsv_logger.py +206 -0
  95. pycontrol/repl/__init__.py +30 -0
  96. pycontrol/repl/device_helpers.py +27 -0
  97. pycontrol/repl/file_transfer.py +258 -0
  98. pycontrol/repl/json_repl.py +63 -0
  99. pycontrol/repl/raw_repl.py +181 -0
  100. pycontrol/session/__init__.py +24 -0
  101. pycontrol/session/board_session.py +1200 -0
  102. pycontrol/session/commands.py +30 -0
  103. pycontrol/session/device_resolution.py +237 -0
  104. pycontrol/session/info.py +100 -0
  105. pycontrol/session/services/__init__.py +6 -0
  106. pycontrol/session/services/firmware.py +257 -0
  107. pycontrol/session/services/setup.py +407 -0
  108. pycontrol/transport/__init__.py +12 -0
  109. pycontrol/transport/base.py +57 -0
  110. pycontrol/transport/mock.py +115 -0
  111. pycontrol/transport/recording.py +92 -0
  112. pycontrol/transport/replay.py +91 -0
  113. pycontrol/transport/serial.py +76 -0
  114. pycontrol/workspace/__init__.py +307 -0
  115. pycontrol/workspace/creation.py +263 -0
  116. pycontrol/workspace/hwdef_inspect.py +107 -0
  117. pycontrol/workspace/task_inspect.py +30 -0
  118. pycontrol/workspace_template/__init__.py +21 -0
  119. pycontrol/workspace_template/devices/__init__.py +11 -0
  120. pycontrol/workspace_template/devices/example_widget.py +22 -0
  121. pycontrol/workspace_template/experiments/blink_demo.json +13 -0
  122. pycontrol/workspace_template/hardware_definitions/encoder.py +19 -0
  123. pycontrol/workspace_template/hardware_definitions/encoder_h7.py +28 -0
  124. pycontrol/workspace_template/hardware_definitions/example.py +17 -0
  125. pycontrol/workspace_template/hardware_definitions/example_h7.py +16 -0
  126. pycontrol/workspace_template/hardware_definitions/karpova_TOF_h7.py +18 -0
  127. pycontrol/workspace_template/hardware_definitions/old_board.py +9 -0
  128. pycontrol/workspace_template/hardware_definitions/usr_button.py +17 -0
  129. pycontrol/workspace_template/hardware_definitions/usr_button_h7.py +22 -0
  130. pycontrol/workspace_template/plugins/experiment_extensions/notion_logger.py +105 -0
  131. pycontrol/workspace_template/plugins/experiment_extensions/s3_data_exporter.py +95 -0
  132. pycontrol/workspace_template/plugins/task_controls/example_dialog.py +20 -0
  133. pycontrol/workspace_template/plugins/task_extensions/example_extension.py +32 -0
  134. pycontrol/workspace_template/plugins/task_extensions/run_notify.py +73 -0
  135. pycontrol/workspace_template/plugins/task_extensions/trial_summary.py +18 -0
  136. pycontrol/workspace_template/settings.json +27 -0
  137. pycontrol/workspace_template/setups.json +1 -0
  138. pycontrol/workspace_template/tasks/blinker.py +65 -0
  139. pycontrol/workspace_template/tasks/examples/blinker.py +37 -0
  140. pycontrol/workspace_template/tasks/examples/button.py +48 -0
  141. pycontrol_core-3.0.0a1.dist-info/METADATA +226 -0
  142. pycontrol_core-3.0.0a1.dist-info/RECORD +145 -0
  143. pycontrol_core-3.0.0a1.dist-info/WHEEL +4 -0
  144. pycontrol_core-3.0.0a1.dist-info/entry_points.txt +2 -0
  145. pycontrol_core-3.0.0a1.dist-info/licenses/LICENSE +674 -0
pycontrol/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """pyControl host library: sans-IO core + extension points."""
2
+
3
+ __version__ = "3.0.0a1"
4
+
5
+ __all__ = ["__version__"]
@@ -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
+ ]