diff --git a/conda-recipe/meta.yaml b/conda-recipe/meta.yaml index aadaba30..8d94497e 100644 --- a/conda-recipe/meta.yaml +++ b/conda-recipe/meta.yaml @@ -33,9 +33,11 @@ requirements: - ophyd >=1.5.0 - pcdsdevices - pcdsutils + - platformdirs - pydm >=1.16.1 - pyqt - pyqtgraph + - pyyaml - qdarkstyle - qtawesome - qtconsole diff --git a/requirements.txt b/requirements.txt index 6ba11dfa..e3d2df4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,11 @@ numpy numpydoc ophyd pcdsutils +platformdirs PyQt5 pydm>=1.16.1 pyqtgraph +pyyaml qdarkstyle qtawesome qtconsole diff --git a/typhos/display.py b/typhos/display.py index 6f6788a3..85176fea 100644 --- a/typhos/display.py +++ b/typhos/display.py @@ -21,6 +21,7 @@ from . import panel as typhos_panel from . import utils, web, widgets from .jira import TyphosJiraIssueWidget +from .notes import TyphosNotesEdit from .plugins.core import register_signal logger = logging.getLogger(__name__) @@ -805,10 +806,13 @@ def __init__(self, title='${name}', *, show_switcher=True, self.underline.setFrameShadow(self.underline.Plain) self.underline.setLineWidth(10) + self.notes_edit = TyphosNotesEdit() + self.grid_layout = QtWidgets.QGridLayout() self.grid_layout.addWidget(self.label, 0, 0) - self.grid_layout.addWidget(self.switcher, 0, 1, Qt.AlignRight) - self.grid_layout.addWidget(self.underline, 1, 0, 1, 2) + self.grid_layout.addWidget(self.switcher, 0, 2, Qt.AlignRight) + self.grid_layout.addWidget(self.notes_edit, 0, 1, Qt.AlignLeft) + self.grid_layout.addWidget(self.underline, 1, 0, 1, 3) self.help = TyphosHelpFrame() if utils.HELP_WEB_ENABLED: @@ -879,6 +883,9 @@ def add_device(self, device): if not self.label.text(): self.label.setText(device.name) + if not self.notes_edit.text(): + self.notes_edit.setup_data(device.name) + if self.help is not None: self.help.add_device(device) diff --git a/typhos/notes.py b/typhos/notes.py new file mode 100644 index 00000000..c9944e0f --- /dev/null +++ b/typhos/notes.py @@ -0,0 +1,229 @@ +import getpass +import logging +import os +import shutil +import time +import uuid +from datetime import datetime +from enum import IntEnum +from pathlib import Path +from typing import Dict, Optional, Tuple + +import platformdirs +import yaml +from qtpy import QtCore, QtWidgets + +from typhos import utils + +logger = logging.getLogger(__name__) +NOTES_VAR = "PCDS_DEVICE_NOTES" + + +class NotesSource(IntEnum): + USER = 0 + ENV = 1 + # HAPPI = 2 # to be implemented later + + +def get_data_from_yaml(device_name: str, path: Path) -> Optional[Dict[str, str]]: + """ + Returns the device data from the yaml file stored in ``path``. + Returns `None` if reading the file fails or there is no information + + Parameters + ---------- + device_name : str + The name of the device to retrieve notes for. + path : Path + Path to the device information yaml + + Returns + ------- + Optional[Dict[str, str]] + The device information or None + """ + try: + with open(path) as f: + device_notes = yaml.full_load(f) + except Exception as ex: + logger.warning(f'failed to load device notes: {ex}') + return + + return device_notes.get(device_name, None) + + +def get_notes_data(device_name: str) -> Tuple[NotesSource, Dict[str, str]]: + """ + get the notes data for a given device + attempt to get the info from the following locations + in order of priority (higher priority will shadow) + 1: device_notes in the user directory + 2: the path in NOTES_VAR environment variable + 3: TODO: happi ... eventually + + Parameters + ---------- + device_name : str + The device name. Can also be a component. e.g. device_component_name + + Returns + ------- + Tuple[NotesSource, dict] + The source of the device notes, and + a dictionary containing the device note information + """ + data = {'note': '', 'timestamp': ''} + source = NotesSource.USER + + # try env var + env_notes_path = os.environ.get(NOTES_VAR) + if env_notes_path and Path(env_notes_path).is_file(): + note_data = get_data_from_yaml(device_name, env_notes_path) + if note_data: + data = note_data + source = NotesSource.ENV + + # try user directory + user_data_path = platformdirs.user_data_path() / 'device_notes.yaml' + if user_data_path.exists(): + note_data = get_data_from_yaml(device_name, user_data_path) + if note_data: + data = note_data + source = NotesSource.USER + + return source, data + + +def insert_into_yaml(path: Path, device_name: str, data: dict[str, str]) -> None: + try: + with open(path, 'r') as f: + device_notes = yaml.full_load(f) + except Exception as ex: + logger.warning(f'unable to open existing device info: {ex}') + + device_notes[device_name] = data + + directory = os.path.dirname(path) + temp_path = Path(directory) / ( + f".{getpass.getuser()}" + f"_{int(time.time())}" + f"_{str(uuid.uuid4())[:8]}" + f"_{os.path.basename(path)}" + ) + try: + with open(temp_path, 'w') as f: + yaml.dump(device_notes, f) + except Exception as ex: + logger.warning(f'unable to write device info: {ex}') + return + + if os.path.exists(path): + shutil.copymode(path, temp_path) + shutil.move(temp_path, path) + + +def write_notes_data( + source: NotesSource, + device_name: str, + data: dict[str, str] +) -> None: + """ + Write the notes ``data`` to the specified ``source`` under the key ``device_name`` + + Parameters + ---------- + source : NotesSource + The source to write the data to + device_name : str + The device name. Can also be a component. e.g. device_component_name + data : dict[str, str] + The notes data. Expected to contain the 'note' and 'timestamp' keys + """ + if source == NotesSource.USER: + user_data_path = platformdirs.user_data_path() / 'device_notes.yaml' + insert_into_yaml(user_data_path, device_name, data) + elif source == NotesSource.ENV: + env_data_path = Path(os.environ.get(NOTES_VAR)) + insert_into_yaml(env_data_path, device_name, data) + + +class TyphosNotesEdit(QtWidgets.QLineEdit): + """ + A QLineEdit for storing notes for a device. + """ + def __init__(self, *args, refresh_time: float = 5.0, **kwargs): + super().__init__(*args, **kwargs) + self.editingFinished.connect(self.save_note) + self.setPlaceholderText('no notes...') + self.edit_filter = utils.FrameOnEditFilter(parent=self) + self.setFrame(False) + self.setStyleSheet("QLineEdit { background: transparent }") + self.setReadOnly(True) + self.installEventFilter(self.edit_filter) + self._last_updated: float = None + self._refresh_time: float = refresh_time + # to be initialized later + self.device_name: str = None + self.notes_source: Optional[NotesSource] = None + self.data = {'note': '', 'timestamp': ''} + + def update_tooltip(self) -> None: + if self.data['note']: + self.setToolTip(f"({self.data['timestamp']}, {self.notes_source.name}):\n" + f"{self.data['note']}") + else: + self.setToolTip('click to edit note') + + def setup_data(self, device_name: Optional[str] = None) -> None: + """ + Set up the device data. Saves the device name and initializes the notes + line edit. Will refresh the data if the time since the last refresh is + longer than `self._refresh_time` + + Once initialized, this widget will not change its targeted device_name. + Subsequent attempts to set a new device_name will be ignored, and simply + refresh this widget's note + + The setup is split up here due to how Typhos Display initialize themselves + first, then add the device later + + Parameters + ---------- + device_name : Optional[str] + The device name. Can also be a component. e.g. device_component_name + """ + # if not initialized + if self.device_name is None: + self.device_name = device_name + + # if no-arg called without device being initialized + if self.device_name is None: + return + + if not self._last_updated: + self._last_updated = time.time() + elif (time.time() - self._last_updated) < self._refresh_time: + return + + self._last_updated = time.time() + self.notes_source, self.data = get_notes_data(self.device_name) + + self.setText(self.data.get('note', '')) + self.update_tooltip() + + def save_note(self) -> None: + note_text = self.text() + curr_time = datetime.now().ctime() + self.data['note'] = note_text + self.data['timestamp'] = curr_time + self.update_tooltip() + write_notes_data(self.notes_source, self.device_name, self.data) + + def event(self, event: QtCore.QEvent) -> bool: + """ Overload event method to update data on tooltip-request """ + # Catch relevant events to update status tooltip + if event.type() in (QtCore.QEvent.ToolTip, QtCore.QEvent.Paint, + QtCore.QEvent.FocusAboutToChange): + self.setup_data() + + return super().event(event) diff --git a/typhos/tests/test_notes.py b/typhos/tests/test_notes.py new file mode 100644 index 00000000..fdb98166 --- /dev/null +++ b/typhos/tests/test_notes.py @@ -0,0 +1,99 @@ +import os +import shutil +from pathlib import Path + +import platformdirs +import pytest +import yaml +from pytestqt.qtbot import QtBot + +from typhos.notes import NOTES_VAR, TyphosNotesEdit + +from .conftest import MODULE_PATH + + +@pytest.fixture(scope='function') +def user_notes_path(monkeypatch, tmp_path: Path): + # copy user_device_notes.yaml to a temp file + # monkeypatch platformdirs to look for device_notes.yaml + # provide the new path for confirmation + user_path = tmp_path / 'device_notes.yaml' + notes_path = MODULE_PATH / 'utils' / 'user_device_notes.yaml' + shutil.copy(notes_path, user_path) + monkeypatch.setattr(platformdirs, 'user_data_path', + lambda: tmp_path) + + yield user_path + + +@pytest.fixture(scope='function') +def env_notes_path(tmp_path: Path): + # copy user env var device_notes.yaml to a temp file + # add env var pointing to env device notes + # provide the path for confirmation + env_path = tmp_path / 'env_device_notes.yaml' + notes_path = MODULE_PATH / 'utils' / 'env_device_notes.yaml' + shutil.copy(notes_path, env_path) + os.environ[NOTES_VAR] = str(env_path) + + yield env_path + + os.environ.pop(NOTES_VAR) + + +def test_note_shadowing(qtbot: QtBot, user_notes_path: Path, env_notes_path: Path): + # user data shadows all other sources + notes_edit = TyphosNotesEdit() + qtbot.addWidget(notes_edit) + notes_edit.setup_data('Syn:Motor') + assert 'user' in notes_edit.text() + + # no data in user, so fall back to data specified in env var + accel_edit = TyphosNotesEdit() + qtbot.addWidget(accel_edit) + accel_edit.setup_data('Syn:Motor_acceleration') + assert 'env' in accel_edit.text() + + +def test_env_note(qtbot: QtBot, env_notes_path: Path): + # grab only data in env notes + notes_edit = TyphosNotesEdit() + qtbot.addWidget(notes_edit) + notes_edit.setup_data('Syn:Motor') + assert 'user' not in notes_edit.text() + assert 'env' in notes_edit.text() + + accel_edit = TyphosNotesEdit() + qtbot.addWidget(accel_edit) + accel_edit.setup_data('Syn:Motor_acceleration') + assert 'env' in accel_edit.text() + + +def test_user_note(qtbot: QtBot, user_notes_path: Path): + # user data shadows all other sources + notes_edit = TyphosNotesEdit() + qtbot.addWidget(notes_edit) + notes_edit.setup_data('Syn:Motor') + assert 'user' in notes_edit.text() + assert 'env' not in notes_edit.text() + + # no data in user, and nothing to fallback to + accel_edit = TyphosNotesEdit() + qtbot.addWidget(accel_edit) + accel_edit.setup_data('Syn:Motor_acceleration') + assert '' == accel_edit.text() + + +def test_note_edit(qtbot: QtBot, user_notes_path: Path): + notes_edit = TyphosNotesEdit() + qtbot.addWidget(notes_edit) + notes_edit.setup_data('Syn:Motor') + + assert 'user' in notes_edit.text() + + notes_edit.setText('new user note') + notes_edit.editingFinished.emit() + with open(user_notes_path) as f: + user_notes = yaml.full_load(f) + + assert 'new user note' == user_notes['Syn:Motor']['note'] diff --git a/typhos/tests/utils/env_device_notes.yaml b/typhos/tests/utils/env_device_notes.yaml new file mode 100644 index 00000000..20297f6d --- /dev/null +++ b/typhos/tests/utils/env_device_notes.yaml @@ -0,0 +1,6 @@ +Syn:Motor: + note: env note main + timestamp: Tue Jul 11 15:14:00 2023 +Syn:Motor_acceleration: + note: env note accel + timestamp: Tue Jul 11 14:53:31 2023 diff --git a/typhos/tests/utils/user_device_notes.yaml b/typhos/tests/utils/user_device_notes.yaml new file mode 100644 index 00000000..541115f6 --- /dev/null +++ b/typhos/tests/utils/user_device_notes.yaml @@ -0,0 +1,3 @@ +Syn:Motor: + note: user note main + timestamp: Tue Jul 11 15:14:00 2023 diff --git a/typhos/utils.py b/typhos/utils.py index 075f0fa0..c32ed90a 100644 --- a/typhos/utils.py +++ b/typhos/utils.py @@ -1651,3 +1651,56 @@ def raise_window(widget): window.raise_() window.activateWindow() window.setFocus() + + +class FrameOnEditFilter(QtCore.QObject): + """ + A QLineEdit event filter for editing vs not editing style handling. + This will make the QLineEdit look like a QLabel when the user is + not editing it. + """ + def eventFilter(self, object: QtWidgets.QLineEdit, event: QtCore.QEvent) -> bool: + # Even if we install only on line edits, this can be passed a generic + # QWidget when we remove and clean up the line edit widget. + if not isinstance(object, QtWidgets.QLineEdit): + return False + if event.type() == QtCore.QEvent.FocusIn: + self.set_edit_style(object) + return False + if event.type() == QtCore.QEvent.FocusOut: + self.set_no_edit_style(object) + return False + return False + + @staticmethod + def set_edit_style(object: QtWidgets.QLineEdit): + """ + Set a QLineEdit to the look and feel we want for editing. + Parameters + ---------- + object : QLineEdit + Any line edit widget. + """ + object.setFrame(True) + color = object.palette().color(QtGui.QPalette.ColorRole.Base) + object.setStyleSheet( + f"QLineEdit {{ background: rgba({color.red()}," + f"{color.green()}, {color.blue()}, {color.alpha()})}}" + ) + object.setReadOnly(False) + + @staticmethod + def set_no_edit_style(object: QtWidgets.QLineEdit): + """ + Set a QLineEdit to the look and feel we want for not editing. + Parameters + ---------- + object : QLineEdit + Any line edit widget. + """ + if object.text(): + object.setFrame(False) + object.setStyleSheet( + "QLineEdit { background: transparent }" + ) + object.setReadOnly(True)