leaf-framework 0.11.1__tar.gz → 0.12.0__tar.gz

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 (110) hide show
  1. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/PKG-INFO +2 -2
  2. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +1 -1
  3. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/streaming_adapter.py +2 -2
  4. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/equipment_adapter.py +6 -1
  5. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/ble_watcher.py +1 -1
  6. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/file_watcher.py +3 -3
  7. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/lads_watcher.py +2 -2
  8. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/modbus_watcher.py +1 -1
  9. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/mqtt_watcher.py +3 -3
  10. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/opc_watcher.py +2 -2
  11. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/polling_watcher.py +8 -5
  12. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/topic_store.py +2 -17
  13. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/measure.py +71 -37
  14. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/start.py +2 -2
  15. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/stop.py +2 -2
  16. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/upload_module.py +3 -3
  17. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/equipment_actions.yaml +5 -4
  18. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/metadata.py +13 -10
  19. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/topic_utilities.py +1 -1
  20. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/control.py +12 -6
  21. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/monitoring.py +50 -39
  22. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/pyproject.toml +2 -2
  23. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/LICENSE +0 -0
  24. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/README.md +0 -0
  25. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/__init__.py +0 -0
  26. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/__init__.py +0 -0
  27. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/control_spec.py +0 -0
  28. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/__init__.py +0 -0
  29. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
  30. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
  31. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/config/configuration.yaml +0 -0
  32. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/data/marketplace.yaml +0 -0
  33. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/error_handler/__init__.py +0 -0
  34. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/error_handler/error_holder.py +0 -0
  35. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/error_handler/exceptions.py +0 -0
  36. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/measurement_handler/__init__.py +0 -0
  37. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/measurement_handler/measurements.yaml +0 -0
  38. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/measurement_handler/terms.py +0 -0
  39. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/.gitkeep +0 -0
  40. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/__init__.py +0 -0
  41. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/__init__.py +0 -0
  42. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/event_watcher.py +0 -0
  43. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_api_watcher.py +0 -0
  44. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_event_watcher.py +0 -0
  45. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/http_watcher.py +0 -0
  46. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
  47. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/simple_watcher.py +0 -0
  48. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/__init__.py +0 -0
  49. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
  50. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
  51. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/fluorescence.py +0 -0
  52. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/measurement_module.py +0 -0
  53. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/optical_density.py +0 -0
  54. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/ph.py +0 -0
  55. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/temperature.py +0 -0
  56. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/__init__.py +0 -0
  57. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/file.py +0 -0
  58. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/mqtt.py +0 -0
  59. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/output_module.py +0 -0
  60. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/sql.py +0 -0
  61. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/__init__.py +0 -0
  62. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/control.py +0 -0
  63. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/error.py +0 -0
  64. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/external_event_phase.py +0 -0
  65. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/initialisation.py +0 -0
  66. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/phase.py +0 -0
  67. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/__init__.py +0 -0
  68. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/continous_module.py +0 -0
  69. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/discrete_module.py +0 -0
  70. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/external_event_process.py +0 -0
  71. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/process_module.py +0 -0
  72. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/__init__.py +0 -0
  73. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/abstract_leaf.py +0 -0
  74. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/equipment_data.yaml +0 -0
  75. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/py.typed +0 -0
  76. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/discovery.py +0 -0
  77. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/loader.py +0 -0
  78. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/registry.py +0 -0
  79. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/utils.py +0 -0
  80. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/start.py +0 -0
  81. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/__init__.py +0 -0
  82. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/adapter_ui_extension.py +0 -0
  83. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/auth.py +0 -0
  84. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/__init__.py +0 -0
  85. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/app_control.py +0 -0
  86. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/header.py +0 -0
  87. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/update_notice.py +0 -0
  88. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/constants.py +0 -0
  89. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/desktop_launcher.py +0 -0
  90. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/images/icon.icns +0 -0
  91. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/images/icon.ico +0 -0
  92. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/images/icon.svg +0 -0
  93. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/interface.py +0 -0
  94. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/markdown/configuration_help.md +0 -0
  95. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/markdown/documentation_main.md +0 -0
  96. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/markdown/documentation_protips.md +0 -0
  97. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/scripts.js +0 -0
  98. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/styles.css +0 -0
  99. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/__init__.py +0 -0
  100. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/adapters.py +0 -0
  101. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/configuration.py +0 -0
  102. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/documentation.py +0 -0
  103. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/logs.py +0 -0
  104. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/update_checker.py +0 -0
  105. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/utils.py +0 -0
  106. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/config_utils.py +0 -0
  107. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/logger/__init__.py +0 -0
  108. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/logger/logger_utils.py +0 -0
  109. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/running_adapters.py +0 -0
  110. {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/running_utilities.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: leaf-framework
3
- Version: 0.11.1
3
+ Version: 0.12.0
4
4
  Summary: Lightweight Adapter Framework (LEAF)
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
16
16
  Requires-Dist: dateparser (>=1.4.3,<1.5.0)
17
17
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
18
- Requires-Dist: leafobject (>=0.0.14,<0.0.15)
18
+ Requires-Dist: leafobject (>=0.1.0,<0.2.0)
19
19
  Requires-Dist: nicegui (>=3.18.0,<3.19.0)
20
20
  Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
21
21
  Requires-Dist: pillow (>=13.0.0,<14.0.0)
@@ -87,6 +87,6 @@ class ContinuousExperimentAdapter(EquipmentAdapter):
87
87
  Injects a synthetic start message via the control process
88
88
  to initialise any start-specific logic or output.
89
89
  """
90
- start_topic = self._metadata_manager.experiment.start
90
+ start_topic = self._metadata_manager.start
91
91
  self._control_process.process_input(start_topic, {})
92
92
  super().start()
@@ -159,9 +159,9 @@ class StreamingAdapter(EquipmentAdapter):
159
159
  is active for a filename, further non-start/stop events for it are
160
160
  no-ops; the running stream() call handles tailing on its own.
161
161
  """
162
- if topic() == self._metadata_manager.experiment.start():
162
+ if topic() == self._metadata_manager.start():
163
163
  self._launch_stream(data)
164
- elif topic() == self._metadata_manager.experiment.stop():
164
+ elif topic() == self._metadata_manager.stop():
165
165
  self._stop_stream(data)
166
166
  else:
167
167
  with self._streams_guard:
@@ -293,7 +293,12 @@ class EquipmentAdapter(ABC):
293
293
  """Publish what happened to a control message on <control topic>/result, so it is stored with the data."""
294
294
  # Never let the record break the command it describes
295
295
  try:
296
- record = result_record(data, source, error, self._metadata_manager.instance_tags())
296
+ # A control acts on the instance itself, so that is its entity unless the message names one
297
+ instance_tags = {
298
+ **self._metadata_manager.instance_tags(),
299
+ "entity": self._metadata_manager.get_instance_id(),
300
+ }
301
+ record = result_record(data, source, error, instance_tags)
297
302
  _ = self._output.transmit(f"{topic}/result", record)
298
303
  except Exception as e:
299
304
  self._logger.warning(f"Could not publish the control record for {topic}: {e}")
@@ -231,7 +231,7 @@ class BLEEventWatcher(EventWatcher):
231
231
 
232
232
  # Dispatch to callbacks using the measurement event
233
233
  if self._metadata_manager:
234
- self._dispatch_callback(self._metadata_manager.experiment.measurement, event_data)
234
+ self._dispatch_callback(self._metadata_manager.measurement, event_data)
235
235
  else:
236
236
  # Fallback: call callbacks directly with "measurement" event
237
237
  for callback in self._callbacks:
@@ -131,9 +131,9 @@ class FileWatcher(FileSystemEventHandler, EventWatcher):
131
131
  self._pending_timers: dict = {} # Track pending trailing timers per file path
132
132
 
133
133
  self._term_map = {
134
- self.on_created: metadata_manager.experiment.start,
135
- self.on_modified: metadata_manager.experiment.measurement,
136
- self.on_deleted: metadata_manager.experiment.stop,
134
+ self.on_created: metadata_manager.start,
135
+ self.on_modified: metadata_manager.measurement,
136
+ self.on_deleted: metadata_manager.stop,
137
137
  }
138
138
 
139
139
  def start(self) -> None:
@@ -100,7 +100,7 @@ class LADSWatcher(EventWatcher):
100
100
  self.logger.debug(f"LADS datachange_notification: node={nodeid}, value={val}")
101
101
  info = self._variables.get(nodeid, {})
102
102
  self._dispatch_callback(
103
- self._metadata_manager.experiment.measurement,
103
+ self._metadata_manager.measurement,
104
104
  {
105
105
  "functional_unit": info.get("functional_unit"),
106
106
  "node": node.nodeid.Identifier,
@@ -321,7 +321,7 @@ class LADSWatcher(EventWatcher):
321
321
  self.logger.info(f"Subscribed to: {info['functional_unit']}/{info['browse_name']} ({nodeid})")
322
322
  # Send a dummy value to trigger the callback
323
323
  self._dispatch_callback(
324
- self._metadata_manager.experiment.measurement,
324
+ self._metadata_manager.measurement,
325
325
  {
326
326
  "functional_unit": info["functional_unit"],
327
327
  "node": node.nodeid.Identifier,
@@ -170,7 +170,7 @@ class ModbusWatcher(ExternalEventWatcher):
170
170
 
171
171
  def _emit(self, data: dict[str, Any]) -> None:
172
172
  if self._metadata_manager:
173
- self._dispatch_callback(self._metadata_manager.experiment.measurement, data)
173
+ self._dispatch_callback(self._metadata_manager.measurement, data)
174
174
  else:
175
175
  for callback in self._callbacks:
176
176
  try:
@@ -92,9 +92,9 @@ class MQTTEventWatcher(EventWatcher):
92
92
 
93
93
  # topic -> list of events
94
94
  self._topic_event_map: dict[str, list[str]] = {}
95
- self._register_topics(start_topics, metadata_manager.experiment.start)
96
- self._register_topics(measurement_topics, metadata_manager.experiment.measurement)
97
- self._register_topics(stop_topics, metadata_manager.experiment.stop)
95
+ self._register_topics(start_topics, metadata_manager.start)
96
+ self._register_topics(measurement_topics, metadata_manager.measurement)
97
+ self._register_topics(stop_topics, metadata_manager.stop)
98
98
  self._register_topics(error_topics, metadata_manager.error)
99
99
 
100
100
  # Set when on_connect fires so start() can block until subscriptions are active
@@ -77,7 +77,7 @@ class OPCWatcher(EventWatcher):
77
77
  def datachange_notification(self, node: Node, val: int | str | float, data: DataChangeNotification) -> None:
78
78
  self.logger.debug(f"OPC datachange_notification: node={node.nodeid.Identifier}, value={val}")
79
79
  self._dispatch_callback(
80
- self._metadata_manager.experiment.measurement,
80
+ self._metadata_manager.measurement,
81
81
  {
82
82
  "node": node.nodeid.Identifier,
83
83
  "value": val,
@@ -233,7 +233,7 @@ class OPCWatcher(EventWatcher):
233
233
  self.logger.info(f"Subscribed to: {node.nodeid.to_string()}")
234
234
  # Send a dummy value to trigger the callback
235
235
  self._dispatch_callback(
236
- self._metadata_manager.experiment.measurement,
236
+ self._metadata_manager.measurement,
237
237
  {
238
238
  "node": node.nodeid.Identifier,
239
239
  "value": node.get_value(),
@@ -1,5 +1,4 @@
1
1
  import threading
2
- import time
3
2
  from abc import abstractmethod
4
3
  from collections.abc import Callable
5
4
  from typing import Any
@@ -40,10 +39,12 @@ class PollingWatcher(EventWatcher):
40
39
 
41
40
  self._interval: int = interval
42
41
  self._thread: threading.Thread | None = None
42
+ # Set by stop() so the poll loop wakes at once instead of finishing a full interval.
43
+ self._stop_event: threading.Event = threading.Event()
43
44
  self._term_map = {
44
- self.start_message: metadata_manager.experiment.start,
45
- self.stop_message: metadata_manager.experiment.stop,
46
- self.measurement_message: metadata_manager.experiment.measurement,
45
+ self.start_message: metadata_manager.start,
46
+ self.stop_message: metadata_manager.stop,
47
+ self.measurement_message: metadata_manager.measurement,
47
48
  }
48
49
 
49
50
  def start_message(self, data: Any) -> None:
@@ -99,7 +100,7 @@ class PollingWatcher(EventWatcher):
99
100
  # reporting data.
100
101
  pass
101
102
 
102
- time.sleep(self._interval)
103
+ _ = self._stop_event.wait(self._interval)
103
104
 
104
105
  def start(self) -> None:
105
106
  """
@@ -112,6 +113,7 @@ class PollingWatcher(EventWatcher):
112
113
  return
113
114
 
114
115
  self._running = True
116
+ self._stop_event.clear()
115
117
  self._thread = threading.Thread(target=self._poll, daemon=True)
116
118
  self._thread.start()
117
119
 
@@ -127,6 +129,7 @@ class PollingWatcher(EventWatcher):
127
129
  return
128
130
 
129
131
  self._running = False
132
+ self._stop_event.set()
130
133
  if self._thread:
131
134
  self._thread.join()
132
135
  logger.info("PollingWatcher stopped.")
@@ -46,13 +46,12 @@ class TopicStore:
46
46
  sample = _point_sample(point)
47
47
  if sample is None:
48
48
  continue
49
- key = history_key(topic, point)
50
49
  with self._history_lock:
51
- history = self._history.setdefault(key, deque(maxlen=HISTORY_LENGTH))
50
+ history = self._history.setdefault(topic, deque(maxlen=HISTORY_LENGTH))
52
51
  history.append(sample)
53
52
 
54
53
  def get_history(self) -> dict[str, list[tuple[float, float]]]:
55
- """(timestamp, value) pairs per history key (see history_key), oldest first."""
54
+ """(timestamp, value) pairs per topic, oldest first."""
56
55
  with self._history_lock:
57
56
  return {topic: list(points) for topic, points in self._history.items()}
58
57
 
@@ -83,20 +82,6 @@ class TopicStore:
83
82
  return self._store
84
83
 
85
84
 
86
- # Separates the topic from the point's entity tag in a history key
87
- ENTITY_SEPARATOR = "#"
88
-
89
-
90
- def history_key(topic: str, point: object) -> str:
91
- """Topic plus the point's "entity" tag, since one topic can carry several entities (e.g. the wells of a plate)."""
92
- tags = cast(dict[str, object], point).get("tags")
93
- if isinstance(tags, dict):
94
- entity = cast(dict[str, object], tags).get("entity")
95
- if isinstance(entity, str) and entity:
96
- return f"{topic}{ENTITY_SEPARATOR}{entity}"
97
- return topic
98
-
99
-
100
85
  def _point_sample(point: object) -> tuple[float, float] | None:
101
86
  """(timestamp, value) of a LeafPoint JSON dict with a numeric fields.value, or None."""
102
87
  if not isinstance(point, dict):
@@ -1,4 +1,5 @@
1
1
  import inspect
2
+ import re
2
3
  import threading
3
4
  import time
4
5
  from typing import TYPE_CHECKING, Any, cast
@@ -35,11 +36,10 @@ class MeasurePhase(PhaseModule):
35
36
  maximum_message_size (Optional[int]): The maximum number of measurements in a single message.
36
37
  error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
37
38
  """
38
- term_builder = (
39
- metadata_manager.experiment.measurement
40
- if metadata_manager is not None
41
- else "metadata_manager.experiment.measurement"
42
- )
39
+ if metadata_manager is not None:
40
+ term_builder = metadata_manager.measurement
41
+ else:
42
+ term_builder = "metadata_manager.measurement"
43
43
 
44
44
  super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
45
45
  self._maximum_message_size: int = maximum_message_size
@@ -147,10 +147,10 @@ class MeasurePhase(PhaseModule):
147
147
  finally:
148
148
  self._measurement_lock.release()
149
149
  else:
150
- experiment_id = kwargs.get("experiment_id", "unknown")
151
150
  measurement = kwargs.get("measurement", "unknown")
151
+ entity = kwargs.get("entity", self._instance_entity())
152
152
 
153
- action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
153
+ action = self._term_builder(entity=entity, measurement=measurement)
154
154
  return [(action, data)]
155
155
 
156
156
  def stream(self, output: "OutputModule", filename: Any) -> None:
@@ -218,11 +218,9 @@ class MeasurePhase(PhaseModule):
218
218
  """
219
219
  Formulate one or more (topic, data) messages for a measurement result.
220
220
 
221
- A list/set of points can mix several distinct measurement types
222
- (e.g. pH, temperature, oxygen) in the same batch. Since the topic
223
- encodes a single measurement type, each distinct type present in
224
- the batch is split out into its own message rather than all being
225
- published under whichever type happened to come first.
221
+ A list/set of points can mix several measurement types (e.g. pH,
222
+ temperature) and entities (e.g. reactors) in one batch. The topic
223
+ encodes one of each, so every distinct pair gets its own message.
226
224
 
227
225
  Args:
228
226
  experiment_id (str): The ID of the experiment.
@@ -232,56 +230,58 @@ class MeasurePhase(PhaseModule):
232
230
  list[tuple]: A list of (action, result) tuples.
233
231
  """
234
232
  if isinstance(result, dict):
235
- self._add_instance_tags(cast(object, result))
236
- measurement = self._resolve_measurement(result)
237
- action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
238
- return [(action, result)]
233
+ self._add_instance_tags(cast(object, result), experiment_id)
234
+ return [(self._topic_of(result), result)]
239
235
 
240
236
  payload = cast(object, result)
241
- point_json = point_json_of(payload)
237
+ try:
238
+ point_json = point_json_of(self._with_entity(payload))
239
+ except ValueError as ex:
240
+ # A LeafPoint that fails validation, e.g. one without an entity tag
241
+ self._handle_exception(InterpreterError(str(ex)))
242
+ return []
242
243
  if point_json is not None:
243
244
  result = point_json
244
- self._add_instance_tags(cast(object, result))
245
- measurement = self._resolve_measurement(result)
246
- action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
247
- return [(action, result)]
245
+ self._add_instance_tags(cast(object, result), experiment_id)
246
+ return [(self._topic_of(result), result)]
248
247
 
249
248
  if isinstance(result, list):
250
249
  converted = []
251
250
  for item in cast(list[object], result):
252
- point_json = point_json_of(item)
251
+ try:
252
+ point_json = point_json_of(self._with_entity(item))
253
+ except ValueError as ex:
254
+ # Only the invalid point is dropped, the rest of the batch is still sent
255
+ self._handle_exception(InterpreterError(str(ex)))
256
+ continue
253
257
  if point_json is not None:
254
258
  converted.append(point_json)
255
259
  else:
256
260
  converted.append(item)
257
261
 
262
+ # One message per topic, so per metric and entity
258
263
  groups: dict[str, list] = {}
259
264
  for item in converted:
260
- self._add_instance_tags(cast(object, item))
261
- if isinstance(item, dict):
262
- key = self._resolve_measurement(item)
263
- else:
264
- key = "unknown"
265
- groups.setdefault(key, []).append(item)
266
-
267
- messages = []
268
- for key, group in groups.items():
269
- action = self._term_builder(experiment_id=experiment_id, measurement=key)
270
- messages.append((action, group))
271
- return messages
265
+ self._add_instance_tags(cast(object, item), experiment_id)
266
+ groups.setdefault(self._topic_of(item), []).append(item)
267
+ return list(groups.items())
272
268
 
273
269
  excp = AdapterLogicError(f"Unknown measurement data type: {type(result)}")
274
270
  self._handle_exception(excp)
275
271
  return []
276
272
 
277
- def _add_instance_tags(self, item: object) -> None:
278
- """Tag a point with its instance's organisation and department, unless the adapter already did."""
273
+ def _add_instance_tags(self, item: object, experiment_id: str | None = None) -> None:
274
+ """Tag a point with its instance's organisation, department, experiment id and entity, unless the adapter already did."""
279
275
  if self._metadata_manager is None or not isinstance(item, dict) or "fields" not in item:
280
276
  return
281
277
  tags = cast(dict[str, object], item).setdefault("tags", {})
282
278
  if not isinstance(tags, dict):
283
279
  return
284
- for key, value in self._metadata_manager.instance_tags().items():
280
+ expected = self._metadata_manager.instance_tags()
281
+ # These defaults mean the adapter never set an experiment id
282
+ if experiment_id and experiment_id not in ("undefined", "invalid_id"):
283
+ expected["experiment_id"] = experiment_id
284
+ for key, value in expected.items():
285
285
  current = cast(dict[str, object], tags).get(key)
286
286
  if current is None:
287
287
  tags[key] = value
@@ -291,6 +291,40 @@ class MeasurePhase(PhaseModule):
291
291
  logger.warning(
292
292
  f"Tag {key}={current!r} differs from the configured {key} {value!r}; keeping {current!r}"
293
293
  )
294
+ # Every point has an entity; one inside the instance (e.g. a reactor) differs from it by design, so no warning
295
+ if not tags.get("entity"):
296
+ tags["entity"] = self._instance_entity()
297
+
298
+ def _with_entity(self, item: object) -> object:
299
+ """Name the instance as the entity of a LeafPoint that names none."""
300
+ # Before to_json(), which rejects a point without an entity
301
+ tags: object = getattr(item, "tags", None)
302
+ if not isinstance(tags, dict):
303
+ return item
304
+ point_tags = cast(dict[str, object], tags)
305
+ if not point_tags.get("entity"):
306
+ point_tags["entity"] = self._instance_entity()
307
+ return item
308
+
309
+ def _topic_of(self, item: object) -> str:
310
+ """Measurement topic of a point: .../measurement/<entity>/<metric>."""
311
+ if not isinstance(item, dict):
312
+ return self._term_builder(entity=self._instance_entity(), measurement="unknown")
313
+ return self._term_builder(entity=self._resolve_entity(item), measurement=self._resolve_measurement(item))
314
+
315
+ def _instance_entity(self) -> str:
316
+ """Entity of a point that names none: the instance itself."""
317
+ if self._metadata_manager is None:
318
+ return "unknown"
319
+ return self._metadata_manager.get_instance_id() or "unknown"
320
+
321
+ def _resolve_entity(self, item: dict[str, Any]) -> str:
322
+ """The point's "entity" tag, else the instance."""
323
+ tags = item.get("tags")
324
+ if not isinstance(tags, dict) or not tags.get("entity"):
325
+ return self._instance_entity()
326
+ # Level separators and wildcards would break the topic
327
+ return re.sub(r"[/+#]", "_", str(tags["entity"]))
294
328
 
295
329
  def _resolve_measurement(self, item: dict) -> str:
296
330
  """
@@ -25,7 +25,7 @@ class StartPhase(ControlPhase):
25
25
  metadata_manager (MetadataManager): Manages metadata associated with the phase.
26
26
  error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
27
27
  """
28
- term_builder = metadata_manager.experiment.start
28
+ term_builder = metadata_manager.start
29
29
  super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
30
30
 
31
31
  def update(self, data: Any) -> list:
@@ -48,5 +48,5 @@ class StartPhase(ControlPhase):
48
48
 
49
49
  data = super().update(data)
50
50
  data += [(self._metadata_manager.running(), True)]
51
- data += [(self._metadata_manager.experiment.stop(), None)]
51
+ data += [(self._metadata_manager.stop(), None)]
52
52
  return data
@@ -25,7 +25,7 @@ class StopPhase(ControlPhase):
25
25
  metadata_manager (MetadataManager): Manages metadata associated with the phase.
26
26
  error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
27
27
  """
28
- term_builder = metadata_manager.experiment.stop
28
+ term_builder = metadata_manager.stop
29
29
  super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
30
30
 
31
31
  def update(self, data: Any) -> list:
@@ -47,5 +47,5 @@ class StopPhase(ControlPhase):
47
47
  self._handle_exception(leaf_exp)
48
48
  data = super().update(data)
49
49
  data += [(self._metadata_manager.running(), False)]
50
- data += [(self._metadata_manager.experiment.start(), None)]
50
+ data += [(self._metadata_manager.start(), None)]
51
51
  return data
@@ -43,7 +43,7 @@ class UploadProcess(DiscreteProcess):
43
43
  topic (str): The topic to activate a specific phase.
44
44
  data (dict): The data to be processed by the phase.
45
45
  """
46
- if topic() != self._metadata_manager.experiment.start():
46
+ if topic() != self._metadata_manager.start():
47
47
  assert topic() == self._metadata_manager.details()
48
48
  return super().process_input(topic, data)
49
49
 
@@ -52,11 +52,11 @@ class UploadProcess(DiscreteProcess):
52
52
  time.sleep(1)
53
53
 
54
54
  # Measurements
55
- measure_builder = self._metadata_manager.experiment.measurement
55
+ measure_builder = self._metadata_manager.measurement
56
56
  super().process_input(measure_builder, data)
57
57
  time.sleep(1)
58
58
 
59
59
  # Stop
60
- stop_builder = self._metadata_manager.experiment.stop
60
+ stop_builder = self._metadata_manager.stop
61
61
  super().process_input(stop_builder, data)
62
62
  time.sleep(1)
@@ -4,7 +4,8 @@ heartbeat: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/heartbe
4
4
  error: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/error
5
5
  control: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control
6
6
  control_result: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control/result
7
- experiment:
8
- start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/start
9
- stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/stop
10
- measurement: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/experiment/<experiment_id>/measurement/<measurement>
7
+ # A run (experiment, batch, upload) starts and stops on the instance itself; its id is a point tag
8
+ start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/start
9
+ stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/stop
10
+ # The entity is what a point is about: the instance itself, or something inside it such as a reactor
11
+ measurement: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/measurement/<entity>/<measurement>
@@ -117,8 +117,8 @@ class MetadataManager:
117
117
 
118
118
  def __getattr__(self, name: str) -> Any:
119
119
  """
120
- If the user does 'metadata_manager.experiment.start()', we look up
121
- 'experiment.start' in the TopicUtility's root group, and wrap it to
120
+ If the user does 'metadata_manager.start()', we look up
121
+ 'start' in the TopicUtility's root group, and wrap it to
122
122
  auto-fill placeholders with equipment metadata.
123
123
  """
124
124
  try:
@@ -129,10 +129,7 @@ class MetadataManager:
129
129
  if callable(sub):
130
130
 
131
131
  def _wrapper(**overrides) -> str:
132
- data = self.get_equipment_data().copy()
133
- data.update(self.get_instance_data())
134
- placeholders = {**data, **overrides}
135
- return sub(**placeholders)
132
+ return cast(str, sub(**self._placeholders(overrides)))
136
133
 
137
134
  return _wrapper
138
135
  if isinstance(sub, _TemplateGroup):
@@ -140,6 +137,15 @@ class MetadataManager:
140
137
 
141
138
  return sub
142
139
 
140
+ def _placeholders(self, overrides: dict[str, Any]) -> dict[str, Any]:
141
+ """Template placeholders from the equipment and instance data, then the caller's overrides."""
142
+ data = self.get_equipment_data().copy()
143
+ data.update(self.get_instance_data())
144
+ # A point that names no entity is about the instance itself
145
+ if data.get("instance_id"):
146
+ data.setdefault("entity", data["instance_id"])
147
+ return {**data, **overrides}
148
+
143
149
 
144
150
  class _ManagerGroup:
145
151
  """
@@ -156,10 +162,7 @@ class _ManagerGroup:
156
162
  if callable(sub):
157
163
 
158
164
  def _wrapper(**overrides) -> str:
159
- data = self._manager.get_equipment_data().copy()
160
- data.update(self._manager.get_instance_data())
161
- placeholders = {**data, **overrides}
162
- return sub(**placeholders)
165
+ return cast(str, sub(**self._manager._placeholders(overrides))) # pyright: ignore[reportPrivateUsage]
163
166
 
164
167
  return _wrapper
165
168
  if isinstance(sub, _TemplateGroup):
@@ -68,7 +68,7 @@ class TopicUtility:
68
68
  self._collect_templates(self._raw_dict)
69
69
  self.root = _TemplateGroup(self._raw_dict, self)
70
70
 
71
- def __getattr__(self, name: str):
71
+ def __getattr__(self, name: str) -> Any:
72
72
  """
73
73
  Handle dynamic attribute access:
74
74
  - If it's a required field from the YAML, return its name.
@@ -190,16 +190,22 @@ def controllable_instances() -> list[EquipmentAdapter]:
190
190
 
191
191
  def _instance_card(adapter: EquipmentAdapter) -> None:
192
192
  metadata = adapter.metadata_manager
193
- with ui.card().classes("leaf-card w-full mb-4"):
194
- _ = ui.label(f"{metadata.get_instance_id()} ({metadata.get_equipment_data('adapter_id')})").classes(
195
- "text-lg font-semibold text-heading"
196
- )
193
+ title = f"{metadata.get_instance_id()} ({metadata.get_equipment_data('adapter_id')})"
194
+ # Foldable, so an instance with many controls can be tucked away
195
+ with (
196
+ ui.card().classes("leaf-card w-full mb-4"),
197
+ ui.expansion(title, caption=f"{len(adapter.controls)} controls", value=True)
198
+ .classes("w-full")
199
+ .props('header-class="text-lg font-semibold text-heading"'),
200
+ ):
197
201
  if not adapter.controls:
198
202
  _ = ui.label(
199
203
  "This adapter declares no controls in its device.json; send LeafPoints on the control topic instead."
200
204
  ).classes("text-body-secondary")
201
- for control in adapter.controls:
202
- _control_row(adapter, control)
205
+ # Two columns on wide screens, one on narrow ones
206
+ with ui.element("div").classes("grid grid-cols-1 lg:grid-cols-2 gap-x-8 w-full"):
207
+ for control in adapter.controls:
208
+ _control_row(adapter, control)
203
209
 
204
210
 
205
211
  def create_control_tab(control_tab: ui.tab) -> None:
@@ -3,7 +3,6 @@ from typing import Any, cast
3
3
  from nicegui import ui
4
4
 
5
5
  from leaf.modules.output_modules.output_module import OutputModule
6
- from leaf.modules.output_modules.topic_store import ENTITY_SEPARATOR
7
6
 
8
7
 
9
8
  def _store_to_tree_nodes(store: dict, parent_path: str = "") -> list[dict[str, Any]]:
@@ -25,9 +24,8 @@ def _store_to_tree_nodes(store: dict, parent_path: str = "") -> list[dict[str, A
25
24
 
26
25
 
27
26
  def metric_name(key: str) -> str:
28
- """Last topic segment of a history key, e.g. "T" for ".../reactor1/T#well A1"."""
29
- topic = key.split(ENTITY_SEPARATOR, 1)[0]
30
- return topic.rsplit("/", 1)[-1]
27
+ """Last topic segment, e.g. "T" for ".../reactor1/measurement/well_A1/T"."""
28
+ return key.rsplit("/", 1)[-1]
31
29
 
32
30
 
33
31
  def format_value(value: float) -> str:
@@ -40,40 +38,42 @@ def full_label(key: str) -> str:
40
38
  return key.split("/", 1)[-1]
41
39
 
42
40
 
41
+ def measurement_levels(key: str) -> list[str]:
42
+ """[instance, entity, metric] of a .../<instance_id>/measurement/<entity>/<metric> topic, else [].
43
+
44
+ The entity is left out when it only names the instance again, as for a single device.
45
+ """
46
+ parts = key.split("/")
47
+ if len(parts) < 4 or parts[-3] != "measurement":
48
+ return []
49
+ instance, entity, metric = parts[-4], parts[-2], parts[-1]
50
+ if entity == instance:
51
+ return [instance, metric]
52
+ return [instance, entity, metric]
53
+
54
+
43
55
  def instance_name(key: str) -> str:
44
- """Instance ID of a measurement topic, which ends in <instance_id>/experiment/<experiment_id>/measurement/<metric>; else ""."""
45
- parts = key.partition(ENTITY_SEPARATOR)[0].split("/")
46
- if len(parts) < 5 or parts[-2] != "measurement" or parts[-4] != "experiment":
47
- return ""
48
- return parts[-5]
56
+ """Instance ID of a measurement topic, else ""."""
57
+ levels = measurement_levels(key)
58
+ if levels:
59
+ return levels[0]
60
+ return ""
49
61
 
50
62
 
51
- def series_label(key: str, all_keys: list[str]) -> str:
52
- """ "<instance_id> · <entity> · <metric>" for a measurement topic, else the full label.
63
+ def series_label(key: str) -> str:
64
+ """ "<instance_id> · <entity> · <metric>" for a measurement topic, else the full label."""
65
+ levels = measurement_levels(key)
66
+ if levels:
67
+ return " · ".join(levels)
68
+ return full_label(key)
53
69
 
54
- The entity only shows when several entities share the topic (e.g. wells of a plate); a single
55
- entity per topic usually just names the instance again (leaf-example tags every point "bioreactor 1").
56
- """
57
- topic, _, entity = key.partition(ENTITY_SEPARATOR)
58
- instance = instance_name(key)
59
- if not instance:
60
- return full_label(key)
61
- # Instance stays in: entity names repeat across instances
62
- names = [instance]
63
- shared = [other for other in all_keys if other.partition(ENTITY_SEPARATOR)[0] == topic]
64
- if entity and len(shared) > 1:
65
- names.append(entity)
66
- names.append(metric_name(topic))
67
- return " · ".join(names)
68
-
69
-
70
- def tile_label(key: str, all_keys: list[str]) -> str:
70
+
71
+ def tile_label(key: str) -> str:
71
72
  """Series label without the instance, which the tile's group box already names."""
72
- instance = instance_name(key)
73
- label = series_label(key, all_keys)
74
- if instance:
75
- return label.removeprefix(f"{instance} · ")
76
- return label
73
+ levels = measurement_levels(key)
74
+ if levels:
75
+ return " · ".join(levels[1:])
76
+ return full_label(key)
77
77
 
78
78
 
79
79
  # ECharts' default palette, assigned per metric rather than per selected line
@@ -92,7 +92,7 @@ def chart_series(history: dict[str, list[tuple[float, float]]], topics: list[str
92
92
  points = [[timestamp * 1000, value] for timestamp, value in history.get(topic, [])]
93
93
  series.append(
94
94
  {
95
- "name": series_label(topic, list(history)),
95
+ "name": series_label(topic),
96
96
  "type": "line",
97
97
  "showSymbol": False,
98
98
  "color": metric_color(topic, list(history)),
@@ -136,6 +136,8 @@ def create_monitoring_tab(monitor_tab: ui.tab) -> None:
136
136
  tiles = ui.row().classes("w-full gap-3")
137
137
  tile_groups: dict[str, ui.row] = {}
138
138
  tile_values: dict[str, ui.label] = {}
139
+ tile_expansions: dict[str, ui.expansion] = {}
140
+ tile_count: dict[str, int] = {}
139
141
 
140
142
  # Metrics with different scales do not share an axis well, so the user picks which to plot
141
143
  with ui.row().classes("w-full items-center no-wrap"):
@@ -179,19 +181,28 @@ def create_monitoring_tab(monitor_tab: ui.tab) -> None:
179
181
  if topic not in tile_values:
180
182
  instance = instance_name(topic)
181
183
  if instance not in tile_groups:
182
- with tiles, ui.card().props("flat bordered").classes("p-3 gap-2"):
183
- _ = ui.label(instance or "Other").classes("text-sm font-medium")
184
- tile_groups[instance] = ui.row().classes("gap-3")
184
+ # Foldable, so an instance with many metrics can be tucked away
185
+ with (
186
+ tiles,
187
+ ui.card().props("flat bordered").classes("p-0"),
188
+ ui.expansion(instance or "Other", value=True).props(
189
+ 'dense header-class="text-sm font-medium"'
190
+ ) as group,
191
+ ):
192
+ tile_groups[instance] = ui.row().classes("gap-3 p-3 pt-0")
193
+ tile_expansions[instance] = group
194
+ tile_count[instance] = tile_count.get(instance, 0) + 1
195
+ _ = tile_expansions[instance].props(f'caption="{tile_count[instance]} metrics"')
185
196
  stripe = f"border-left: 4px solid {metric_color(topic, topics)}"
186
197
  with (
187
198
  tile_groups[instance],
188
199
  ui.card().classes("p-3 min-w-32").style(stripe).tooltip(full_label(topic)),
189
200
  ):
190
- _ = ui.label(tile_label(topic, topics)).classes("text-xs text-body-secondary")
201
+ _ = ui.label(tile_label(topic)).classes("text-xs text-body-secondary")
191
202
  tile_values[topic] = ui.label().classes("text-2xl font-bold text-heading")
192
203
  _ = tile_values[topic].set_text(format_value(history[topic][-1][1]))
193
204
  # NiceGUI leaves the options type unparametrised
194
- _ = metric_select.set_options({topic: series_label(topic, topics) for topic in topics}) # pyright: ignore[reportUnknownMemberType]
205
+ _ = metric_select.set_options({topic: series_label(topic) for topic in topics}) # pyright: ignore[reportUnknownMemberType]
195
206
  selected = cast(list[str], metric_select.value or [])
196
207
  if not selected and topics and not picked_default:
197
208
  selected = [topics[0]]
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "leaf-framework"
3
- version = "0.11.1"
3
+ version = "0.12.0"
4
4
  description = "Lightweight Adapter Framework (LEAF)"
5
5
  authors = [
6
6
  "Matthew Crowther <nmc215@ncl.ac.uk>",
@@ -33,7 +33,7 @@ requests = "~2.34.2"
33
33
  watchdog = "~7.0.0"
34
34
  cairosvg = "^2.9.1"
35
35
  pillow = "^13.0.0"
36
- leafobject = "^0.0.14"
36
+ leafobject = "^0.1.0"
37
37
 
38
38
  [tool.poetry.scripts]
39
39
  leaf = "leaf.start:main"
File without changes