Skip to content

Commit 29556fa

Browse files
committed
Add macro recovery and recent macros features
1 parent 6807861 commit 29556fa

19 files changed

Lines changed: 1829 additions & 26 deletions

File tree

datalab/config.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,35 @@ def set_def_dict(cls, category: Literal["ima", "sig"], def_dict: dict) -> None:
436436
opt.set(def_dict[name])
437437

438438

439+
class MacroSection(conf.Section, metaclass=conf.SectionMeta):
440+
"""Class defining the Macro panel configuration section structure.
441+
Each class attribute is an option (metaclass is automatically affecting
442+
option names in .INI file based on class attribute names)."""
443+
444+
# UUIDs of the macros whose tab was open when DataLab was last closed
445+
# (JSON-serialized list of strings).
446+
open_tab_uids = conf.Option()
447+
448+
# UUID of the macro tab that was active when DataLab was last closed.
449+
active_tab_uid = conf.Option()
450+
451+
# Serialized state of the editor/console QSplitter (base64-encoded
452+
# QByteArray, see QSplitter.saveState).
453+
splitter_state = conf.Option()
454+
455+
# Maximum number of lines kept in the macro console (FIFO).
456+
console_max_lines = conf.Option()
457+
458+
# If True, closing a macro tab only hides it; the macro stays in the
459+
# workspace. The user must use "Delete macro" to remove it permanently.
460+
close_tab_keeps_macro = conf.Option()
461+
462+
# Path to a user-managed directory containing custom macro templates
463+
# (``*.py`` files). Each file may declare its description on the first
464+
# line using the ``# DataLab template: ...`` tag.
465+
templates_path = conf.Option()
466+
467+
439468
class AISection(conf.Section, metaclass=conf.SectionMeta):
440469
"""Class defining the AI assistant configuration section structure.
441470
Each class attribute is an option (metaclass is automatically affecting
@@ -487,6 +516,7 @@ class Conf(conf.Configuration, metaclass=conf.ConfMeta):
487516
view = ViewSection()
488517
proc = ProcSection()
489518
io = IOSection()
519+
macro = MacroSection()
490520
ai = AISection()
491521

492522

@@ -536,6 +566,10 @@ def initialize():
536566
iofmts = Conf.io.imageio_formats.get(())
537567
if len(iofmts) > 0:
538568
sigima_options.imageio_formats.set(iofmts) # Sync with sigima config
569+
# Macro section
570+
Conf.macro.console_max_lines.get(5000)
571+
Conf.macro.close_tab_keeps_macro.get(True)
572+
Conf.macro.templates_path.get(Conf.get_path("macro_templates"))
539573
# Proc section
540574
Conf.proc.operation_mode.get("single")
541575
Conf.proc.use_signal_bounds.get(False)
Lines changed: 55 additions & 1 deletion
Loading

datalab/gui/macroeditor.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import re
1818
import sys
1919
import time
20+
import uuid
2021

2122
from guidata.io import BaseIOHandler
2223
from guidata.utils.misc import to_string
@@ -29,9 +30,13 @@
2930
from datalab.config import _
3031
from datalab.env import execenv
3132
from datalab.gui import ObjItf
33+
from datalab.utils import macrorecovery
3234

3335
UNTITLED_NB = 0
3436

37+
# Debounce delay before persisting a modified macro to the recovery cache.
38+
_AUTOSAVE_DELAY_MS = 500
39+
3540

3641
class MacroMeta(type(QC.QObject), abc.ABCMeta):
3742
"""Mixed metaclass to avoid conflicts"""
@@ -88,10 +93,15 @@ def __init__(self, console: PythonShellWidget, title: str | None = None) -> None
8893
super().__init__()
8994
self.console = console
9095
self.title = self.get_untitled_title() if title is None else title
96+
self.uid = uuid.uuid4().hex
9197
self.editor = CodeEditor(language="python")
9298
self.editor.setLineWrapMode(QW.QPlainTextEdit.NoWrap)
9399
self.set_code(self.MACRO_SAMPLE)
94100
self.editor.modificationChanged.connect(self.modification_changed)
101+
self._autosave_timer = QC.QTimer(self)
102+
self._autosave_timer.setSingleShot(True)
103+
self._autosave_timer.setInterval(_AUTOSAVE_DELAY_MS)
104+
self._autosave_timer.timeout.connect(self._autosave_pending)
95105
self.process = None
96106
self.__last_exit_code = None
97107

@@ -128,6 +138,8 @@ def serialize(self, writer: BaseIOHandler) -> None:
128138
writer.write(self.title)
129139
with writer.group("contents"):
130140
writer.write(self.get_code())
141+
with writer.group("uid"):
142+
writer.write(self.uid)
131143

132144
def deserialize(self, reader: BaseIOHandler) -> None:
133145
"""Deserialize this macro
@@ -139,6 +151,15 @@ def deserialize(self, reader: BaseIOHandler) -> None:
139151
self.title = reader.read_any()
140152
with reader.group("contents"):
141153
self.set_code(reader.read_any())
154+
# Backward-compat: ``uid`` was added in v1.4; fall back to a fresh
155+
# UUID when reading older workspaces.
156+
try:
157+
with reader.group("uid"):
158+
stored_uid = reader.read_any()
159+
if stored_uid:
160+
self.uid = stored_uid
161+
except Exception: # pylint: disable=broad-except
162+
pass
142163

143164
def to_file(self, filename: str) -> None:
144165
"""Save macro to file
@@ -209,6 +230,29 @@ def modification_changed(self, state: bool) -> None:
209230
"""
210231
if state:
211232
self.MODIFIED.emit()
233+
self._autosave_timer.start()
234+
235+
def _autosave_pending(self) -> None:
236+
"""Persist the current code to the recovery cache (best-effort)."""
237+
try:
238+
macrorecovery.save_pending(self.uid, self.title, self.get_code())
239+
except OSError:
240+
pass
241+
242+
def flush_autosave(self) -> None:
243+
"""Force an immediate write to the recovery cache (if pending)."""
244+
if self._autosave_timer.isActive():
245+
self._autosave_timer.stop()
246+
self._autosave_pending()
247+
248+
def clear_autosave(self) -> None:
249+
"""Remove this macro from the recovery cache."""
250+
if self._autosave_timer.isActive():
251+
self._autosave_timer.stop()
252+
try:
253+
macrorecovery.clear_pending(self.uid)
254+
except OSError:
255+
pass
212256

213257
@staticmethod
214258
def transcode(bytearr: QC.QByteArray) -> str:
@@ -271,6 +315,7 @@ def print(self, text, error=False, eol_before=True) -> None:
271315

272316
def run(self) -> None:
273317
"""Run macro"""
318+
self.flush_autosave()
274319
self.process = QC.QProcess()
275320
code = self.get_code()
276321
datalab_path = osp.abspath(osp.join(osp.dirname(datalab.__file__), os.pardir))
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2+
3+
"""
4+
DataLab Macro templates
5+
=======================
6+
7+
This package bundles ready-to-use macro examples that are surfaced in the
8+
"New macro" dropdown of the Macro panel. Each template is a single ``.py``
9+
file whose first comment line is parsed as the template description::
10+
11+
# DataLab template: Short human-readable description
12+
13+
The rest of the file is loaded verbatim as the macro source code.
14+
15+
User-defined templates
16+
----------------------
17+
18+
Additional templates can be dropped into the user templates directory
19+
(by default ``<USER_CONFIG_DIR>/macro_templates`` — typically
20+
``~/.DataLab/macro_templates`` on Linux/macOS and
21+
``%APPDATA%\\.DataLab\\macro_templates`` on Windows). The location can be
22+
overridden through ``Conf.macro.templates_path``.
23+
24+
Any ``*.py`` file placed in that directory is exposed in the "New macro"
25+
dropdown after the bundled templates. Filenames starting with an
26+
underscore are ignored.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import os
32+
import os.path as osp
33+
import pkgutil
34+
from dataclasses import dataclass
35+
36+
_TEMPLATE_FILES = (
37+
"simple_macro.py",
38+
"imageproc_macro.py",
39+
"call_method_macro.py",
40+
)
41+
42+
_DESCRIPTION_TAG = "# DataLab template:"
43+
44+
45+
@dataclass(frozen=True)
46+
class MacroTemplate:
47+
"""A bundled macro template.
48+
49+
Attributes:
50+
name: Stable identifier (file stem, e.g. ``"simple_macro"``).
51+
title: Suggested macro title (used as the tab name).
52+
description: One-line description shown in the menu tooltip.
53+
code: Python source code (without the description tag line).
54+
"""
55+
56+
name: str
57+
title: str
58+
description: str
59+
code: str
60+
61+
62+
def _parse(name: str, raw: str) -> MacroTemplate:
63+
"""Parse a raw template file content into a MacroTemplate."""
64+
lines = raw.splitlines()
65+
description = ""
66+
if lines and lines[0].startswith(_DESCRIPTION_TAG):
67+
description = lines[0][len(_DESCRIPTION_TAG) :].strip()
68+
lines = lines[1:]
69+
# Strip leading blank lines after the tag
70+
while lines and not lines[0].strip():
71+
lines = lines[1:]
72+
title = description or name.replace("_", " ").title()
73+
code = "\n".join(lines).rstrip() + "\n"
74+
return MacroTemplate(name=name, title=title, description=description, code=code)
75+
76+
77+
def _user_templates_dir() -> str | None:
78+
"""Return the user templates directory path, or None if disabled."""
79+
# Imported lazily to avoid a circular import at module load time.
80+
try:
81+
from datalab.config import Conf
82+
except ImportError:
83+
return None
84+
try:
85+
return Conf.macro.templates_path.get(None)
86+
except Exception: # pragma: no cover - defensive
87+
return None
88+
89+
90+
def _load_user_templates() -> list[MacroTemplate]:
91+
"""Load user-defined templates from the configured directory."""
92+
directory = _user_templates_dir()
93+
if not directory or not osp.isdir(directory):
94+
return []
95+
templates: list[MacroTemplate] = []
96+
seen: set[str] = set()
97+
try:
98+
filenames = sorted(os.listdir(directory))
99+
except OSError:
100+
return []
101+
for filename in filenames:
102+
if not filename.endswith(".py") or filename.startswith("_"):
103+
continue
104+
name = osp.splitext(filename)[0]
105+
if name in seen:
106+
continue
107+
seen.add(name)
108+
path = osp.join(directory, filename)
109+
try:
110+
with open(path, encoding="utf-8") as fdesc:
111+
raw = fdesc.read()
112+
except OSError:
113+
continue
114+
templates.append(_parse(name, raw))
115+
return templates
116+
117+
118+
def list_templates() -> list[MacroTemplate]:
119+
"""Return all macro templates (bundled first, then user-defined)."""
120+
templates: list[MacroTemplate] = []
121+
bundled_names: set[str] = set()
122+
for filename in _TEMPLATE_FILES:
123+
data = pkgutil.get_data(__name__, filename)
124+
if data is None:
125+
continue
126+
raw = data.decode("utf-8")
127+
name = osp.splitext(filename)[0]
128+
bundled_names.add(name)
129+
templates.append(_parse(name, raw))
130+
for template in _load_user_templates():
131+
if template.name in bundled_names:
132+
# Bundled templates take precedence; skip user file with same stem
133+
continue
134+
templates.append(template)
135+
return templates
136+
137+
138+
def get_template(name: str) -> MacroTemplate | None:
139+
"""Return a template by its stable name (file stem)."""
140+
for template in list_templates():
141+
if template.name == name:
142+
return template
143+
return None
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# DataLab template: Call panel methods
2+
3+
from datalab.control.proxy import RemoteProxy
4+
5+
proxy = RemoteProxy()
6+
7+
# List currently visible signals and images via generic method calls.
8+
sigs = proxy.call_method("get_object_titles", panel="signal")
9+
imgs = proxy.call_method("get_object_titles", panel="image")
10+
print(f"Signals: {len(sigs)} | Images: {len(imgs)}")
11+
12+
# Switch panels through the proxy.
13+
proxy.set_current_panel("signal")
14+
print(f"Current panel: {proxy.get_current_panel()}")
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# DataLab template: Image processing example
2+
3+
import numpy as np
4+
5+
from datalab.control.proxy import RemoteProxy
6+
7+
proxy = RemoteProxy()
8+
9+
# Generate a noisy 2D Gaussian.
10+
size = 256
11+
y, x = np.mgrid[0:size, 0:size]
12+
cx, cy = size / 2, size / 2
13+
img = np.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2 * 30**2))
14+
img += 0.05 * np.random.rand(size, size)
15+
16+
proxy.add_image("gaussian", img)
17+
print("Created image 'gaussian'")
18+
proxy.set_current_panel("image")
19+
20+
# Apply an FFT to the newly created image.
21+
proxy.calc("fft")
22+
print("FFT computed")

0 commit comments

Comments
 (0)