From b6a481bba0df6f832aaf6e77d75c1357ca7dd6a4 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Tue, 25 Jun 2024 11:11:14 -0700 Subject: [PATCH 1/8] Archive Viewer file import and export --- .gitignore | 5 + archive_viewer/archive_viewer.py | 7 +- archive_viewer/archive_viewer.ui | 19 +- archive_viewer/av_file_convert.py | 345 +++++++++++++++++++++ archive_viewer/config.json | 1 + archive_viewer/config.py | 10 +- archive_viewer/mixins/__init__.py | 3 +- archive_viewer/mixins/file_io.py | 69 +++++ archive_viewer/mixins/traces_table.py | 7 +- archive_viewer/table_models/axis_model.py | 32 +- archive_viewer/table_models/curve_model.py | 21 +- 11 files changed, 507 insertions(+), 12 deletions(-) create mode 100755 archive_viewer/av_file_convert.py create mode 100644 archive_viewer/mixins/file_io.py diff --git a/.gitignore b/.gitignore index 3e0a14c..d71e7c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# Config & Save files +*.txt +*.xml +*.pyav + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/archive_viewer/archive_viewer.py b/archive_viewer/archive_viewer.py index 509c975..d40c95f 100644 --- a/archive_viewer/archive_viewer.py +++ b/archive_viewer/archive_viewer.py @@ -1,13 +1,13 @@ from functools import partial from qtpy.QtCore import Slot -from qtpy.QtWidgets import QAbstractButton, QApplication +from qtpy.QtWidgets import (QAbstractButton, QApplication) from pydm import Display from config import logger -from mixins import (TracesTableMixin, AxisTableMixin) +from mixins import (TracesTableMixin, AxisTableMixin, FileIOMixin) from styles import CenterCheckStyle -class ArchiveViewer(Display, TracesTableMixin, AxisTableMixin): +class ArchiveViewer(Display, TracesTableMixin, AxisTableMixin, FileIOMixin): def __init__(self, parent=None, args=None, macros=None, ui_filename=__file__.replace(".py", ".ui")) -> None: super(ArchiveViewer, self).__init__(parent=parent, args=args, macros=macros, ui_filename=ui_filename) @@ -17,6 +17,7 @@ def __init__(self, parent=None, args=None, macros=None, ui_filename=__file__.rep self.axis_table_init() self.traces_table_init() + self.file_io_init() self.curve_delegates_init() self.axis_delegates_init() diff --git a/archive_viewer/archive_viewer.ui b/archive_viewer/archive_viewer.ui index e964524..090589c 100644 --- a/archive_viewer/archive_viewer.ui +++ b/archive_viewer/archive_viewer.ui @@ -24,6 +24,20 @@ + + + + Test Export + + + + + + + Test Import + + + @@ -71,6 +85,9 @@ true + + true + timespan_btns @@ -111,7 +128,7 @@ true - true + false timespan_btns diff --git a/archive_viewer/av_file_convert.py b/archive_viewer/av_file_convert.py new file mode 100755 index 0000000..daf5903 --- /dev/null +++ b/archive_viewer/av_file_convert.py @@ -0,0 +1,345 @@ +import json +import logging +import xml.etree.ElementTree as ET +from os import (path, getenv) +from typing import (Dict, Union) +from pathlib import Path +from datetime import datetime +from argparse import (ArgumentParser, Action, Namespace) +from qtpy.QtGui import QColor +from collections import OrderedDict +from pydm.widgets.timeplot import PyDMTimePlot + + +if __name__ in logging.Logger.manager.loggerDict: + logger = logging.getLogger(__name__) +else: + logger = logging.getLogger("") + handler = logging.StreamHandler() + formatter = logging.Formatter("[%(asctime)s] [%(levelname)-8s] - %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel("INFO") + handler.setLevel("INFO") + + +class ArchiveViewerFileConverter(): + def __init__(self, input_file: Union[str, Path] = "", output_file: Union[str, Path] = "") -> None: + self.input_file = input_file + self.output_file = output_file + + self.stored_data = None + + def import_is_xml(self): + """Helper function to determine if the import file is in XML format.""" + with self.input_file.open() as f: + return f.readline().startswith(" Dict: + """Import Archive Viewer save data from the provided file. The file + should be one of two types: '.pyav' or '.xml'. The data is returned as + well as saved in the stored_data property. + + Parameters + ---------- + file_name : str or pathlib.Path + The absolute filepath for the input file to import + + Returns + ------- + dict + A python dictionaty containing the data imported from the provided file + """ + if file_name: + self.input_file = Path(file_name) + if not self.input_file.is_file(): + raise FileNotFoundError(f"Data file not found: {self.input_file}") + + with self.input_file.open() as f: + is_xml = f.readline().startswith(" None: + """Export the provided Archive Viewer save data to the provided file. + The file to export to should be of type '.pyav'. The provided data can + be either a dictionary or a PyDMTimePlot object. If no data is provided, + then the converter's previously imported data is exported. + + Parameters + ---------- + file_name : str or pathlib.Path + The absolute file path of the file that save data should be written + to. Should be of file type '.pyav'. + output_data : dict or PyDMTimePlot, optional + The data that should be exported, by default uses previously imported data + + Raises + ------ + FileNotFoundError + If the provided file name does not match the expected output file type '.pyav' + ValueError + If no output data is provided and the converter hasn't imported data previously + """ + if file_name: + self.output_file = Path(file_name) + if not self.output_file.suffix: + self.output_file = self.output_file.with_suffix(".pyav") + elif not self.output_file.match("*.pyav"): + raise FileNotFoundError(f"Incorrect output file format: {self.output_file.suffix}") + + if not output_data: + if not self.stored_data: + raise ValueError("Output data is required but was not provided " + "and the 'stored_data' property is not populated.") + output_data = self.stored_data + elif isinstance(output_data, PyDMTimePlot): + output_data = self.get_plot_data(output_data) + + for obj in output_data['y-axes'] + output_data['curves']: + for k, v in obj.copy().items(): + if v is None: + del obj[k] + + with open(self.output_file, 'w') as f: + json.dump(output_data, f, indent=4) + + def convert_data(self, data_in: Dict = {}) -> Dict: + """Convert the inputted data from being formatted for the Java Archive + Viewer to a format used by the PyDM Archive Viewer. This is accomplished + by converting one dictionary structure to another. + + Parameters + ---------- + data_in : dict, optional + The input data to be converted, by default uses previously imported data + + Returns + ------- + dict + The converted data in a format that can be used by the PyDM Archive Viewer + """ + if not data_in: + data_in = self.stored_data + + converted_data = {} + + converted_data['archiver_url'] = data_in.get("connection_parameter", + getenv("PYDM_ARCHIVER_URL")) + converted_data['archiver_url'] = converted_data['archiver_url'].replace("pbraw://", "http://") + + legend_dict = data_in['legend_configuration'] + legend_dict['show_curve_name'] = legend_dict['show_ave_name'] + del legend_dict['show_ave_name'] + + converted_data['plot'] = {'title': data_in['plot_title'], + 'legend': legend_dict} + + converted_data['time_axis'] = data_in['time_axis'][0] + converted_data['y-axes'] = [] + for axis_in in data_in['range_axis']: + ax_dict = {'name': axis_in['name'], + 'label': axis_in['name'], + 'minRange': axis_in['min'], + 'maxRange': axis_in['max'], + 'orientation': axis_in['location'], + 'logMode': axis_in['type'] != "normal"} + filtered_dict = self.remove_null_values(ax_dict) + converted_data['y-axes'].append(filtered_dict) + + converted_data['curves'] = [] + for pv_in in data_in['pv']: + color = self.srgb_to_qColor(pv_in['color']) + pv_dict = {'name': pv_in['name'], + 'channel': pv_in['name'], + 'yAxisName': pv_in['range_axis_name'], + 'lineWidth': float(pv_in['draw_width']), + 'color': color.name(), + 'thresholdColor': color.name()} + filtered_dict = self.remove_null_values(pv_dict) + converted_data['curves'].append(filtered_dict) + + for formula_in in data_in['formula']: + # TODO convert formulas once formulas are implemented for ArchiveViewer + pass + + self.stored_data = converted_data + return self.stored_data + + @staticmethod + def xml_to_dict(xml: ET.ElementTree) -> Dict: + """Convert an XML ElementTree containing an Archive Viewer save + file to a dictionary for easier use + + Parameters + ---------- + xml : ET.ElementTree + The XML ElementTree object read from the file + + Returns + ------- + dict + The data in a dictionary format + """ + data_dict = {'connection_parameter': '', + 'plot_title': '', + 'legend_configuration': {}, + 'time_axis': [], + 'range_axis': [], + 'pv': [], + 'formula': []} + + data_dict['connection_parameter'] = xml.find("connection_parameter").text + data_dict['plot_title'] = xml.find("plot_title").text + data_dict['legend_configuration'] = xml.find("legend_configuration").attrib + + for key in ("time_axis", "range_axis", "pv", "formula"): + for element in xml.findall(key): + ele_dict = element.attrib + ele_dict |= {sub_ele.tag: sub_ele.text for sub_ele in element} + data_dict[key].append(ele_dict) + + return data_dict + + @staticmethod + def get_plot_data(plot: PyDMTimePlot) -> Dict: + """Extract plot, axis, and curve data from a PyDMTimePlot object""" + output_dict = {'archiver_url': getenv("PYDM_ARCHIVER_URL"), + 'plot': {}, + 'time_axis': {}, + 'y-axes': [], + 'curves': []} + + [start_ts, end_ts] = plot.getXAxis().range + start_dt = datetime.fromtimestamp(start_ts) + end_dt = datetime.fromtimestamp(end_ts) + + output_dict['time_axis'] = {'name': "Main Time Axis", + 'start': start_dt.isoformat(sep=' ', timespec='seconds'), + 'end': end_dt.isoformat(sep=' ', timespec='seconds'), + 'location': "bottom"} + + for a in plot.getYAxes(): + axis_dict = json.loads(a, object_pairs_hook=OrderedDict) + output_dict['y-axes'].append(axis_dict) + + for c in plot.getCurves(): + curve_dict = json.loads(c, object_pairs_hook=OrderedDict) + if not curve_dict['channel']: + continue + output_dict['curves'].append(curve_dict) + + return output_dict + + @staticmethod + def srgb_to_qColor(srgb: str) -> QColor: + """Convert RGB strings to QColors. The string is a 32-bit + integer containing the aRGB values of a color. (e.g. #FF0000 or -65536) + + Parameters + ---------- + srgb : str + Either a hex value or a string containing a signed 32-bit integer + + Returns + ------- + QColor + A QColor object storing the color described in the string + """ + if not srgb: + return QColor() + elif srgb[0] != '#': + rgb_int = int(srgb) & 0xFFFFFFFF + srgb = f"#{rgb_int:08X}" + return QColor(srgb) + + @staticmethod + def remove_null_values(dict_in: Dict) -> Dict: + """Remove all key-value pairs from a given dictionary where the value is None""" + dict_out = dict_in.copy() + for k, v in dict_in.items(): + if v is None: + del dict_out[k] + return dict_out + + +def main(input_file: Path = None, output_file: Path = None, overwrite: bool = False, clean: bool = False): + # Check that the input file is usable + if not input_file: + raise FileNotFoundError("Input file not provided") + elif not input_file.is_file(): + raise FileNotFoundError(f"Data file not found: {input_file}") + elif not input_file.match("*.xml"): + raise FileNotFoundError(f"Incorrect input file format: {input_file}") + + # Check that the output file is usable + if not output_file: + output_file = input_file.with_suffix(".pyav") + elif not output_file.suffix: + output_file = output_file.with_suffix(".pyav") + elif not output_file.match("*.pyav"): + raise FileNotFoundError(f"Incorrect output file format: {output_file}") + + # Check if file exists, and if it does if the overwrite flag is used + if output_file.is_file() and not overwrite: + raise FileNotFoundError(f"Output file exists but overwrite not enabled: {output_file}") + + # Complete the requested conversion + converter = ArchiveViewerFileConverter() + + converter.import_file(input_file) + converter.convert_data() + converter.export_file(output_file) + + # Remove the input file if requested + if clean: + input_file.unlink() + logger.debug(f"Removing input file: {input_file}") + + return 0 + + +if __name__ == "__main__": + class PathAction(Action): + def __call__(self, parser: ArgumentParser, namespace: Namespace, values: str, option_string: str = None) -> None: + """Convert filepath string from argument into a pathlib.Path object""" + new_path = path.expandvars(values) + new_path = Path(new_path).expanduser() + new_path = new_path.resolve() + setattr(namespace, self.dest, new_path) + + parser = ArgumentParser(prog="Archive Viewer File Converter", + description="Convert files used by the Java Archive" + " Viewer to a file format that can be used with the" + " newer PyDM Archive Viewer.") + parser.add_argument("input_file", + action=PathAction, + type=str, + help="Path to the file to be converted") + parser.add_argument("--output_file", "-o", + action=PathAction, + type=str, + help="Path to the output file (defaults to input file name)") + parser.add_argument("--overwrite", "-w", + action="store_true", + help="Overwrite the target file if it exists") + parser.add_argument("--clean", + action="store_true", + help="Remove the input file after successful conversion") + args = parser.parse_args() + + try: + main(**vars(args)) + except Exception as e: + logger.error(e) diff --git a/archive_viewer/config.json b/archive_viewer/config.json index df314fd..6103731 100644 --- a/archive_viewer/config.json +++ b/archive_viewer/config.json @@ -1,4 +1,5 @@ { + "save_file_dir": "$PHYSICS_DATA/ArchiveViewer/", "archivers": { "LCLS": "http://lcls-archapp.slac.stanford.edu" }, diff --git a/archive_viewer/config.py b/archive_viewer/config.py index 35aa325..b34b389 100644 --- a/archive_viewer/config.py +++ b/archive_viewer/config.py @@ -1,13 +1,19 @@ import os from json import load +from pathlib import Path from logging import getLogger from qtpy.QtGui import QColor +config_file = Path(__file__).parent / "config.json" +with config_file.open() as f: + loaded_json = load(f) + logger = getLogger(__name__) -with open("config.json") as f: - loaded_json = load(f) +save_file_dir = Path(os.path.expandvars(loaded_json['save_file_dir'])) +if not save_file_dir.is_dir(): + save_file_dir = Path.home() archiver_urls = loaded_json['archivers'] if not archiver_urls: diff --git a/archive_viewer/mixins/__init__.py b/archive_viewer/mixins/__init__.py index a66b6ca..63245d8 100644 --- a/archive_viewer/mixins/__init__.py +++ b/archive_viewer/mixins/__init__.py @@ -1,2 +1,3 @@ -from .traces_table import TracesTableMixin +from .file_io import FileIOMixin from .axis_table import AxisTableMixin +from .traces_table import TracesTableMixin diff --git a/archive_viewer/mixins/file_io.py b/archive_viewer/mixins/file_io.py new file mode 100644 index 0000000..7dede7c --- /dev/null +++ b/archive_viewer/mixins/file_io.py @@ -0,0 +1,69 @@ +from os import getenv +from pathlib import Path +from datetime import datetime +from urllib.parse import urlparse +from qtpy.QtWidgets import (QMessageBox, QFileDialog) +from config import (logger, save_file_dir) +from av_file_convert import ArchiveViewerFileConverter + + +class FileIOMixin: + def file_io_init(self): + self.converter = ArchiveViewerFileConverter() + + self.io_path = save_file_dir + + self.ui.test_export_btn.clicked.connect(self.export_save_file) + self.ui.test_import_btn.clicked.connect(self.import_save_file) + + def export_save_file(self): + file_name, _ = QFileDialog.getSaveFileName(self, "Save Archive Viewer", + str(self.io_path), + "Python Archive Viewer (*.pyav)") + file_name = Path(file_name) + if file_name.is_dir(): + logger.warning("No file name provided") + return + + try: + self.io_path = file_name.parent + self.converter.export_file(file_name, self.ui.archiver_plot) + except FileNotFoundError as e: + logger.error(e) + self.export_save_file() + + def import_save_file(self): + file_name, _ = QFileDialog.getOpenFileName(self, "Open Archive Viewer", + str(self.io_path), + "Python Archive Viewer (*.pyav);;" + + "Java Archive Viewer (*.xml);;" + + "All Files (*)") + file_name = Path(file_name) + if not file_name.is_file(): + return + + try: + file_data = self.converter.import_file(file_name) + if self.converter.import_is_xml(): + file_data = self.converter.convert_data(file_data) + self.io_path = file_name.parent + except FileNotFoundError as e: + logger.error(e) + self.import_save_file() + return + + import_url = urlparse(file_data['archiver_url']) + archiver_url = urlparse(getenv("PYDM_ARCHIVER_URL")) + if import_url.hostname != archiver_url.hostname: + ret = QMessageBox.warning(self, + "Import Error", + "The config file you tried to open reads from a different archiver.\n" + f"\nCurrent archiver is:\n{archiver_url.hostname}\n" + f"\nAttempted import uses:\n{import_url.hostname}", + QMessageBox.Ok | QMessageBox.Cancel, + QMessageBox.Ok) + if ret == QMessageBox.Cancel: + return + + self.axis_table_model.set_model_axes(file_data['y-axes']) + self.curves_model.set_model_curves(file_data['curves']) diff --git a/archive_viewer/mixins/traces_table.py b/archive_viewer/mixins/traces_table.py index 827315f..555193a 100644 --- a/archive_viewer/mixins/traces_table.py +++ b/archive_viewer/mixins/traces_table.py @@ -1,4 +1,5 @@ from typing import (Dict, Any) +from qtpy import sip from qtpy.QtCore import (Slot, QPoint, QModelIndex, QObject) from qtpy.QtWidgets import (QHeaderView, QMenu, QAction, QTableView, QDialog, QVBoxLayout, QGridLayout, QLineEdit, QPushButton) @@ -123,8 +124,12 @@ def axis_change(self, row: int, axis_name: str) -> None: axis_name : str The name of the new axis the curve should be on """ + if not axis_name: + return + # Get the curve and check if it has been deleted curve = self.curves_model.curve_at_index(row) - self.ui.archiver_plot.plotItem.linkDataToAxis(curve, axis_name) + if not sip.isdeleted(curve): + self.ui.archiver_plot.plotItem.linkDataToAxis(curve, axis_name) class PVContextMenu(QMenu): diff --git a/archive_viewer/table_models/axis_model.py b/archive_viewer/table_models/axis_model.py index f8d33c7..d164e10 100644 --- a/archive_viewer/table_models/axis_model.py +++ b/archive_viewer/table_models/axis_model.py @@ -1,6 +1,6 @@ -from typing import Any +from typing import (Any, List, Dict) from qtpy.QtCore import (Qt, QVariant, QPersistentModelIndex, QModelIndex) -from pydm.widgets.baseplot import BasePlot, BasePlotAxisItem +from pydm.widgets.baseplot import (BasePlot, BasePlotAxisItem) from pydm.widgets.axis_table_model import BasePlotAxesModel @@ -93,6 +93,34 @@ def append(self, name: str = "") -> None: row = self.rowCount() - 1 self.attach_range_changed(row, new_axis) + def set_model_axes(self, axes: List[Dict]) -> None: + key_translate = {"minRange": "min_range", + "maxRange": "max_range", + "autoRange": "enable_auto_range", + "logMode": "log_mode"} + cleaned_axes = [] + for a in axes: + clean_a = {} + for k, v in a.items(): + if v is None: + continue + elif k in key_translate: + new_k = key_translate[k] + clean_a[new_k] = a[k] + else: + clean_a[k] = a[k] + cleaned_axes.append(clean_a) + + self.beginResetModel() + self._plot.clearAxes() + + for a in cleaned_axes: + self._plot.addAxis(None, **a) + + for row, axis in enumerate(self._plot._axes): + self.attach_range_changed(row, axis) + self.endResetModel() + def removeAtIndex(self, index: QModelIndex) -> None: """Removes the axis at the given table index. diff --git a/archive_viewer/table_models/curve_model.py b/archive_viewer/table_models/curve_model.py index c049b83..426a51f 100644 --- a/archive_viewer/table_models/curve_model.py +++ b/archive_viewer/table_models/curve_model.py @@ -1,9 +1,9 @@ -from typing import (Any, Optional) +from typing import (Any, List, Dict, Optional) +from qtpy.QtGui import QColor from qtpy.QtCore import (QObject, QModelIndex, Qt) from pydm.widgets.baseplot import BasePlot from pydm.widgets.archiver_time_plot import ArchivePlotCurveItem from pydm.widgets.archiver_time_plot_editor import PyDMArchiverTimePlotCurvesModel -from qtpy.QtGui import QColor from widgets import ColorButton from table_models import ArchiverAxisModel @@ -107,6 +107,23 @@ def append(self, address: Optional[str] = None, name: Optional[str] = None, colo self._plot.addYChannel(y_channel=address, name=name, color=color, useArchiveData=True, yAxisName=y_axis.name) self.endInsertRows() + def set_model_curves(self, curves: List[Dict]) -> None: + self.beginResetModel() + self._plot.clearCurves() + self._row_names = [] + + for c in curves: + for k, v in c.items(): + if v is None: + del c[k] + c['y_channel'] = c['channel'] + del c['channel'] + self._plot.addYChannel(**c) + self._row_names.append(self.next_header()) + self.append() + + self.endResetModel() + def removeAtIndex(self, index: QModelIndex) -> None: """Removes the curve at the given table index. From ce0aa114899589b004a55cecb7e61725b70089a9 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 11 Jul 2024 09:55:21 -0700 Subject: [PATCH 2/8] ENH: Parse imported times --- archive_viewer/mixins/file_io.py | 206 ++++++++++++++++++++++++++++++- 1 file changed, 205 insertions(+), 1 deletion(-) diff --git a/archive_viewer/mixins/file_io.py b/archive_viewer/mixins/file_io.py index 7dede7c..ad3fa0d 100644 --- a/archive_viewer/mixins/file_io.py +++ b/archive_viewer/mixins/file_io.py @@ -1,6 +1,8 @@ from os import getenv +from re import compile +from typing import Tuple from pathlib import Path -from datetime import datetime +from datetime import (datetime, timedelta) from urllib.parse import urlparse from qtpy.QtWidgets import (QMessageBox, QFileDialog) from config import (logger, save_file_dir) @@ -10,6 +12,7 @@ class FileIOMixin: def file_io_init(self): self.converter = ArchiveViewerFileConverter() + self.parser = IOTimeParser() self.io_path = save_file_dir @@ -17,6 +20,7 @@ def file_io_init(self): self.ui.test_import_btn.clicked.connect(self.import_save_file) def export_save_file(self): + """Prompt the user for a file to export config data to""" file_name, _ = QFileDialog.getSaveFileName(self, "Save Archive Viewer", str(self.io_path), "Python Archive Viewer (*.pyav)") @@ -33,6 +37,7 @@ def export_save_file(self): self.export_save_file() def import_save_file(self): + """Prompt the user for which config file to import from""" file_name, _ = QFileDialog.getOpenFileName(self, "Open Archive Viewer", str(self.io_path), "Python Archive Viewer (*.pyav);;" @@ -65,5 +70,204 @@ def import_save_file(self): if ret == QMessageBox.Cancel: return + try: + start_str = file_data['time_axis']['start'] + end_str = file_data['time_axis']['end'] + start_dt, end_dt = self.parser.parse_times(start_str, end_str) + logger.warning(f"Starting time: {start_dt}") + logger.warning(f"Ending time: {end_dt}") + except ValueError as e: + logger.error(e) + self.import_save_file() + return + self.axis_table_model.set_model_axes(file_data['y-axes']) self.curves_model.set_model_curves(file_data['curves']) + + self.ui.cursor_scale_btn.click() + if end_str == "now": + delta = end_dt - start_dt + timespan = delta.total_seconds() + self.ui.archiver_plot.setAutoScroll(True, timespan) + else: + x_range = (start_dt.timestamp(), end_dt.timestamp()) + self.ui.archiver_plot.plotItem.disableXAutoRange() + self.ui.archiver_plot.plotItem.setXRange(*x_range) + + +class IOTimeParser: + def __init__(self): + self.full_relative_re = compile(r"^([+-]?\d+[yMwdHms] ?)*\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") + self.full_absolute_re = compile(r"^\d{4}-[01]\d-[0-3]\d\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") + + self.relative_re = compile(r"(? bool: + """Check if the given string is a relative time (e.g. '+1d', + '-8h', '-1w 08:00') + + Parameters + ---------- + time : str + Time string to check + + Returns + ------- + bool + """ + found = self.full_relative_re.fullmatch(time) + return bool(found) + + def is_absolute(self, time: str) -> bool: + """Check if the given string is an absolute time (e.g. + '2024-07-16 08:00') + + Parameters + ---------- + time : str + Time string to check + + Returns + ------- + bool + """ + found = self.full_absolute_re.fullmatch(time) + return bool(found) + + def relative_to_delta(self, time: str) -> timedelta: + """Convert the given string containing a relative time into a + datetime.timedelta + + Parameters + ---------- + time : str + String consisting of a time in a relative format (e.g. '-1d') + + Returns + ------- + datetime.timedelta + A duration expressing the difference between two datetimes + """ + td = timedelta() + negative = True + for token in self.relative_re.findall(time): + logger.debug(f"Processing relative time token: {token}") + if token[0] in '+-': + negative = token[0] == '-' + elif negative: + token = '-' + token + number = int(token[:-1]) + + unit = token[-1] + if unit == 's': + td += timedelta(seconds=number) + elif unit == 'm': + td += timedelta(minutes=number) + elif unit == 'H': + td += timedelta(hours=number) + elif unit == 'w': + td += timedelta(weeks=number) + elif unit in 'yMd': + if unit == 'y': + number *= 365 + elif unit == 'M': + number *= 30 + td += timedelta(days=number) + logger.debug(f"Relative time '{time}' as delta: {td}") + return td + + def set_time_on_datetime(self, dt: datetime, time_str: str) -> datetime: + """Set an absolute time on a datetime object + + Parameters + ---------- + dt : datetime + The datetime to alter + time_str : str + The string containing the new time to set (e.g. '-1d 15:00') + + Returns + ------- + datetime + The datetime object with the same date and the new time + """ + time = self.time_re.search(time_str).group() + if not time: + return dt + + if time.count(':') == 1: + time += ":00" + h, m, s = map(int, map(float, time.split(':'))) + dt = dt.replace(hour=h, minute=m, second=s) + + return dt + + def parse_times(self, start_str: str, end_str: str) -> Tuple[datetime, datetime]: + """Convert 2 strings containing a start and end date & time, return the + values' datetime objects. The strings can be formatted as either absolute + times or relative times. Both are needed as relative times may be relative + to the other time. + + Parameters + ---------- + start_str : str + The leftmost time the x-axis of the plot should show + end_str : str + The rigthmost time the x-axis of the plot should show, should be >start + + Returns + ------- + Tuple[datetime, datetime] + The python datetime objects for the exact start and end datetimes referenced + + Raises + ------ + ValueError + One of the given strings is in an incorrect format + """ + start_dt = start_delta = None + end_dt = end_delta = None + basetime = datetime.now() + + # Process the end time string first to determine + # if the basetime is the start time, end time, or 'now' + if end_str == "now": + end_dt = basetime + elif self.is_relative(end_str): + end_delta = self.relative_to_delta(end_str) + + # end_delta >= 0 --> the basetime is start time, so are processed after the start time + # end_delta < 0 --> the basetime is 'now' + if end_delta < timedelta(): + end_dt = basetime + end_delta + end_dt = self.set_time_on_datetime(end_dt, end_str) + elif self.is_absolute(end_str): + end_dt = datetime.fromisoformat(end_str) + basetime = end_dt + else: + raise ValueError("Time Axis end value is in an unexpected format.") + + # Process the start time string second, it may be used as the basetime + if self.is_relative(start_str): + start_delta = self.relative_to_delta(start_str) + + # start_delta >= 0 --> raise ValueError; this isn't allowed + if start_delta < timedelta(): + start_dt = basetime + start_delta + start_dt = self.set_time_on_datetime(start_dt, start_str) + else: + raise ValueError("Time Axis start value cannot be a relative time and be positive.") + elif self.is_absolute(start_str): + start_dt = datetime.fromisoformat(start_str) + else: + raise ValueError("Time Axis start value is in an unexpected format.") + + # If the end time is relative and end_delta >= 0 --> start time is the base + if end_delta and end_delta >= timedelta(): + basetime = start_dt + end_dt = end_delta + basetime + end_dt = self.set_time_on_datetime(end_dt, end_str) + + return (start_dt, end_dt) From 48f9f28fa78e0c5f70498b149401cbdff9cfa883 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 11 Jul 2024 11:06:36 -0700 Subject: [PATCH 3/8] FIX: Default axis import name & orientation --- archive_viewer/table_models/axis_model.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/archive_viewer/table_models/axis_model.py b/archive_viewer/table_models/axis_model.py index d164e10..93c2cc1 100644 --- a/archive_viewer/table_models/axis_model.py +++ b/archive_viewer/table_models/axis_model.py @@ -94,13 +94,22 @@ def append(self, name: str = "") -> None: self.attach_range_changed(row, new_axis) def set_model_axes(self, axes: List[Dict]) -> None: - key_translate = {"minRange": "min_range", - "maxRange": "max_range", - "autoRange": "enable_auto_range", - "logMode": "log_mode"} + """Given a list of dictionaries containing axis data, clear the + plot's axes, and set all new axes based on the provided axis data. + + Parameters + ---------- + axes : List[Dict] + Axis properties to be set for all new axes on the plot + """ + key_translate = {'minRange': "min_range", + 'maxRange': "max_range", + 'autoRange': "enable_auto_range", + 'logMode': "log_mode"} cleaned_axes = [] for a in axes: - clean_a = {} + clean_a = {'name': f"New Axis {len(cleaned_axes) + 1}", + 'orientation': "left"} for k, v in a.items(): if v is None: continue @@ -113,13 +122,12 @@ def set_model_axes(self, axes: List[Dict]) -> None: self.beginResetModel() self._plot.clearAxes() - for a in cleaned_axes: self._plot.addAxis(None, **a) + self.endResetModel() for row, axis in enumerate(self._plot._axes): self.attach_range_changed(row, axis) - self.endResetModel() def removeAtIndex(self, index: QModelIndex) -> None: """Removes the axis at the given table index. From 0404b6093548e3cc643f5ac5080bcc1fa8949341 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 11 Jul 2024 15:32:22 -0700 Subject: [PATCH 4/8] STY: Change IOTimeParser to classmethods --- archive_viewer/mixins/file_io.py | 77 +++++++++++++------------------- 1 file changed, 31 insertions(+), 46 deletions(-) diff --git a/archive_viewer/mixins/file_io.py b/archive_viewer/mixins/file_io.py index ad3fa0d..a7622a5 100644 --- a/archive_viewer/mixins/file_io.py +++ b/archive_viewer/mixins/file_io.py @@ -12,7 +12,6 @@ class FileIOMixin: def file_io_init(self): self.converter = ArchiveViewerFileConverter() - self.parser = IOTimeParser() self.io_path = save_file_dir @@ -73,9 +72,9 @@ def import_save_file(self): try: start_str = file_data['time_axis']['start'] end_str = file_data['time_axis']['end'] - start_dt, end_dt = self.parser.parse_times(start_str, end_str) - logger.warning(f"Starting time: {start_dt}") - logger.warning(f"Ending time: {end_dt}") + start_dt, end_dt = IOTimeParser.parse_times(start_str, end_str) + logger.debug(f"Starting time: {start_dt}") + logger.debug(f"Ending time: {end_dt}") except ValueError as e: logger.error(e) self.import_save_file() @@ -96,47 +95,31 @@ def import_save_file(self): class IOTimeParser: - def __init__(self): - self.full_relative_re = compile(r"^([+-]?\d+[yMwdHms] ?)*\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") - self.full_absolute_re = compile(r"^\d{4}-[01]\d-[0-3]\d\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") + full_relative_re = compile(r"^([+-]?\d+[yMwdHms] ?)*\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") + full_absolute_re = compile(r"^\d{4}-[01]\d-[0-3]\d\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") - self.relative_re = compile(r"(? bool: + @classmethod + def is_relative(cls, input_str: str) -> bool: """Check if the given string is a relative time (e.g. '+1d', '-8h', '-1w 08:00') - - Parameters - ---------- - time : str - Time string to check - - Returns - ------- - bool """ - found = self.full_relative_re.fullmatch(time) + found = cls.full_relative_re.fullmatch(input_str) return bool(found) - def is_absolute(self, time: str) -> bool: + @classmethod + def is_absolute(cls, input_str: str) -> bool: """Check if the given string is an absolute time (e.g. '2024-07-16 08:00') - - Parameters - ---------- - time : str - Time string to check - - Returns - ------- - bool """ - found = self.full_absolute_re.fullmatch(time) + found = cls.full_absolute_re.fullmatch(input_str) return bool(found) - def relative_to_delta(self, time: str) -> timedelta: + @classmethod + def relative_to_delta(cls, time: str) -> timedelta: """Convert the given string containing a relative time into a datetime.timedelta @@ -152,7 +135,7 @@ def relative_to_delta(self, time: str) -> timedelta: """ td = timedelta() negative = True - for token in self.relative_re.findall(time): + for token in cls.relative_re.findall(time): logger.debug(f"Processing relative time token: {token}") if token[0] in '+-': negative = token[0] == '-' @@ -178,7 +161,8 @@ def relative_to_delta(self, time: str) -> timedelta: logger.debug(f"Relative time '{time}' as delta: {td}") return td - def set_time_on_datetime(self, dt: datetime, time_str: str) -> datetime: + @classmethod + def set_time_on_datetime(cls, dt: datetime, time_str: str) -> datetime: """Set an absolute time on a datetime object Parameters @@ -193,7 +177,7 @@ def set_time_on_datetime(self, dt: datetime, time_str: str) -> datetime: datetime The datetime object with the same date and the new time """ - time = self.time_re.search(time_str).group() + time = cls.time_re.search(time_str).group() if not time: return dt @@ -204,7 +188,8 @@ def set_time_on_datetime(self, dt: datetime, time_str: str) -> datetime: return dt - def parse_times(self, start_str: str, end_str: str) -> Tuple[datetime, datetime]: + @classmethod + def parse_times(cls, start_str: str, end_str: str) -> Tuple[datetime, datetime]: """Convert 2 strings containing a start and end date & time, return the values' datetime objects. The strings can be formatted as either absolute times or relative times. Both are needed as relative times may be relative @@ -235,31 +220,31 @@ def parse_times(self, start_str: str, end_str: str) -> Tuple[datetime, datetime] # if the basetime is the start time, end time, or 'now' if end_str == "now": end_dt = basetime - elif self.is_relative(end_str): - end_delta = self.relative_to_delta(end_str) + elif cls.is_relative(end_str): + end_delta = cls.relative_to_delta(end_str) # end_delta >= 0 --> the basetime is start time, so are processed after the start time # end_delta < 0 --> the basetime is 'now' if end_delta < timedelta(): end_dt = basetime + end_delta - end_dt = self.set_time_on_datetime(end_dt, end_str) - elif self.is_absolute(end_str): + end_dt = cls.set_time_on_datetime(end_dt, end_str) + elif cls.is_absolute(end_str): end_dt = datetime.fromisoformat(end_str) basetime = end_dt else: raise ValueError("Time Axis end value is in an unexpected format.") # Process the start time string second, it may be used as the basetime - if self.is_relative(start_str): - start_delta = self.relative_to_delta(start_str) + if cls.is_relative(start_str): + start_delta = cls.relative_to_delta(start_str) # start_delta >= 0 --> raise ValueError; this isn't allowed if start_delta < timedelta(): start_dt = basetime + start_delta - start_dt = self.set_time_on_datetime(start_dt, start_str) + start_dt = cls.set_time_on_datetime(start_dt, start_str) else: raise ValueError("Time Axis start value cannot be a relative time and be positive.") - elif self.is_absolute(start_str): + elif cls.is_absolute(start_str): start_dt = datetime.fromisoformat(start_str) else: raise ValueError("Time Axis start value is in an unexpected format.") @@ -268,6 +253,6 @@ def parse_times(self, start_str: str, end_str: str) -> Tuple[datetime, datetime] if end_delta and end_delta >= timedelta(): basetime = start_dt end_dt = end_delta + basetime - end_dt = self.set_time_on_datetime(end_dt, end_str) + end_dt = cls.set_time_on_datetime(end_dt, end_str) return (start_dt, end_dt) From b6dc1037d1deafc4e120f0d34796da831c73205e Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Thu, 11 Jul 2024 15:33:06 -0700 Subject: [PATCH 5/8] ENH: File Converter reformats dates correctly --- archive_viewer/av_file_convert.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/archive_viewer/av_file_convert.py b/archive_viewer/av_file_convert.py index daf5903..f6a1ddd 100755 --- a/archive_viewer/av_file_convert.py +++ b/archive_viewer/av_file_convert.py @@ -1,6 +1,7 @@ import json import logging import xml.etree.ElementTree as ET +from re import compile from os import (path, getenv) from typing import (Dict, Union) from pathlib import Path @@ -24,6 +25,11 @@ class ArchiveViewerFileConverter(): + # Java date time conversion regex + full_java_absolute_re = compile(r"^[01]\d/[0-3]\d/\d{4}\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") + java_date_re = compile(r"^[01]\d/[0-3]\d/\d{4}") + time_re = compile(r"(?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?") + def __init__(self, input_file: Union[str, Path] = "", output_file: Union[str, Path] = "") -> None: self.input_file = input_file self.output_file = output_file @@ -146,7 +152,13 @@ def convert_data(self, data_in: Dict = {}) -> Dict: converted_data['plot'] = {'title': data_in['plot_title'], 'legend': legend_dict} - converted_data['time_axis'] = data_in['time_axis'][0] + # Convert date formats from MM/DD/YYYY --> YYYY-MM-DD + converted_data['time_axis'] = {} + for key, val in data_in['time_axis'][0].items(): + if key in ['start', 'end']: + val = self.reformat_date(val) + converted_data['time_axis'][key] = val + converted_data['y-axes'] = [] for axis_in in data_in['range_axis']: ax_dict = {'name': axis_in['name'], @@ -177,6 +189,23 @@ def convert_data(self, data_in: Dict = {}) -> Dict: self.stored_data = converted_data return self.stored_data + @classmethod + def reformat_date(cls, input_str: str) -> str: + """Convert a time string from the format 'MM/DD/YYYY' --> 'YYYY-MM-DD' + and retain time if included + """ + if not cls.full_java_absolute_re.fullmatch(input_str): + return input_str + + date = cls.java_date_re.search(input_str).group() + m, d, y = date.split('/') + formatted_date = f"{y}-{m}-{d}" + + time_match = cls.time_re.search(input_str) + if time_match: + formatted_date += " " + time_match.group() + return formatted_date + @staticmethod def xml_to_dict(xml: ET.ElementTree) -> Dict: """Convert an XML ElementTree containing an Archive Viewer save From a3cfdeab555117299d872c0cdc470af6caec86f6 Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Mon, 22 Jul 2024 10:39:34 -0700 Subject: [PATCH 6/8] ENH: Add logging when save_file_dir is not a directory --- archive_viewer/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/archive_viewer/config.py b/archive_viewer/config.py index b34b389..5099698 100644 --- a/archive_viewer/config.py +++ b/archive_viewer/config.py @@ -13,7 +13,9 @@ save_file_dir = Path(os.path.expandvars(loaded_json['save_file_dir'])) if not save_file_dir.is_dir(): + logger.warning(f"Config file's save_file_dir path does not exist: {save_file_dir}") save_file_dir = Path.home() + logger.warning(f"Setting save_file_dir to home: {save_file_dir}") archiver_urls = loaded_json['archivers'] if not archiver_urls: From 969b904b01f7ee266410415495553b5276e3e2ce Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Mon, 22 Jul 2024 11:42:04 -0700 Subject: [PATCH 7/8] ENH: Move import/export functionality to File menu --- archive_viewer/archive_viewer.py | 5 +++++ archive_viewer/archive_viewer.ui | 14 -------------- archive_viewer/mixins/file_io.py | 6 +----- launch_archive_viewer.bash | 2 +- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/archive_viewer/archive_viewer.py b/archive_viewer/archive_viewer.py index d40c95f..362f64e 100644 --- a/archive_viewer/archive_viewer.py +++ b/archive_viewer/archive_viewer.py @@ -36,6 +36,11 @@ def __init__(self, parent=None, args=None, macros=None, ui_filename=__file__.rep app = QApplication.instance() app.setStyle(CenterCheckStyle()) + def file_menu_items(self): + """Add export & import functionality to File menu""" + return {"save": (self.export_save_file, "Ctrl+S"), + "load": (self.import_save_file, "Ctrl+L")} + @Slot(QAbstractButton) def set_plot_timerange(self, button: QAbstractButton) -> None: """Slot to be called when a timespan setting button is pressed. diff --git a/archive_viewer/archive_viewer.ui b/archive_viewer/archive_viewer.ui index 090589c..8401c94 100644 --- a/archive_viewer/archive_viewer.ui +++ b/archive_viewer/archive_viewer.ui @@ -24,20 +24,6 @@ - - - - Test Export - - - - - - - Test Import - - - diff --git a/archive_viewer/mixins/file_io.py b/archive_viewer/mixins/file_io.py index a7622a5..dab1d20 100644 --- a/archive_viewer/mixins/file_io.py +++ b/archive_viewer/mixins/file_io.py @@ -11,12 +11,8 @@ class FileIOMixin: def file_io_init(self): - self.converter = ArchiveViewerFileConverter() - self.io_path = save_file_dir - - self.ui.test_export_btn.clicked.connect(self.export_save_file) - self.ui.test_import_btn.clicked.connect(self.import_save_file) + self.converter = ArchiveViewerFileConverter() def export_save_file(self): """Prompt the user for a file to export config data to""" diff --git a/launch_archive_viewer.bash b/launch_archive_viewer.bash index 193493f..790012e 100755 --- a/launch_archive_viewer.bash +++ b/launch_archive_viewer.bash @@ -12,7 +12,7 @@ exit_abnormal(){ exit 1 } -pydm --hide-nav-bar --hide-status-bar --hide-menu-bar \ +pydm --hide-nav-bar --hide-status-bar \ archive_viewer.py exit 0 From 3f00fff13322325cd1f81101ea9b124ff61f02fb Mon Sep 17 00:00:00 2001 From: Zach Domke Date: Fri, 2 Aug 2024 15:47:00 -0700 Subject: [PATCH 8/8] DOC: Requested documentation for PR --- archive_viewer/archive_viewer.py | 4 +- archive_viewer/av_file_convert.py | 61 +++++++++++++++++++++++++------ archive_viewer/mixins/file_io.py | 27 ++++++++++++-- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/archive_viewer/archive_viewer.py b/archive_viewer/archive_viewer.py index 362f64e..bbabb39 100644 --- a/archive_viewer/archive_viewer.py +++ b/archive_viewer/archive_viewer.py @@ -36,8 +36,8 @@ def __init__(self, parent=None, args=None, macros=None, ui_filename=__file__.rep app = QApplication.instance() app.setStyle(CenterCheckStyle()) - def file_menu_items(self): - """Add export & import functionality to File menu""" + def file_menu_items(self) -> dict: + """Add export & import functionality to File menu; override Display.file_menu_items""" return {"save": (self.export_save_file, "Ctrl+S"), "load": (self.import_save_file, "Ctrl+L")} diff --git a/archive_viewer/av_file_convert.py b/archive_viewer/av_file_convert.py index f6a1ddd..39b7830 100755 --- a/archive_viewer/av_file_convert.py +++ b/archive_viewer/av_file_convert.py @@ -25,6 +25,10 @@ class ArchiveViewerFileConverter(): + """Converter class that will convert save files for the Java-based Archive + Viewer into a format readable by the Trace application. This class can also + be used for importing data into Trace or exporting data from it. + """ # Java date time conversion regex full_java_absolute_re = compile(r"^[01]\d/[0-3]\d/\d{4}\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") java_date_re = compile(r"^[01]\d/[0-3]\d/\d{4}") @@ -193,6 +197,16 @@ def convert_data(self, data_in: Dict = {}) -> Dict: def reformat_date(cls, input_str: str) -> str: """Convert a time string from the format 'MM/DD/YYYY' --> 'YYYY-MM-DD' and retain time if included + + Parameters + ---------- + input_str : str + Date string in the format of 'MM/DD/YYYY'; can include a time + + Returns + ------- + str + Date string in the format of 'YYYY-MM-DD' """ if not cls.full_java_absolute_re.fullmatch(input_str): return input_str @@ -242,8 +256,19 @@ def xml_to_dict(xml: ET.ElementTree) -> Dict: return data_dict @staticmethod - def get_plot_data(plot: PyDMTimePlot) -> Dict: - """Extract plot, axis, and curve data from a PyDMTimePlot object""" + def get_plot_data(plot: PyDMTimePlot) -> dict: + """Extract plot, axis, and curve data from a PyDMTimePlot object + + Parameters + ---------- + plot : PyDMTimePlot + The PyDM Plotting object to extract data from. Gets plot, axis, and curve data. + + Returns + ------- + dict + A dictionary representation of all of the relevant data for the given plot + """ output_dict = {'archiver_url': getenv("PYDM_ARCHIVER_URL"), 'plot': {}, 'time_axis': {}, @@ -294,8 +319,19 @@ def srgb_to_qColor(srgb: str) -> QColor: return QColor(srgb) @staticmethod - def remove_null_values(dict_in: Dict) -> Dict: - """Remove all key-value pairs from a given dictionary where the value is None""" + def remove_null_values(dict_in: dict) -> dict: + """Remove all key-value pairs from a given dictionary where the value is None + + Parameters + ---------- + dict_in : dict + Some dictionary, possibly containing key-value pairs where value is None + + Returns + ------- + dict + The same dictionary, but with those key-value pairs deleted + """ dict_out = dict_in.copy() for k, v in dict_in.items(): if v is None: @@ -339,15 +375,16 @@ def main(input_file: Path = None, output_file: Path = None, overwrite: bool = Fa return 0 -if __name__ == "__main__": - class PathAction(Action): - def __call__(self, parser: ArgumentParser, namespace: Namespace, values: str, option_string: str = None) -> None: - """Convert filepath string from argument into a pathlib.Path object""" - new_path = path.expandvars(values) - new_path = Path(new_path).expanduser() - new_path = new_path.resolve() - setattr(namespace, self.dest, new_path) +class PathAction(Action): + def __call__(self, parser: ArgumentParser, namespace: Namespace, values: str, option_string: str = None) -> None: + """Convert filepath string from argument into a pathlib.Path object""" + new_path = path.expandvars(values) + new_path = Path(new_path).expanduser() + new_path = new_path.resolve() + setattr(namespace, self.dest, new_path) + +if __name__ == "__main__": parser = ArgumentParser(prog="Archive Viewer File Converter", description="Convert files used by the Java Archive" " Viewer to a file format that can be used with the" diff --git a/archive_viewer/mixins/file_io.py b/archive_viewer/mixins/file_io.py index dab1d20..fa0bcce 100644 --- a/archive_viewer/mixins/file_io.py +++ b/archive_viewer/mixins/file_io.py @@ -10,11 +10,15 @@ class FileIOMixin: - def file_io_init(self): + """Mixins class to manage the file imports and exports for Trace""" + def file_io_init(self) -> None: + """Initialize the File IO capabilities of Trace by saving a default + path and creating an ArchiveViewerFileConverter object + """ self.io_path = save_file_dir self.converter = ArchiveViewerFileConverter() - def export_save_file(self): + def export_save_file(self) -> None: """Prompt the user for a file to export config data to""" file_name, _ = QFileDialog.getSaveFileName(self, "Save Archive Viewer", str(self.io_path), @@ -31,8 +35,9 @@ def export_save_file(self): logger.error(e) self.export_save_file() - def import_save_file(self): + def import_save_file(self) -> None: """Prompt the user for which config file to import from""" + # Get the save file from the user file_name, _ = QFileDialog.getOpenFileName(self, "Open Archive Viewer", str(self.io_path), "Python Archive Viewer (*.pyav);;" @@ -42,6 +47,8 @@ def import_save_file(self): if not file_name.is_file(): return + # Import the given file, and convert it from Java Archive Viewer's + # format to Trace's format if necessary try: file_data = self.converter.import_file(file_name) if self.converter.import_is_xml(): @@ -52,6 +59,8 @@ def import_save_file(self): self.import_save_file() return + # Confirm the PYDM_ARCHIVER_URL is the same as the imported Archiver URL + # If they are not the same, prompt the user to confirm continuing import_url = urlparse(file_data['archiver_url']) archiver_url = urlparse(getenv("PYDM_ARCHIVER_URL")) if import_url.hostname != archiver_url.hostname: @@ -65,6 +74,7 @@ def import_save_file(self): if ret == QMessageBox.Cancel: return + # Parse the time range for the X-Axis try: start_str = file_data['time_axis']['start'] end_str = file_data['time_axis']['end'] @@ -76,9 +86,11 @@ def import_save_file(self): self.import_save_file() return + # Set the models to use the file data self.axis_table_model.set_model_axes(file_data['y-axes']) self.curves_model.set_model_curves(file_data['curves']) + # Enable auto scroll if the end time is "now" self.ui.cursor_scale_btn.click() if end_str == "now": delta = end_dt - start_dt @@ -91,6 +103,10 @@ def import_save_file(self): class IOTimeParser: + """Collection of classmethods to parse a given date time string. The + string can contain an absolute date and time, or a date and time that + are relative to another time or even each other. + """ full_relative_re = compile(r"^([+-]?\d+[yMwdHms] ?)*\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") full_absolute_re = compile(r"^\d{4}-[01]\d-[0-3]\d\s*((?:[01]\d|2[0-3])(?::[0-5]\d)(?::[0-5]\d(?:.\d*)?)?)?$") @@ -102,6 +118,11 @@ class IOTimeParser: def is_relative(cls, input_str: str) -> bool: """Check if the given string is a relative time (e.g. '+1d', '-8h', '-1w 08:00') + + Parameters + --------- + input_str : str + """ found = cls.full_relative_re.fullmatch(input_str) return bool(found)