Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: add notes field in TyphosDisplayTitle #557

Merged
merged 11 commits into from
Jul 25, 2023
2 changes: 2 additions & 0 deletions conda-recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ requirements:
- ophyd >=1.5.0
- pcdsdevices
- pcdsutils
- platformdirs
- pydm >=1.16.1
- pyqt
- pyqtgraph
- pyyaml
- qdarkstyle
- qtawesome
- qtconsole
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ numpy
numpydoc
ophyd
pcdsutils
platformdirs
PyQt5
pydm>=1.16.1
pyqtgraph
pyyaml
qdarkstyle
qtawesome
qtconsole
Expand Down
11 changes: 9 additions & 2 deletions typhos/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
tangkong marked this conversation as resolved.
Show resolved Hide resolved

if self.help is not None:
self.help.add_device(device)

Expand Down
189 changes: 189 additions & 0 deletions typhos/notes.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This passes the feels/vibes test for sure, it felt good to use.

Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import logging
import os
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 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

if device_name in device_notes:
return device_notes.get(device_name, None)
tangkong marked this conversation as resolved.
Show resolved Hide resolved


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}')
return
tangkong marked this conversation as resolved.
Show resolved Hide resolved

device_notes[device_name] = data

try:
with open(path, 'w') as f:
yaml.dump(device_notes, f)
tangkong marked this conversation as resolved.
Show resolved Hide resolved
except Exception as ex:
logger.warning(f'unable to write device info: {ex}')
return


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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intention single-line notes and not multi-line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my intention, but I'm not particularly tied to it. Longer descriptions should probably be covered by the bevy of other help displays (docstring, confluence), right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems logical to me

"""
A QLineEdit for storing notes for a device.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.editingFinished.connect(self.save_note)
self.setPlaceholderText('...')
self.edit_filter = utils.FrameOnEditFilter(parent=self)
self.setFrame(False)
self.setStyleSheet("QLineEdit { background: transparent }")
self.setReadOnly(True)
self.installEventFilter(self.edit_filter)

# 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.notes_source is not None:
self.setToolTip(f"({self.data['timestamp']}, {self.notes_source.name}):\n"
f"{self.data['note']}")
tangkong marked this conversation as resolved.
Show resolved Hide resolved

def setup_data(self, device_name: str) -> None:
"""
Set up the device data. Saves the device name and initializes the notes
line edit.

The setup is split up here due to how Typhos Display initialize themselves
first, then add the device later

Parameters
----------
device_name : str
The device name. Can also be a component. e.g. device_component_name
"""
if self.device_name:
return

self.device_name = device_name
self.notes_source, self.data = get_notes_data(device_name)
tangkong marked this conversation as resolved.
Show resolved Hide resolved

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)
99 changes: 99 additions & 0 deletions typhos/tests/test_notes.py
Original file line number Diff line number Diff line change
@@ -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
tangkong marked this conversation as resolved.
Show resolved Hide resolved
# 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']
6 changes: 6 additions & 0 deletions typhos/tests/utils/env_device_notes.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions typhos/tests/utils/user_device_notes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Syn:Motor:
note: user note main
timestamp: Tue Jul 11 15:14:00 2023
Loading