diff options
Diffstat (limited to 'src/silx/app')
-rw-r--r-- | src/silx/app/compare/CompareImagesWindow.py | 254 | ||||
-rw-r--r-- | src/silx/app/compare/__init__.py (renamed from src/silx/app/view/utils.py) | 23 | ||||
-rw-r--r-- | src/silx/app/compare/main.py | 105 | ||||
-rw-r--r-- | src/silx/app/compare/test/__init__.py | 23 | ||||
-rw-r--r-- | src/silx/app/compare/test/test_compare.py | 49 | ||||
-rw-r--r-- | src/silx/app/compare/test/test_launcher.py | 142 | ||||
-rw-r--r-- | src/silx/app/convert.py | 362 | ||||
-rw-r--r-- | src/silx/app/test/test_convert.py | 18 | ||||
-rw-r--r-- | src/silx/app/utils/__init__.py | 27 | ||||
-rw-r--r-- | src/silx/app/utils/parseutils.py | 133 | ||||
-rw-r--r-- | src/silx/app/utils/test/__init__.py | 23 | ||||
-rw-r--r-- | src/silx/app/utils/test/test_parseutils.py | 68 | ||||
-rw-r--r-- | src/silx/app/view/About.py | 24 | ||||
-rw-r--r-- | src/silx/app/view/ApplicationContext.py | 39 | ||||
-rw-r--r-- | src/silx/app/view/CustomNxdataWidget.py | 12 | ||||
-rw-r--r-- | src/silx/app/view/DataPanel.py | 2 | ||||
-rw-r--r-- | src/silx/app/view/Viewer.py | 183 | ||||
-rw-r--r-- | src/silx/app/view/main.py | 108 | ||||
-rw-r--r-- | src/silx/app/view/test/test_launcher.py | 13 | ||||
-rw-r--r-- | src/silx/app/view/test/test_view.py | 22 |
20 files changed, 1286 insertions, 344 deletions
diff --git a/src/silx/app/compare/CompareImagesWindow.py b/src/silx/app/compare/CompareImagesWindow.py new file mode 100644 index 0000000..7a509ae --- /dev/null +++ b/src/silx/app/compare/CompareImagesWindow.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python +# /*########################################################################## +# +# Copyright (c) 2016-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Main window used to compare images +""" + +import logging +import numpy +import typing +import os.path + +import silx.io +from silx.gui import icons +from silx.gui import qt +from silx.gui.plot.CompareImages import CompareImages +from silx.gui.widgets.UrlSelectionTable import UrlSelectionTable +from ..utils import parseutils +from silx.gui.plot.tools.profile.manager import ProfileManager +from silx.gui.plot.tools.compare.profile import ProfileImageDirectedLineROI + +try: + import PIL +except ImportError: + PIL = None + + +_logger = logging.getLogger(__name__) + + +def _get_image_from_file(urlPath: str) -> typing.Optional[numpy.ndarray]: + """Returns a dataset from an image file. + + The returned layout shape is supposed to be `rows, columns, channels (rgb[a])`. + """ + if PIL is None: + return None + return numpy.asarray(PIL.Image.open(urlPath)) + + +class CompareImagesWindow(qt.QMainWindow): + def __init__(self, backend=None, settings=None): + qt.QMainWindow.__init__(self, parent=None) + self.setWindowTitle("Silx compare") + + silxIcon = icons.getQIcon("silx") + self.setWindowIcon(silxIcon) + + self._plot = CompareImages(parent=self, backend=backend) + self._plot.setAutoResetZoom(False) + + self.__manager = ProfileManager(self, self._plot.getPlot()) + virtualItem = self._plot._getVirtualPlotItem() + self.__manager.setPlotItem(virtualItem) + + directedLineAction = self.__manager.createProfileAction( + ProfileImageDirectedLineROI, self + ) + + profileToolBar = qt.QToolBar(self) + profileToolBar.setWindowTitle("Profile") + profileToolBar.addAction(directedLineAction) + self.__profileToolBar = profileToolBar + self._plot.addToolBar(profileToolBar) + + self._selectionTable = UrlSelectionTable(parent=self) + self._selectionTable.setAcceptDrops(True) + + self.__settings = settings + if settings: + self.restoreSettings(settings) + + spliter = qt.QSplitter(self) + spliter.addWidget(self._selectionTable) + spliter.addWidget(self._plot) + spliter.setStretchFactor(1, 1) + spliter.setCollapsible(0, False) + spliter.setCollapsible(1, False) + self.__splitter = spliter + + self.setCentralWidget(spliter) + + self._selectionTable.sigImageAChanged.connect(self._updateImageA) + self._selectionTable.sigImageBChanged.connect(self._updateImageB) + + def setUrls(self, urls): + self.clear() + for url in urls: + self._selectionTable.addUrl(url) + url1 = urls[0].path() if len(urls) >= 1 else None + url2 = urls[1].path() if len(urls) >= 2 else None + self._selectionTable.setUrlSelection(url_img_a=url1, url_img_b=url2) + self._plot.resetZoom() + self._plot.centerLines() + + def clear(self): + self._plot.clear() + self._selectionTable.clear() + + def _updateImageA(self, urlPath): + try: + data = self.readData(urlPath) + except Exception as e: + _logger.error("Error while loading URL %s", urlPath, exc_info=True) + self._selectionTable.setError(urlPath, e.args[0]) + data = None + self._plot.setImage1(data) + + def _updateImageB(self, urlPath): + try: + data = self.readData(urlPath) + except Exception as e: + _logger.error("Error while loading URL %s", urlPath, exc_info=True) + self._selectionTable.setError(urlPath, e.args[0]) + data = None + self._plot.setImage2(data) + + def readData(self, urlPath: str): + """Read an URL as an image""" + if urlPath in ("", None): + return None + + data = None + _, ext = os.path.splitext(urlPath) + if ext in {".jpg", ".jpeg", ".png"}: + try: + data = _get_image_from_file(urlPath) + except Exception: + _logger.debug("Error while loading image with PIL", exc_info=True) + + if data is None: + try: + data = silx.io.utils.get_data(urlPath) + except Exception: + raise ValueError(f"Data from '{urlPath}' is not readable") + + if not isinstance(data, numpy.ndarray): + raise ValueError(f"URL '{urlPath}' does not link to a numpy array") + if data.dtype.kind not in set(["f", "u", "i", "b"]): + raise ValueError(f"URL '{urlPath}' does not link to a numeric numpy array") + + if data.ndim == 2: + return data + if data.ndim == 3 and data.shape[2] in {3, 4}: + return data + + raise ValueError(f"URL '{urlPath}' does not link to an numpy image") + + def closeEvent(self, event): + settings = self.__settings + if settings: + self.saveSettings(self.__settings) + + def saveSettings(self, settings): + """Save the window settings to this settings object + + :param qt.QSettings settings: Initialized settings + """ + isFullScreen = bool(self.windowState() & qt.Qt.WindowFullScreen) + if isFullScreen: + # show in normal to catch the normal geometry + self.showNormal() + + settings.beginGroup("comparewindow") + settings.setValue("size", self.size()) + settings.setValue("pos", self.pos()) + settings.setValue("full-screen", isFullScreen) + settings.setValue("spliter", self.__splitter.sizes()) + # NOTE: isInverted returns a numpy bool + settings.setValue( + "y-axis-inverted", bool(self._plot.getPlot().getYAxis().isInverted()) + ) + + settings.setValue("visualization-mode", self._plot.getVisualizationMode().name) + settings.setValue("alignment-mode", self._plot.getAlignmentMode().name) + settings.setValue("display-keypoints", self._plot.getKeypointsVisible()) + + displayKeypoints = settings.value("display-keypoints", False) + displayKeypoints = parseutils.to_bool(displayKeypoints, False) + + # self._plot.getAlignmentMode() + # self._plot.getVisualizationMode() + # self._plot.getKeypointsVisible() + settings.endGroup() + + if isFullScreen: + self.showFullScreen() + + def restoreSettings(self, settings): + """Restore the window settings using this settings object + + :param qt.QSettings settings: Initialized settings + """ + settings.beginGroup("comparewindow") + size = settings.value("size", qt.QSize(640, 480)) + pos = settings.value("pos", qt.QPoint()) + isFullScreen = settings.value("full-screen", False) + isFullScreen = parseutils.to_bool(isFullScreen, False) + yAxisInverted = settings.value("y-axis-inverted", False) + yAxisInverted = parseutils.to_bool(yAxisInverted, False) + + visualizationMode = settings.value("visualization-mode", "") + visualizationMode = parseutils.to_enum( + visualizationMode, + CompareImages.VisualizationMode, + CompareImages.VisualizationMode.VERTICAL_LINE, + ) + alignmentMode = settings.value("alignment-mode", "") + alignmentMode = parseutils.to_enum( + alignmentMode, + CompareImages.AlignmentMode, + CompareImages.AlignmentMode.ORIGIN, + ) + displayKeypoints = settings.value("display-keypoints", False) + displayKeypoints = parseutils.to_bool(displayKeypoints, False) + + try: + data = settings.value("spliter") + data = [int(d) for d in data] + self.__splitter.setSizes(data) + except Exception: + _logger.debug("Backtrace", exc_info=True) + settings.endGroup() + + if not pos.isNull(): + self.move(pos) + if not size.isNull(): + self.resize(size) + if isFullScreen: + self.showFullScreen() + self._plot.setVisualizationMode(visualizationMode) + self._plot.setAlignmentMode(alignmentMode) + self._plot.setKeypointsVisible(displayKeypoints) + self._plot.getPlot().getYAxis().setInverted(yAxisInverted) diff --git a/src/silx/app/view/utils.py b/src/silx/app/compare/__init__.py index 6a980e9..e5ec4c6 100644 --- a/src/silx/app/view/utils.py +++ b/src/silx/app/compare/__init__.py @@ -1,5 +1,5 @@ # /*########################################################################## -# Copyright (C) 2018 European Synchrotron Radiation Facility +# Copyright (C) 2022-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -20,25 +20,8 @@ # THE SOFTWARE. # # ############################################################################*/ -"""Browse a data file with a GUI""" +"""Package containing source code of the `silx compare` application""" __authors__ = ["V. Valls"] __license__ = "MIT" -__date__ = "28/05/2018" - - -_trueStrings = set(["yes", "true", "1"]) -_falseStrings = set(["no", "false", "0"]) - - -def stringToBool(string): - """Returns a boolean from a string. - - :raise ValueError: If the string do not contains a boolean information. - """ - lower = string.lower() - if lower in _trueStrings: - return True - if lower in _falseStrings: - return False - raise ValueError("'%s' is not a valid boolean" % string) +__date__ = "04/13/2023" diff --git a/src/silx/app/compare/main.py b/src/silx/app/compare/main.py new file mode 100644 index 0000000..79c33f1 --- /dev/null +++ b/src/silx/app/compare/main.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# /*########################################################################## +# +# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""GUI to compare images""" + +import sys +import logging +import argparse +import silx +from silx.gui import qt +from silx.app.utils import parseutils +from silx.app.compare.CompareImagesWindow import CompareImagesWindow + +_logger = logging.getLogger(__name__) + + +file_description = """ +Image data to compare (HDF5 file with path, EDF files, JPEG/PNG image files). +Data from HDF5 files can be accessed using dataset path and slicing as an URL: silx:../my_file.h5?path=/entry/data&slice=10 +EDF file frames also can can be accessed using URL: fabio:../my_file.edf?slice=10 +Using URL in command like usually have to be quoted: "URL". +""" + + +def createParser(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("files", nargs=argparse.ZERO_OR_MORE, help=file_description) + parser.add_argument( + "--debug", + dest="debug", + action="store_true", + default=False, + help="Set logging system in debug mode", + ) + parser.add_argument( + "--use-opengl-plot", + dest="use_opengl_plot", + action="store_true", + default=False, + help="Use OpenGL for plots (instead of matplotlib)", + ) + return parser + + +def mainQt(options): + """Part of the main depending on Qt""" + if options.debug: + logging.root.setLevel(logging.DEBUG) + + if options.use_opengl_plot: + backend = "gl" + else: + backend = "mpl" + + settings = qt.QSettings( + qt.QSettings.IniFormat, qt.QSettings.UserScope, "silx", "silx-compare", None + ) + + urls = list(parseutils.filenames_to_dataurls(options.files)) + + if options.use_opengl_plot: + # It have to be done after the settings (after the Viewer creation) + silx.config.DEFAULT_PLOT_BACKEND = "opengl" + + app = qt.QApplication([]) + window = CompareImagesWindow(backend=backend, settings=settings) + window.setAttribute(qt.Qt.WA_DeleteOnClose, True) + + # Note: Have to be before setUrls to have a proper resetZoom + window.setVisible(True) + + window.setUrls(urls) + + app.exec() + + +def main(argv): + parser = createParser() + options = parser.parse_args(argv[1:]) + mainQt(options) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/src/silx/app/compare/test/__init__.py b/src/silx/app/compare/test/__init__.py new file mode 100644 index 0000000..1d8207b --- /dev/null +++ b/src/silx/app/compare/test/__init__.py @@ -0,0 +1,23 @@ +# /*########################################################################## +# +# Copyright (c) 2016-2017 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ diff --git a/src/silx/app/compare/test/test_compare.py b/src/silx/app/compare/test/test_compare.py new file mode 100644 index 0000000..45c6838 --- /dev/null +++ b/src/silx/app/compare/test/test_compare.py @@ -0,0 +1,49 @@ +# /*########################################################################## +# +# Copyright (c) 2016-2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2023" + + +import weakref +import pytest +from silx.app.compare.CompareImagesWindow import CompareImagesWindow +from silx.gui.utils.testutils import TestCaseQt + + +@pytest.mark.usefixtures("qapp") +class TestCompare(TestCaseQt): + """Test for Viewer class""" + + def testConstruct(self): + widget = CompareImagesWindow() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = CompareImagesWindow() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) diff --git a/src/silx/app/compare/test/test_launcher.py b/src/silx/app/compare/test/test_launcher.py new file mode 100644 index 0000000..a42b762 --- /dev/null +++ b/src/silx/app/compare/test/test_launcher.py @@ -0,0 +1,142 @@ +# /*########################################################################## +# +# Copyright (c) 2016-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2023" + + +import os +import sys +import shutil +import logging +import subprocess +import pytest + +from .. import main +from silx import __main__ as silx_main + +_logger = logging.getLogger(__name__) + + +def test_help(qapp): + # option -h must cause a raise SystemExit or a return 0 + try: + parser = main.createParser() + parser.parse_args(["compare", "--help"]) + result = 0 + except SystemExit as e: + result = e.args[0] + assert result == 0 + + +def test_wrong_option(qapp): + try: + parser = main.createParser() + parser.parse_args(["compare", "--foo"]) + assert False + except SystemExit as e: + result = e.args[0] + assert result != 0 + + +def test_wrong_file(qapp): + try: + parser = main.createParser() + result = parser.parse_args(["compare", "__file.not.found__"]) + result = 0 + except SystemExit as e: + result = e.args[0] + assert result == 0 + + +def _create_test_env(): + """ + Returns an associated environment with a working project. + """ + env = dict((str(k), str(v)) for k, v in os.environ.items()) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + return env + + +@pytest.fixture +def execute_as_script(tmp_path): + """Execute a command line. + + Log output as debug in case of bad return code. + """ + + def execute_as_script(filename, *args): + env = _create_test_env() + + # Copy file to temporary dir to avoid import from current dir. + script = os.path.join(tmp_path, "launcher.py") + shutil.copyfile(filename, script) + command_line = [sys.executable, script] + list(args) + + _logger.info("Execute: %s", " ".join(command_line)) + p = subprocess.Popen( + command_line, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env + ) + out, err = p.communicate() + _logger.info("Return code: %d", p.returncode) + try: + out = out.decode("utf-8") + except UnicodeError: + pass + try: + err = err.decode("utf-8") + except UnicodeError: + pass + + if p.returncode != 0: + _logger.error("stdout:") + _logger.error("%s", out) + _logger.error("stderr:") + _logger.error("%s", err) + else: + _logger.debug("stdout:") + _logger.debug("%s", out) + _logger.debug("stderr:") + _logger.debug("%s", err) + assert p.returncode == 0 + + return execute_as_script + + +def test_execute_compare_help(qapp, execute_as_script): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + execute_as_script(main.__file__, "--help") + + +def test_execute_silx_compare_help(qapp, execute_as_script): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + execute_as_script(silx_main.__file__, "view", "--help") diff --git a/src/silx/app/convert.py b/src/silx/app/convert.py index 78c1ebf..e20a448 100644 --- a/src/silx/app/convert.py +++ b/src/silx/app/convert.py @@ -85,8 +85,10 @@ def drop_indices_before_begin(filenames, regex, begin): m = re.match(regex, fname) file_indices = list(map(int, m.groups())) if len(file_indices) != len(begin_indices): - raise IOError("Number of indices found in filename " - "does not match number of parsed end indices.") + raise IOError( + "Number of indices found in filename " + "does not match number of parsed end indices." + ) good_indices = True for i, fidx in enumerate(file_indices): if fidx < begin_indices[i]: @@ -110,8 +112,10 @@ def drop_indices_after_end(filenames, regex, end): m = re.match(regex, fname) file_indices = list(map(int, m.groups())) if len(file_indices) != len(end_indices): - raise IOError("Number of indices found in filename " - "does not match number of parsed end indices.") + raise IOError( + "Number of indices found in filename " + "does not match number of parsed end indices." + ) good_indices = True for i, fidx in enumerate(file_indices): if fidx > end_indices[i]: @@ -133,15 +137,17 @@ def are_files_missing_in_series(filenames, regex): previous_indices = None for fname in filenames: m = re.match(regex, fname) - assert m is not None, \ - "regex %s does not match filename %s" % (fname, regex) + assert m is not None, "regex %s does not match filename %s" % (fname, regex) new_indices = list(map(int, m.groups())) if previous_indices is not None: for old_idx, new_idx in zip(previous_indices, new_indices): if (new_idx - old_idx) > 1: - _logger.error("Index increment > 1 in file series: " - "previous idx %d, next idx %d", - old_idx, new_idx) + _logger.error( + "Index increment > 1 in file series: " + "previous idx %d, next idx %d", + old_idx, + new_idx, + ) return True previous_indices = new_indices return False @@ -196,116 +202,134 @@ def main(argv): """ parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( - 'input_files', + "input_files", nargs="*", - help='Input files (EDF, TIFF, FIO, SPEC...). When specifying ' - 'multiple files, you cannot specify both fabio images ' - 'and SPEC (or FIO) files. Multiple SPEC or FIO files will ' - 'simply be concatenated, with one entry per scan. ' - 'Multiple image files will be merged into a single ' - 'entry with a stack of images.') + help="Input files (EDF, TIFF, FIO, SPEC...). When specifying " + "multiple files, you cannot specify both fabio images " + "and SPEC (or FIO) files. Multiple SPEC or FIO files will " + "simply be concatenated, with one entry per scan. " + "Multiple image files will be merged into a single " + "entry with a stack of images.", + ) # input_files and --filepattern are mutually exclusive parser.add_argument( - '--file-pattern', - help='File name pattern for loading a series of indexed image files ' - '(toto_%%04d.edf). This argument is incompatible with argument ' - 'input_files. If an output URI with a HDF5 path is provided, ' - 'only the content of the NXdetector group will be copied there. ' - 'If no HDF5 path, or just "/", is given, a complete NXdata ' - 'structure will be created.') + "--file-pattern", + help="File name pattern for loading a series of indexed image files " + "(toto_%%04d.edf). This argument is incompatible with argument " + "input_files. If an output URI with a HDF5 path is provided, " + "only the content of the NXdetector group will be copied there. " + 'If no HDF5 path, or just "/", is given, a complete NXdata ' + "structure will be created.", + ) parser.add_argument( - '-o', '--output-uri', - default=time.strftime("%Y%m%d-%H%M%S") + '.h5', - help='Output file name (HDF5). An URI can be provided to write' - ' the data into a specific group in the output file: ' - '/path/to/file::/path/to/group. ' - 'If not provided, the filename defaults to a timestamp:' - ' YYYYmmdd-HHMMSS.h5') + "-o", + "--output-uri", + default=time.strftime("%Y%m%d-%H%M%S") + ".h5", + help="Output file name (HDF5). An URI can be provided to write" + " the data into a specific group in the output file: " + "/path/to/file::/path/to/group. " + "If not provided, the filename defaults to a timestamp:" + " YYYYmmdd-HHMMSS.h5", + ) parser.add_argument( - '-m', '--mode', + "-m", + "--mode", default="w-", help='Write mode: "r+" (read/write, file must exist), ' - '"w" (write, existing file is lost), ' - '"w-" (write, fail if file exists) or ' - '"a" (read/write if exists, create otherwise)') + '"w" (write, existing file is lost), ' + '"w-" (write, fail if file exists) or ' + '"a" (read/write if exists, create otherwise)', + ) parser.add_argument( - '--begin', - help='First file index, or first file indices to be considered. ' - 'This argument only makes sense when used together with ' - '--file-pattern. Provide as many start indices as there ' - 'are indices in the file pattern, separated by commas. ' - 'Examples: "--filepattern toto_%%d.edf --begin 100", ' - ' "--filepattern toto_%%d_%%04d_%%02d.edf --begin 100,2000,5".') + "--begin", + help="First file index, or first file indices to be considered. " + "This argument only makes sense when used together with " + "--file-pattern. Provide as many start indices as there " + "are indices in the file pattern, separated by commas. " + 'Examples: "--filepattern toto_%%d.edf --begin 100", ' + ' "--filepattern toto_%%d_%%04d_%%02d.edf --begin 100,2000,5".', + ) parser.add_argument( - '--end', - help='Last file index, or last file indices to be considered. ' - 'The same rules as with argument --begin apply. ' - 'Example: "--filepattern toto_%%d_%%d.edf --end 199,1999"') + "--end", + help="Last file index, or last file indices to be considered. " + "The same rules as with argument --begin apply. " + 'Example: "--filepattern toto_%%d_%%d.edf --end 199,1999"', + ) parser.add_argument( - '--add-root-group', + "--add-root-group", action="store_true", - help='This option causes each input file to be written to a ' - 'specific root group with the same name as the file. When ' - 'merging multiple input files, this can help preventing conflicts' - ' when datasets have the same name (see --overwrite-data). ' - 'This option is ignored when using --file-pattern.') + help="This option causes each input file to be written to a " + "specific root group with the same name as the file. When " + "merging multiple input files, this can help preventing conflicts" + " when datasets have the same name (see --overwrite-data). " + "This option is ignored when using --file-pattern.", + ) parser.add_argument( - '--overwrite-data', + "--overwrite-data", action="store_true", - help='If the output path exists and an input dataset has the same' - ' name as an existing output dataset, overwrite the output ' - 'dataset (in modes "r+" or "a").') + help="If the output path exists and an input dataset has the same" + " name as an existing output dataset, overwrite the output " + 'dataset (in modes "r+" or "a").', + ) parser.add_argument( - '--min-size', + "--min-size", type=int, default=500, - help='Minimum number of elements required to be in a dataset to ' - 'apply compression or chunking (default 500).') + help="Minimum number of elements required to be in a dataset to " + "apply compression or chunking (default 500).", + ) parser.add_argument( - '--chunks', + "--chunks", nargs="?", const="auto", - help='Chunk shape. Provide an argument that evaluates as a python ' - 'tuple (e.g. "(1024, 768)"). If this option is provided without ' - 'specifying an argument, the h5py library will guess a chunk for ' - 'you. Note that if you specify an explicit chunking shape, it ' - 'will be applied identically to all datasets with a large enough ' - 'size (see --min-size). ') + help="Chunk shape. Provide an argument that evaluates as a python " + 'tuple (e.g. "(1024, 768)"). If this option is provided without ' + "specifying an argument, the h5py library will guess a chunk for " + "you. Note that if you specify an explicit chunking shape, it " + "will be applied identically to all datasets with a large enough " + "size (see --min-size). ", + ) parser.add_argument( - '--compression', + "--compression", nargs="?", const="gzip", - help='Compression filter. By default, the datasets in the output ' - 'file are not compressed. If this option is specified without ' - 'argument, the GZIP compression is used. Additional compression ' - 'filters may be available, depending on your HDF5 installation.') + help="Compression filter. By default, the datasets in the output " + "file are not compressed. If this option is specified without " + "argument, the GZIP compression is used. Additional compression " + "filters may be available, depending on your HDF5 installation.", + ) def check_gzip_compression_opts(value): ivalue = int(value) if ivalue < 0 or ivalue > 9: raise argparse.ArgumentTypeError( - "--compression-opts must be an int from 0 to 9") + "--compression-opts must be an int from 0 to 9" + ) return ivalue parser.add_argument( - '--compression-opts', + "--compression-opts", type=check_gzip_compression_opts, help='Compression options. For "gzip", this may be an integer from ' - '0 to 9, with a default of 4. This is only supported for GZIP.') + "0 to 9, with a default of 4. This is only supported for GZIP.", + ) parser.add_argument( - '--shuffle', + "--shuffle", action="store_true", - help='Enables the byte shuffle filter. This may improve the compression ' - 'ratio for block oriented compressors like GZIP or LZF.') + help="Enables the byte shuffle filter. This may improve the compression " + "ratio for block oriented compressors like GZIP or LZF.", + ) parser.add_argument( - '--fletcher32', + "--fletcher32", action="store_true", - help='Adds a checksum to each chunk to detect data corruption.') + help="Adds a checksum to each chunk to detect data corruption.", + ) parser.add_argument( - '--debug', + "--debug", action="store_true", default=False, - help='Set logging system in debug mode') + help="Set logging system in debug mode", + ) options = parser.parse_args(argv[1:]) @@ -329,8 +353,10 @@ def main(argv): write_to_h5 = None if hdf5plugin is None: - message = "Module 'hdf5plugin' is not installed. It supports additional hdf5"\ - + " compressions. You can install it using \"pip install hdf5plugin\"." + message = ( + "Module 'hdf5plugin' is not installed. It supports additional hdf5" + + ' compressions. You can install it using "pip install hdf5plugin".' + ) _logger.debug(message) # Process input arguments (mutually exclusive arguments) @@ -360,33 +386,40 @@ def main(argv): dirname = os.path.dirname(options.file_pattern) file_pattern_re = c_format_string_to_re(options.file_pattern) + "$" files_in_dir = glob(os.path.join(dirname, "*")) - _logger.debug(""" + _logger.debug( + """ Processing file_pattern dirname: %s file_pattern_re: %s files_in_dir: %s - """, dirname, file_pattern_re, files_in_dir) - - options.input_files = sorted(list(filter(lambda name: re.match(file_pattern_re, name), - files_in_dir))) + """, + dirname, + file_pattern_re, + files_in_dir, + ) + + options.input_files = sorted( + list(filter(lambda name: re.match(file_pattern_re, name), files_in_dir)) + ) _logger.debug("options.input_files: %s", options.input_files) if options.begin is not None: - options.input_files = drop_indices_before_begin(options.input_files, - file_pattern_re, - options.begin) - _logger.debug("options.input_files after applying --begin: %s", - options.input_files) + options.input_files = drop_indices_before_begin( + options.input_files, file_pattern_re, options.begin + ) + _logger.debug( + "options.input_files after applying --begin: %s", options.input_files + ) if options.end is not None: - options.input_files = drop_indices_after_end(options.input_files, - file_pattern_re, - options.end) - _logger.debug("options.input_files after applying --end: %s", - options.input_files) - - if are_files_missing_in_series(options.input_files, - file_pattern_re): + options.input_files = drop_indices_after_end( + options.input_files, file_pattern_re, options.end + ) + _logger.debug( + "options.input_files after applying --end: %s", options.input_files + ) + + if are_files_missing_in_series(options.input_files, file_pattern_re): _logger.error("File missing in the file series. Aborting.") return -1 @@ -402,37 +435,39 @@ def main(argv): if os.path.isfile(output_name): if options.mode == "w-": - _logger.error("Output file %s exists and mode is 'w-' (default)." - " Aborting. To append data to an existing file, " - "use 'a' or 'r+'.", - output_name) + _logger.error( + "Output file %s exists and mode is 'w-' (default)." + " Aborting. To append data to an existing file, " + "use 'a' or 'r+'.", + output_name, + ) return -1 elif not os.access(output_name, os.W_OK): - _logger.error("Output file %s exists and is not writeable.", - output_name) + _logger.error("Output file %s exists and is not writeable.", output_name) return -1 elif options.mode == "w": - _logger.info("Output file %s exists and mode is 'w'. " - "Overwriting existing file.", output_name) + _logger.info( + "Output file %s exists and mode is 'w'. " "Overwriting existing file.", + output_name, + ) elif options.mode in ["a", "r+"]: - _logger.info("Appending data to existing file %s.", - output_name) + _logger.info("Appending data to existing file %s.", output_name) else: if options.mode == "r+": - _logger.error("Output file %s does not exist and mode is 'r+'" - " (append, file must exist). Aborting.", - output_name) + _logger.error( + "Output file %s does not exist and mode is 'r+'" + " (append, file must exist). Aborting.", + output_name, + ) return -1 else: - _logger.info("Creating new output file %s.", - output_name) + _logger.info("Creating new output file %s.", output_name) # Test that all input files exist and are readable bad_input = False for fname in options.input_files: if not os.access(fname, os.R_OK): - _logger.error("Cannot read input file %s.", - fname) + _logger.error("Cannot read input file %s.", fname) bad_input = True if bad_input: _logger.error("Aborting.") @@ -456,10 +491,12 @@ def main(argv): nitems = numpy.prod(chunks) nbytes = nitems * 8 if nbytes > 10**6: - _logger.warning("Requested chunk size might be larger than" - " the default 1MB chunk cache, for float64" - " data. This can dramatically affect I/O " - "performances.") + _logger.warning( + "Requested chunk size might be larger than" + " the default 1MB chunk cache, for float64" + " data. This can dramatically affect I/O " + "performances." + ) create_dataset_args["chunks"] = chunks if options.compression is not None: @@ -478,61 +515,78 @@ def main(argv): if options.fletcher32: create_dataset_args["fletcher32"] = True - if (len(options.input_files) > 1 and - not contains_specfile(options.input_files) and - not contains_fiofile(options.input_files) and - not options.add_root_group) or options.file_pattern is not None: + if ( + len(options.input_files) > 1 + and not contains_specfile(options.input_files) + and not contains_fiofile(options.input_files) + and not options.add_root_group + ) or options.file_pattern is not None: # File series -> stack of images input_group = fabioh5.File(file_series=options.input_files) if hdf5_path != "/": # we want to append only data and headers to an existing file input_group = input_group["/scan_0/instrument/detector_0"] with h5py.File(output_name, mode=options.mode) as h5f: - write_to_h5(input_group, h5f, - h5path=hdf5_path, - overwrite_data=options.overwrite_data, - create_dataset_args=create_dataset_args, - min_size=options.min_size) - - elif len(options.input_files) == 1 or \ - are_all_specfile(options.input_files) or\ - are_all_fiofile(options.input_files) or\ - options.add_root_group: + write_to_h5( + input_group, + h5f, + h5path=hdf5_path, + overwrite_data=options.overwrite_data, + create_dataset_args=create_dataset_args, + min_size=options.min_size, + ) + + elif ( + len(options.input_files) == 1 + or are_all_specfile(options.input_files) + or are_all_fiofile(options.input_files) + or options.add_root_group + ): # single file, or spec files h5paths_and_groups = [] for input_name in options.input_files: hdf5_path_for_file = hdf5_path if options.add_root_group: - hdf5_path_for_file = hdf5_path.rstrip("/") + "/" + os.path.basename(input_name) + hdf5_path_for_file = ( + hdf5_path.rstrip("/") + "/" + os.path.basename(input_name) + ) try: - h5paths_and_groups.append((hdf5_path_for_file, - silx.io.open(input_name))) + h5paths_and_groups.append( + (hdf5_path_for_file, silx.io.open(input_name)) + ) except IOError: - _logger.error("Cannot read file %s. If this is a file format " - "supported by the fabio library, you can try to" - " install fabio (`pip install fabio`)." - " Aborting conversion.", - input_name) + _logger.error( + "Cannot read file %s. If this is a file format " + "supported by the fabio library, you can try to" + " install fabio (`pip install fabio`)." + " Aborting conversion.", + input_name, + ) return -1 with h5py.File(output_name, mode=options.mode) as h5f: for hdf5_path_for_file, input_group in h5paths_and_groups: - write_to_h5(input_group, h5f, - h5path=hdf5_path_for_file, - overwrite_data=options.overwrite_data, - create_dataset_args=create_dataset_args, - min_size=options.min_size) + write_to_h5( + input_group, + h5f, + h5path=hdf5_path_for_file, + overwrite_data=options.overwrite_data, + create_dataset_args=create_dataset_args, + min_size=options.min_size, + ) else: # multiple file, SPEC and fabio images mixed - _logger.error("Multiple files with incompatible formats specified. " - "You can provide multiple SPEC files or multiple image " - "files, but not both.") + _logger.error( + "Multiple files with incompatible formats specified. " + "You can provide multiple SPEC files or multiple image " + "files, but not both." + ) return -1 with h5py.File(output_name, mode="r+") as h5f: # append "silx convert" to the creator attribute, for NeXus files - previous_creator = h5f.attrs.get("creator", u"") + previous_creator = h5f.attrs.get("creator", "") creator = "silx convert (v%s)" % silx.version # only if it not already there if creator not in previous_creator: @@ -541,7 +595,7 @@ def main(argv): else: new_creator = previous_creator + "; " + creator h5f.attrs["creator"] = numpy.array( - new_creator, - dtype=h5py.special_dtype(vlen=str)) + new_creator, dtype=h5py.special_dtype(vlen=str) + ) return 0 diff --git a/src/silx/app/test/test_convert.py b/src/silx/app/test/test_convert.py index f3ca269..7ff94a3 100644 --- a/src/silx/app/test/test_convert.py +++ b/src/silx/app/test/test_convert.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2016-2021 European Synchrotron Radiation Facility +# Copyright (c) 2016-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -29,7 +29,6 @@ __date__ = "17/01/2018" import os -import sys import tempfile import unittest import io @@ -120,16 +119,12 @@ class TestConvertCommand(unittest.TestCase): # write a temporary SPEC file specname = os.path.join(tempdir, "input.dat") with io.open(specname, "wb") as fd: - if sys.version_info < (3, ): - fd.write(sftext) - else: - fd.write(bytes(sftext, 'ascii')) + fd.write(bytes(sftext, "ascii")) # convert it h5name = os.path.join(tempdir, "output.h5") assert not os.path.isfile(h5name) - command_list = ["convert", "-m", "w", - specname, "-o", h5name] + command_list = ["convert", "-m", "w", specname, "-o", h5name] result = convert.main(command_list) self.assertEqual(result, 0) @@ -137,15 +132,10 @@ class TestConvertCommand(unittest.TestCase): with h5py.File(h5name, "r") as h5f: title12 = h5py_read_dataset(h5f["/1.2/title"]) - if sys.version_info < (3, ): - title12 = title12.encode("utf-8") - self.assertEqual(title12, - "aaaaaa") + self.assertEqual(title12, "aaaaaa") creator = h5f.attrs.get("creator") self.assertIsNotNone(creator, "No creator attribute in NXroot group") - if sys.version_info < (3, ): - creator = creator.encode("utf-8") self.assertIn("silx convert (v%s)" % silx.version, creator) # delete input file diff --git a/src/silx/app/utils/__init__.py b/src/silx/app/utils/__init__.py new file mode 100644 index 0000000..97ef4a5 --- /dev/null +++ b/src/silx/app/utils/__init__.py @@ -0,0 +1,27 @@ +# /*########################################################################## +# Copyright (C) 2016-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Package containing utils related to applications""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2023" diff --git a/src/silx/app/utils/parseutils.py b/src/silx/app/utils/parseutils.py new file mode 100644 index 0000000..4135290 --- /dev/null +++ b/src/silx/app/utils/parseutils.py @@ -0,0 +1,133 @@ +# /*########################################################################## +# Copyright (C) 2018-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ +"""Utils related to parsing""" + +from __future__ import annotations + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "28/05/2018" + +from collections.abc import Sequence +import glob +import logging +from typing import Generator, Iterable, Union, Any, Optional +from pathlib import Path + + +_logger = logging.getLogger(__name__) +"""Module logger""" + + +_trueStrings = {"yes", "true", "1"} +_falseStrings = {"no", "false", "0"} + + +def _string_to_bool(string: str) -> bool: + """Returns a boolean from a string. + + :raise ValueError: If the string do not contains a boolean information. + """ + lower = string.lower() + if lower in _trueStrings: + return True + if lower in _falseStrings: + return False + raise ValueError("'%s' is not a valid boolean" % string) + + +def to_bool(thing: Any, default: Optional[bool] = None) -> bool: + """Returns a boolean from an object. + + :raise ValueError: If the thing can't be interpreted as a boolean and + no default is set + """ + if isinstance(thing, bool): + return thing + try: + return _string_to_bool(thing) + except ValueError: + if default is not None: + return default + raise + + +def filenames_to_dataurls( + filenames: Iterable[Union[str, Path]], + slices: Sequence[int] = tuple(), +) -> Generator[object, None, None]: + """Expand filenames and HDF5 data path in files input argument""" + # Imports here so they are performed after setting HDF5_USE_FILE_LOCKING and logging level + import silx.io + from silx.io.utils import match + from silx.io.url import DataUrl + import silx.utils.files + + extra_slices = tuple(slices) + + for filename in filenames: + url = DataUrl(filename) + + for file_path in sorted(silx.utils.files.expand_filenames([url.file_path()])): + if url.data_path() is not None and glob.has_magic(url.data_path()): + try: + with silx.io.open(file_path) as f: + data_paths = list(match(f, url.data_path())) + except BaseException as e: + _logger.error( + f"Error searching HDF5 path pattern '{url.data_path()}' in file '{file_path}': Ignored" + ) + _logger.error(e.args[0]) + _logger.debug("Backtrace", exc_info=True) + continue + else: + data_paths = [url.data_path()] + + if not extra_slices: + data_slices = (url.data_slice(),) + elif not url.data_slice(): + data_slices = extra_slices + else: + data_slices = [tuple(url.data_slice()) + (s,) for s in extra_slices] + + for data_path in data_paths: + for data_slice in data_slices: + yield DataUrl( + file_path=file_path, + data_path=data_path, + data_slice=data_slice, + scheme=url.scheme(), + ) + + +def to_enum(thing: Any, enum_type, default: Optional[object] = None): + """Parse this string as this enum_type.""" + try: + v = getattr(enum_type, str(thing)) + if isinstance(v, enum_type): + return v + raise ValueError(f"{thing} is not a {enum_type.__name__}") + except (AttributeError, ValueError) as e: + if default is not None: + return default + raise diff --git a/src/silx/app/utils/test/__init__.py b/src/silx/app/utils/test/__init__.py new file mode 100644 index 0000000..f94d0a3 --- /dev/null +++ b/src/silx/app/utils/test/__init__.py @@ -0,0 +1,23 @@ +# /*########################################################################## +# +# Copyright (c) 2016-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ diff --git a/src/silx/app/utils/test/test_parseutils.py b/src/silx/app/utils/test/test_parseutils.py new file mode 100644 index 0000000..9570bb7 --- /dev/null +++ b/src/silx/app/utils/test/test_parseutils.py @@ -0,0 +1,68 @@ +# /*########################################################################## +# Copyright (C) 2018-2023 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ############################################################################*/ + +import pytest +import h5py +from ..parseutils import filenames_to_dataurls + + +@pytest.fixture(scope="module") +def data_path(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("silx_app_utils") + with h5py.File(tmp_path / "test1.h5", "w") as h5: + h5["g1/sub1/data1"] = 1 + h5["g1/sub1/data2"] = 2 + h5["g1/sub2/data1"] = 3 + return tmp_path + + +def test_h5__datapath(data_path): + urls = filenames_to_dataurls([data_path / "test1.h5::g1/sub1/data1"]) + urls = list(urls) + assert len(urls) == 1 + assert urls[0].data_path().replace("\\", "/") == "g1/sub1/data1" + + +def test_h5__datapath_not_existing(data_path): + urls = filenames_to_dataurls([data_path / "test1.h5::g1/sub0/data1"]) + urls = list(urls) + assert len(urls) == 1 + assert urls[0].data_path().replace("\\", "/") == "g1/sub0/data1" + + +def test_h5__datapath_with_magic(data_path): + urls = filenames_to_dataurls([data_path / "test1.h5::g1/sub*/data*"]) + urls = list(urls) + assert len(urls) == 3 + + +def test_h5__datapath_with_magic_not_existing(data_path): + urls = filenames_to_dataurls([data_path / "test1.h5::g1/sub0/data*"]) + urls = list(urls) + assert len(urls) == 0 + + +def test_h5__datapath_with_recursive_magic(data_path): + urls = filenames_to_dataurls([data_path / "test1.h5::**/data1"]) + urls = list(urls) + assert len(urls) == 2 diff --git a/src/silx/app/view/About.py b/src/silx/app/view/About.py index 2af7ed4..76e0cf2 100644 --- a/src/silx/app/view/About.py +++ b/src/silx/app/view/About.py @@ -115,10 +115,9 @@ class About(qt.QDialog): :rtype: str """ from silx._version import __date__ as date + year = date.split("/")[2] - info = dict( - year=year - ) + info = dict(year=year) textLicense = _LICENSE_TEMPLATE.format(**info) return textLicense @@ -191,6 +190,7 @@ class About(qt.QDialog): # Previous versions only return True if the filter was first used # to decode a dataset import h5py.h5z + FILTER_LZ4 = 32004 FILTER_BITSHUFFLE = 32008 filters = [ @@ -201,7 +201,11 @@ class About(qt.QDialog): isAvailable = h5py.h5z.filter_avail(filterId) optionals.append(self.__formatOptionalFilters(name, isAvailable)) else: - optionals.append(self.__formatOptionalLibraries("hdf5plugin", "hdf5plugin" in sys.modules)) + optionals.append( + self.__formatOptionalLibraries( + "hdf5plugin", "hdf5plugin" in sys.modules + ) + ) # Access to the logo in SVG or PNG logo = icons.getQFile("silx:" + os.path.join("gui", "logo", "silx")) @@ -217,7 +221,7 @@ class About(qt.QDialog): qt_version=qt.qVersion(), python_version=sys.version.replace("\n", "<br />"), optional_lib="<br />".join(optionals), - silx_image_path=logo.fileName() + silx_image_path=logo.fileName(), ) self.__label.setText(message.format(**info)) @@ -225,10 +229,14 @@ class About(qt.QDialog): def __updateSize(self): """Force the size to a QMessageBox like size.""" - if qt.BINDING in ("PySide2", "PyQt5"): - screenSize = qt.QApplication.desktop().availableGeometry(qt.QCursor.pos()).size() + if qt.BINDING == "PyQt5": + screenSize = ( + qt.QApplication.desktop().availableGeometry(qt.QCursor.pos()).size() + ) else: # Qt6 - screenSize = qt.QApplication.instance().primaryScreen().availableGeometry().size() + screenSize = ( + qt.QApplication.instance().primaryScreen().availableGeometry().size() + ) hardLimit = min(screenSize.width() - 480, 1000) if screenSize.width() <= 1024: hardLimit = screenSize.width() diff --git a/src/silx/app/view/ApplicationContext.py b/src/silx/app/view/ApplicationContext.py index 30dad7d..157b8cc 100644 --- a/src/silx/app/view/ApplicationContext.py +++ b/src/silx/app/view/ApplicationContext.py @@ -1,5 +1,5 @@ # /*########################################################################## -# Copyright (C) 2016-2018 European Synchrotron Radiation Facility +# Copyright (C) 2016-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -28,6 +28,7 @@ __date__ = "23/05/2018" import weakref import logging +from collections.abc import Sequence import silx from silx.gui.data.DataViews import DataViewHooks @@ -69,15 +70,20 @@ class ApplicationContext(DataViewHooks): if settings is None: return settings.beginGroup("library") + mplTightLayout = settings.value("mpl.tight_layout", False, bool) plotBackend = settings.value("plot.backend", "") plotImageYAxisOrientation = settings.value("plot-image.y-axis-orientation", "") settings.endGroup() # Use matplotlib backend by default - silx.config.DEFAULT_PLOT_BACKEND = \ - "opengl" if plotBackend == "opengl" else "matplotlib" + silx.config.DEFAULT_PLOT_BACKEND = ( + ("opengl", "matplotlib") if plotBackend == "opengl" else "matplotlib" + ) if plotImageYAxisOrientation != "": - silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = plotImageYAxisOrientation + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = ( + plotImageYAxisOrientation + ) + silx.config._MPL_TIGHT_LAYOUT = mplTightLayout def restoreSettings(self): """Restore the settings of all the application""" @@ -121,8 +127,12 @@ class ApplicationContext(DataViewHooks): settings.endGroup() settings.beginGroup("library") - settings.setValue("plot.backend", silx.config.DEFAULT_PLOT_BACKEND) - settings.setValue("plot-image.y-axis-orientation", silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION) + settings.setValue("plot.backend", self.getDefaultPlotBackend()) + settings.setValue( + "plot-image.y-axis-orientation", + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION, + ) + settings.setValue("mpl.tight_layout", silx.config._MPL_TIGHT_LAYOUT) settings.endGroup() settings.beginGroup("recent-files") @@ -162,8 +172,7 @@ class ApplicationContext(DataViewHooks): self.__recentFiles.pop() def clearRencentFiles(self): - """Clear the history of the rencent files. - """ + """Clear the history of the rencent files.""" self.__recentFiles[:] = [] def getColormap(self, view): @@ -192,3 +201,17 @@ class ApplicationContext(DataViewHooks): dialog.setModal(False) self.__defaultColormapDialog = dialog return self.__defaultColormapDialog + + @staticmethod + def getDefaultPlotBackend() -> str: + """Returns default plot backend as a str from current config""" + backend = silx.config.DEFAULT_PLOT_BACKEND + if isinstance(backend, str): + return backend + if ( + isinstance(backend, Sequence) + and len(backend) + and isinstance(backend[0], str) + ): + return backend[0] + return "matplotlib" # fallback diff --git a/src/silx/app/view/CustomNxdataWidget.py b/src/silx/app/view/CustomNxdataWidget.py index 3c79c0d..3ec62c0 100644 --- a/src/silx/app/view/CustomNxdataWidget.py +++ b/src/silx/app/view/CustomNxdataWidget.py @@ -568,7 +568,7 @@ class _Model(qt.QStandardItemModel): """ if isinstance(item, _NxDataItem): parent = item.parent() - assert(parent is None) + assert parent is None model = item.model() model.removeRow(item.row()) else: @@ -693,7 +693,7 @@ class CustomNxDataToolBar(qt.QToolBar): def setCustomNxDataWidget(self, widget): """Set the linked CustomNxdataWidget to this toolbar.""" - assert(isinstance(widget, CustomNxdataWidget)) + assert isinstance(widget, CustomNxdataWidget) if self.__nxdataWidget is not None: selectionModel = self.__nxdataWidget.selectionModel() selectionModel.currentChanged.disconnect(self.__currentSelectionChanged) @@ -713,7 +713,9 @@ class CustomNxDataToolBar(qt.QToolBar): item = model.itemFromIndex(index) self.__removeNxDataAction.setEnabled(isinstance(item, _NxDataItem)) self.__removeNxDataAxisAction.setEnabled(isinstance(item, _DatasetAxisItemRow)) - self.__addNxDataAxisAction.setEnabled(isinstance(item, _NxDataItem) or isinstance(item, _DatasetItemRow)) + self.__addNxDataAxisAction.setEnabled( + isinstance(item, _NxDataItem) or isinstance(item, _DatasetItemRow) + ) class _HashDropZones(qt.QStyledItemDelegate): @@ -847,7 +849,9 @@ class CustomNxdataWidget(qt.QTreeView): if isinstance(item, _NxDataItem): action = qt.QAction("Add a new axis", menu) - action.triggered.connect(lambda: weakself.model().appendAxisToNxdataItem(item)) + action.triggered.connect( + lambda: weakself.model().appendAxisToNxdataItem(item) + ) action.setIcon(icons.getQIcon("nxdata-axis-add")) action.setIconVisibleInMenu(True) menu.addAction(action) diff --git a/src/silx/app/view/DataPanel.py b/src/silx/app/view/DataPanel.py index d4a0e63..592a520 100644 --- a/src/silx/app/view/DataPanel.py +++ b/src/silx/app/view/DataPanel.py @@ -37,7 +37,6 @@ _logger = logging.getLogger(__name__) class _HeaderLabel(qt.QLabel): - def __init__(self, parent=None): qt.QLabel.__init__(self, parent=parent) self.setFrameShape(qt.QFrame.StyledPanel) @@ -89,7 +88,6 @@ class _HeaderLabel(qt.QLabel): class DataPanel(qt.QWidget): - def __init__(self, parent=None, context=None): qt.QWidget.__init__(self, parent=parent) diff --git a/src/silx/app/view/Viewer.py b/src/silx/app/view/Viewer.py index d9ecf6a..12426a1 100644 --- a/src/silx/app/view/Viewer.py +++ b/src/silx/app/view/Viewer.py @@ -1,5 +1,5 @@ # /*########################################################################## -# Copyright (C) 2016-2022 European Synchrotron Radiation Facility +# Copyright (C) 2016-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -22,15 +22,18 @@ # ############################################################################*/ """Browse a data file with a GUI""" +from __future__ import annotations + __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "15/01/2019" import os -import collections import logging import functools +import traceback +from types import TracebackType from typing import Optional import silx.io.nxdata @@ -40,7 +43,7 @@ import silx.gui.hdf5 from .ApplicationContext import ApplicationContext from .CustomNxdataWidget import CustomNxdataWidget from .CustomNxdataWidget import CustomNxDataToolBar -from . import utils +from ..utils import parseutils from silx.gui.utils import projecturl from .DataPanel import DataPanel @@ -65,6 +68,8 @@ class Viewer(qt.QMainWindow): silxIcon = icons.getQIcon("silx") self.setWindowIcon(silxIcon) + self.__error = "" + self.__context = self.createApplicationContext(settings) self.__context.restoreLibrarySettings() @@ -87,7 +92,9 @@ class Viewer(qt.QMainWindow): treeModel.sigH5pyObjectRemoved.connect(self.__h5FileRemoved) treeModel.sigH5pyObjectSynchronized.connect(self.__h5FileSynchonized) treeModel.setDatasetDragEnabled(True) - self.__treeModelSorted = silx.gui.hdf5.NexusSortFilterProxyModel(self.__treeview) + self.__treeModelSorted = silx.gui.hdf5.NexusSortFilterProxyModel( + self.__treeview + ) self.__treeModelSorted.setSourceModel(treeModel) self.__treeModelSorted.sort(0, qt.Qt.AscendingOrder) self.__treeModelSorted.setSortCaseSensitivity(qt.Qt.CaseInsensitive) @@ -142,8 +149,8 @@ class Viewer(qt.QMainWindow): columns.insert(1, treeModel.DESCRIPTION_COLUMN) self.__treeview.header().setSections(columns) - self._iconUpward = icons.getQIcon('plot-yup') - self._iconDownward = icons.getQIcon('plot-ydown') + self._iconUpward = icons.getQIcon("plot-yup") + self._iconDownward = icons.getQIcon("plot-ydown") self.createActions() self.createMenus() @@ -162,23 +169,22 @@ class Viewer(qt.QMainWindow): action.setText("Refresh") action.setToolTip("Refresh all selected items") action.triggered.connect(self.__refreshSelected) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_F5)) + action.setShortcuts( + [ + qt.QKeySequence(qt.Qt.Key_F5), + qt.QKeySequence(qt.Qt.CTRL | qt.Qt.Key_R), + ] + ) toolbar.addAction(action) treeView.addAction(action) self.__refreshAction = action - # Another shortcut for refresh - action = qt.QAction(toolbar) - action.setShortcut(qt.QKeySequence(qt.Qt.CTRL | qt.Qt.Key_R)) - treeView.addAction(action) - action.triggered.connect(self.__refreshSelected) - action = qt.QAction(toolbar) # action.setIcon(icons.getQIcon("view-refresh")) action.setText("Close") action.setToolTip("Close selected item") action.triggered.connect(self.__removeSelected) - action.setShortcut(qt.QKeySequence(qt.Qt.Key_Delete)) + action.setShortcut(qt.QKeySequence.Delete) treeView.addAction(action) self.__closeAction = action @@ -254,8 +260,7 @@ class Viewer(qt.QMainWindow): qt.QApplication.restoreOverrideCursor() def __refreshSelected(self): - """Refresh all selected items - """ + """Refresh all selected items""" qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor) selection = self.__treeview.selectionModel() @@ -274,8 +279,12 @@ class Viewer(qt.QMainWindow): rootRow = rootIndex.row() relativePath = self.__getRelativePath(model, rootIndex, index) selectedItems.append((rootRow, relativePath)) - h5 = model.data(rootIndex, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) - item = model.data(rootIndex, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE) + h5 = model.data( + rootIndex, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE + ) + item = model.data( + rootIndex, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE + ) h5files.append((h5, item._openedPath)) if len(h5files) == 0: @@ -350,7 +359,7 @@ class Viewer(qt.QMainWindow): path = node._getCanonicalName() if rootPath is None: rootPath = path - path = path[len(rootPath):] + path = path[len(rootPath) :] paths.append(path) for child in range(model.rowCount(index)): @@ -455,9 +464,9 @@ class Viewer(qt.QMainWindow): layout.addWidget(customNxdataWidget) return widget - def __h5FileLoaded(self, loadedH5): + def __h5FileLoaded(self, loadedH5, filename): self.__context.pushRecentFile(loadedH5.file.filename) - if loadedH5.file.filename == self.__displayIt: + if filename == self.__displayIt: self.__displayIt = None self.displayData(loadedH5) @@ -521,11 +530,7 @@ class Viewer(qt.QMainWindow): size = settings.value("size", qt.QSize(640, 480)) pos = settings.value("pos", qt.QPoint()) isFullScreen = settings.value("full-screen", False) - try: - if not isinstance(isFullScreen, bool): - isFullScreen = utils.stringToBool(isFullScreen) - except ValueError: - isFullScreen = False + isFullScreen = parseutils.to_bool(isFullScreen, False) settings.endGroup() settings.beginGroup("mainlayout") @@ -542,23 +547,14 @@ class Viewer(qt.QMainWindow): except Exception: _logger.debug("Backtrace", exc_info=True) isVisible = settings.value("custom-nxdata-window-visible", False) - try: - if not isinstance(isVisible, bool): - isVisible = utils.stringToBool(isVisible) - except ValueError: - isVisible = False + isVisible = parseutils.to_bool(isVisible, False) self.__customNxdataWindow.setVisible(isVisible) self._displayCustomNxdataWindow.setChecked(isVisible) - settings.endGroup() settings.beginGroup("content") isSorted = settings.value("is-sorted", True) - try: - if not isinstance(isSorted, bool): - isSorted = utils.stringToBool(isSorted) - except ValueError: - isSorted = True + isSorted = parseutils.to_bool(isSorted, True) self.setContentSorted(isSorted) settings.endGroup() @@ -571,12 +567,13 @@ class Viewer(qt.QMainWindow): def createActions(self): action = qt.QAction("E&xit", self) - action.setShortcuts(qt.QKeySequence.Quit) + action.setShortcut(qt.QKeySequence.Quit) action.setStatusTip("Exit the application") action.triggered.connect(self.close) self._exitAction = action action = qt.QAction("&Open...", self) + action.setShortcut(qt.QKeySequence.Open) action.setStatusTip("Open a file") action.triggered.connect(self.open) self._openAction = action @@ -586,6 +583,7 @@ class Viewer(qt.QMainWindow): self._openRecentMenu = menu action = qt.QAction("Close All", self) + action.setShortcut(qt.QKeySequence.Close) action.setStatusTip("Close all opened files") action.triggered.connect(self.closeAll) self._closeAllAction = action @@ -627,9 +625,11 @@ class Viewer(qt.QMainWindow): # Plot image orientation self._plotImageOrientationMenu = qt.QMenu( - "Default plot image y-axis orientation", self) + "Default plot image y-axis orientation", self + ) self._plotImageOrientationMenu.setStatusTip( - "Select the default y-axis orientation used by plot displaying images") + "Select the default y-axis orientation used by plot displaying images" + ) group = qt.QActionGroup(self) group.setExclusive(True) @@ -652,10 +652,19 @@ class Viewer(qt.QMainWindow): self._plotImageOrientationMenu.addAction(action) self._useYAxisOrientationUpward = action + # mpl layout + + action = qt.QAction("Use MPL tight layout", self) + action.setCheckable(True) + action.triggered.connect(self.__forceMplTightLayout) + self._useMplTightLayout = action + # Windows action = qt.QAction("Show custom NXdata selector", self) - action.setStatusTip("Show a widget which allow to create plot by selecting data and axes") + action.setStatusTip( + "Show a widget which allow to create plot by selecting data and axes" + ) action.setCheckable(True) action.setShortcut(qt.QKeySequence(qt.Qt.Key_F6)) action.toggled.connect(self.__toggleCustomNxdataWindow) @@ -674,7 +683,9 @@ class Viewer(qt.QMainWindow): baseName = os.path.basename(filePath) action = qt.QAction(baseName, self) action.setToolTip(filePath) - action.triggered.connect(functools.partial(self.__openRecentFile, filePath)) + action.triggered.connect( + functools.partial(self.__openRecentFile, filePath) + ) self._openRecentMenu.addAction(action) self._openRecentMenu.addSeparator() baseName = os.path.basename(filePath) @@ -696,17 +707,18 @@ class Viewer(qt.QMainWindow): # plot backend title = self._plotBackendMenu.title().split(": ", 1)[0] - self._plotBackendMenu.setTitle("%s: %s" % (title, silx.config.DEFAULT_PLOT_BACKEND)) + backend = self.__context.getDefaultPlotBackend() + self._plotBackendMenu.setTitle(f"{title}: {backend}") action = self._usePlotWithMatplotlib - action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["matplotlib", "mpl"]) + action.setChecked(backend in ["matplotlib", "mpl"]) title = action.text().split(" (", 1)[0] if not action.isChecked(): title += " (applied after application restart)" action.setText(title) action = self._usePlotWithOpengl - action.setChecked(silx.config.DEFAULT_PLOT_BACKEND in ["opengl", "gl"]) + action.setChecked(backend in ["opengl", "gl"]) title = action.text().split(" (", 1)[0] if not action.isChecked(): title += " (applied after application restart)" @@ -721,19 +733,28 @@ class Viewer(qt.QMainWindow): menu.setIcon(self._iconUpward) action = self._useYAxisOrientationDownward - action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward") + action.setChecked( + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward" + ) title = action.text().split(" (", 1)[0] if not action.isChecked(): title += " (applied after application restart)" action.setText(title) action = self._useYAxisOrientationUpward - action.setChecked(silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION != "downward") + action.setChecked( + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION != "downward" + ) title = action.text().split(" (", 1)[0] if not action.isChecked(): title += " (applied after application restart)" action.setText(title) + # mpl + + action = self._useMplTightLayout + action.setChecked(silx.config._MPL_TIGHT_LAYOUT) + def createMenus(self): fileMenu = self.menuBar().addMenu("&File") fileMenu.addAction(self._openAction) @@ -746,6 +767,7 @@ class Viewer(qt.QMainWindow): optionMenu = self.menuBar().addMenu("&Options") optionMenu.addMenu(self._plotImageOrientationMenu) optionMenu.addMenu(self._plotBackendMenu) + optionMenu.addAction(self._useMplTightLayout) optionMenu.aboutToShow.connect(self.__updateOptionMenu) viewMenu = self.menuBar().addMenu("&Views") @@ -755,6 +777,17 @@ class Viewer(qt.QMainWindow): helpMenu.addAction(self._aboutAction) helpMenu.addAction(self._documentationAction) + self.__errorButton = qt.QToolButton(self) + self.__errorButton.setIcon( + self.style().standardIcon(qt.QStyle.SP_MessageBoxWarning) + ) + self.__errorButton.setToolTip( + "An error occured!\nClick to display last error\nor check messages in the console" + ) + self.__errorButton.setVisible(False) + self.__errorButton.clicked.connect(self.__errorButtonClicked) + self.menuBar().setCornerWidget(self.__errorButton) + def open(self): dialog = self.createFileDialog() if self.__dialogState is None: @@ -784,7 +817,7 @@ class Viewer(qt.QMainWindow): dialog.setModal(True) # NOTE: hdf5plugin have to be loaded before - extensions = collections.OrderedDict() + extensions = {} for description, ext in silx.io.supported_extensions().items(): extensions[description] = " ".join(sorted(list(ext))) @@ -812,6 +845,7 @@ class Viewer(qt.QMainWindow): def about(self): from .About import About + About.about(self, "Silx viewer") def showDocumentation(self): @@ -826,7 +860,6 @@ class Viewer(qt.QMainWindow): """ sort = bool(sort) if sort != self.isContentSorted(): - # save expanded nodes pathss = [] root = qt.QModelIndex() @@ -837,7 +870,8 @@ class Viewer(qt.QMainWindow): pathss.append(paths) self.__treeview.setModel( - self.__treeModelSorted if sort else self.__treeModelSorted.sourceModel()) + self.__treeModelSorted if sort else self.__treeModelSorted.sourceModel() + ) self._sortContentAction.setChecked(self.isContentSorted()) # restore expanded nodes @@ -864,7 +898,10 @@ class Viewer(qt.QMainWindow): silx.config.DEFAULT_PLOT_BACKEND = "matplotlib" def __forceOpenglBackend(self): - silx.config.DEFAULT_PLOT_BACKEND = "opengl" + silx.config.DEFAULT_PLOT_BACKEND = "opengl", "matplotlib" + + def __forceMplTightLayout(self): + silx.config._MPL_TIGHT_LAYOUT = self._useMplTightLayout.isChecked() def appendFile(self, filename): if self.__displayIt is None: @@ -873,8 +910,7 @@ class Viewer(qt.QMainWindow): self.__treeview.findHdf5TreeModel().appendFile(filename) def displaySelectedData(self): - """Called to update the dataviewer with the selected data. - """ + """Called to update the dataviewer with the selected data.""" selected = list(self.__treeview.selectedH5Nodes(ignoreBrokenLinks=False)) if len(selected) == 1: # Update the viewer for a single selection @@ -884,8 +920,7 @@ class Viewer(qt.QMainWindow): _logger.debug("Too many data selected") def displayData(self, data): - """Called to update the dataviewer with a secific data. - """ + """Called to update the dataviewer with a secific data.""" self.__dataPanel.setData(data) def displaySelectedCustomData(self): @@ -957,8 +992,42 @@ class Viewer(qt.QMainWindow): if silx.io.is_file(h5): action = qt.QAction("Close %s" % obj.local_filename, event.source()) - action.triggered.connect(lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(h5)) + action.triggered.connect( + lambda: self.__treeview.findHdf5TreeModel().removeH5pyObject(h5) + ) menu.addAction(action) - action = qt.QAction("Synchronize %s" % obj.local_filename, event.source()) + action = qt.QAction( + "Synchronize %s" % obj.local_filename, event.source() + ) action.triggered.connect(lambda: self.__synchronizeH5pyObject(h5)) menu.addAction(action) + + def __errorButtonClicked(self): + button = qt.QMessageBox.warning( + self, + "Error", + self.getError(), + qt.QMessageBox.Reset | qt.QMessageBox.Close, + qt.QMessageBox.Close, + ) + if button == qt.QMessageBox.Reset: + self.setError("") + + def getError(self) -> str: + """Returns error information string""" + return self.__error + + def setError(self, error: str): + """Set error information string""" + if error == self.__error: + return + + self.__error = error + self.__errorButton.setVisible(error != "") + + def setErrorFromException( + self, type_: type[BaseException], value: BaseException, trace: TracebackType + ): + """Set information about the last exception that occured""" + formattedTrace = "\n".join(traceback.format_tb(trace)) + self.setError(f"{type_.__name__}:\n{value}\n\n{formattedTrace}") diff --git a/src/silx/app/view/main.py b/src/silx/app/view/main.py index c37b8aa..f6c5274 100644 --- a/src/silx/app/view/main.py +++ b/src/silx/app/view/main.py @@ -1,5 +1,5 @@ # /*########################################################################## -# Copyright (C) 2016-2022 European Synchrotron Radiation Facility +# Copyright (C) 2016-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -27,12 +27,12 @@ __license__ = "MIT" __date__ = "17/01/2019" import argparse -import glob import logging import os import signal import sys -from typing import Generator, Iterable +import traceback +from silx.app.utils import parseutils _logger = logging.getLogger(__name__) @@ -42,72 +42,53 @@ _logger = logging.getLogger(__name__) def createParser(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( - 'files', + "files", nargs=argparse.ZERO_OR_MORE, - help='Data file to show (h5 file, edf files, spec files)') + help="Data file to show (h5 file, edf files, spec files)", + ) parser.add_argument( - '--debug', + "--slices", + dest="slices", + default=tuple(), + type=int, + nargs="+", + help="List of slice indices to open (Only for dataset)", + ) + parser.add_argument( + "--debug", dest="debug", action="store_true", default=False, - help='Set logging system in debug mode') + help="Set logging system in debug mode", + ) parser.add_argument( - '--use-opengl-plot', + "--use-opengl-plot", dest="use_opengl_plot", action="store_true", default=False, - help='Use OpenGL for plots (instead of matplotlib)') + help="Use OpenGL for plots (instead of matplotlib)", + ) parser.add_argument( - '-f', '--fresh', + "-f", + "--fresh", dest="fresh_preferences", action="store_true", default=False, - help='Start the application using new fresh user preferences') + help="Start the application using new fresh user preferences", + ) parser.add_argument( - '--hdf5-file-locking', + "--hdf5-file-locking", dest="hdf5_file_locking", action="store_true", default=False, - help='Start the application with HDF5 file locking enabled (it is disabled by default)') + help="Start the application with HDF5 file locking enabled (it is disabled by default)", + ) return parser -def filesArgToUrls(filenames: Iterable[str]) -> Generator[object, None, None]: - """Expand filenames and HDF5 data path in files input argument""" - # Imports here so they are performed after setting HDF5_USE_FILE_LOCKING and logging level - import silx.io - from silx.io.utils import match - from silx.io.url import DataUrl - import silx.utils.files - - for filename in filenames: - url = DataUrl(filename) - - for file_path in sorted(silx.utils.files.expand_filenames([url.file_path()])): - if url.data_path() is not None and glob.has_magic(url.data_path()): - try: - with silx.io.open(file_path) as f: - data_paths = list(match(f, url.data_path())) - except BaseException as e: - _logger.error( - f"Error searching HDF5 path pattern '{url.data_path()}' in file '{file_path}': Ignored") - _logger.error(e.args[0]) - _logger.debug("Backtrace", exc_info=True) - continue - else: - data_paths = [url.data_path()] - - for data_path in data_paths: - yield DataUrl( - file_path=file_path, - data_path=data_path, - data_slice=url.data_slice(), - scheme=url.scheme(), - ) - - def createWindow(parent, settings): from .Viewer import Viewer + window = Viewer(parent=None, settings=settings) return window @@ -127,7 +108,7 @@ def mainQt(options): except ImportError: _logger.debug("No resource module available") else: - if hasattr(resource, 'RLIMIT_NOFILE'): + if hasattr(resource, "RLIMIT_NOFILE"): try: hard_nofile = resource.getrlimit(resource.RLIMIT_NOFILE)[1] resource.setrlimit(resource.RLIMIT_NOFILE, (hard_nofile, hard_nofile)) @@ -137,9 +118,9 @@ def mainQt(options): _logger.debug("Set max opened files to %d", hard_nofile) # This needs to be done prior to load HDF5 - hdf5_file_locking = 'TRUE' if options.hdf5_file_locking else 'FALSE' - _logger.info('Set HDF5_USE_FILE_LOCKING=%s', hdf5_file_locking) - os.environ['HDF5_USE_FILE_LOCKING'] = hdf5_file_locking + hdf5_file_locking = "TRUE" if options.hdf5_file_locking else "FALSE" + _logger.info("Set HDF5_USE_FILE_LOCKING=%s", hdf5_file_locking) + os.environ["HDF5_USE_FILE_LOCKING"] = hdf5_file_locking try: # it should be loaded before h5py @@ -151,6 +132,7 @@ def mainQt(options): import silx from silx.gui import qt + # Make sure matplotlib is configured # Needed for Debian 8: compatibility between Qt4/Qt5 and old matplotlib import silx.gui.utils.matplotlib # noqa @@ -163,7 +145,6 @@ def mainQt(options): qt.QApplication.quit() signal.signal(signal.SIGINT, sigintHandler) - sys.excepthook = qt.exceptionHandler timer = qt.QTimer() timer.start(500) @@ -171,23 +152,30 @@ def mainQt(options): # catched timer.timeout.connect(lambda: None) - settings = qt.QSettings(qt.QSettings.IniFormat, - qt.QSettings.UserScope, - "silx", - "silx-view", - None) + settings = qt.QSettings( + qt.QSettings.IniFormat, qt.QSettings.UserScope, "silx", "silx-view", None + ) if options.fresh_preferences: settings.clear() window = createWindow(parent=None, settings=settings) window.setAttribute(qt.Qt.WA_DeleteOnClose, True) + def exceptHook(type_, value, trace): + _logger.error("An error occured in silx view:") + _logger.error("%s %s %s", type_, value, "".join(traceback.format_tb(trace))) + try: + window.setErrorFromException(type_, value, trace) + except Exception: + pass + + sys.excepthook = exceptHook + if options.use_opengl_plot: # It have to be done after the settings (after the Viewer creation) silx.config.DEFAULT_PLOT_BACKEND = "opengl" - - for url in filesArgToUrls(options.files): + for url in parseutils.filenames_to_dataurls(options.files, options.slices): # TODO: Would be nice to add a process widget and a cancel button try: window.appendFile(url.path()) @@ -214,5 +202,5 @@ def main(argv): mainQt(options) -if __name__ == '__main__': +if __name__ == "__main__": main(sys.argv) diff --git a/src/silx/app/view/test/test_launcher.py b/src/silx/app/view/test/test_launcher.py index 8ccf4af..49b1032 100644 --- a/src/silx/app/view/test/test_launcher.py +++ b/src/silx/app/view/test/test_launcher.py @@ -84,23 +84,22 @@ class TestLauncher(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: # Copy file to temporary dir to avoid import from current dir. - script = os.path.join(tmpdir, 'launcher.py') + script = os.path.join(tmpdir, "launcher.py") shutil.copyfile(filename, script) command_line = [sys.executable, script] + list(args) _logger.info("Execute: %s", " ".join(command_line)) - p = subprocess.Popen(command_line, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env) + p = subprocess.Popen( + command_line, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env + ) out, err = p.communicate() _logger.info("Return code: %d", p.returncode) try: - out = out.decode('utf-8') + out = out.decode("utf-8") except UnicodeError: pass try: - err = err.decode('utf-8') + err = err.decode("utf-8") except UnicodeError: pass diff --git a/src/silx/app/view/test/test_view.py b/src/silx/app/view/test/test_view.py index 362995a..1eb588b 100644 --- a/src/silx/app/view/test/test_view.py +++ b/src/silx/app/view/test/test_view.py @@ -115,7 +115,6 @@ class TestAbout(TestCaseQt): @pytest.mark.usefixtures("qapp") @pytest.mark.usefixtures("data_class_attr") class TestDataPanel(TestCaseQt): - def testConstruct(self): widget = DataPanel() self.qWaitForWindowExposed(widget) @@ -169,7 +168,7 @@ class TestDataPanel(TestCaseQt): self.assertIs(widget.getCustomNxdataItem(), data) def testRemoveDatasetsFrom(self): - f = h5py.File(self.data_h5, mode='r') + f = h5py.File(self.data_h5, mode="r") try: widget = DataPanel() widget.setData(f["arrays/scalar"]) @@ -180,8 +179,8 @@ class TestDataPanel(TestCaseQt): f.close() def testReplaceDatasetsFrom(self): - f = h5py.File(self.data_h5, mode='r') - f2 = h5py.File(self.data2_h5, mode='r') + f = h5py.File(self.data_h5, mode="r") + f2 = h5py.File(self.data2_h5, mode="r") try: widget = DataPanel() widget.setData(f["arrays/scalar"]) @@ -197,7 +196,6 @@ class TestDataPanel(TestCaseQt): @pytest.mark.usefixtures("qapp") @pytest.mark.usefixtures("data_class_attr") class TestCustomNxdataWidget(TestCaseQt): - def testConstruct(self): widget = CustomNxdataWidget() self.qWaitForWindowExposed(widget) @@ -250,7 +248,7 @@ class TestCustomNxdataWidget(TestCaseQt): self.assertFalse(item.isValid()) def testRemoveDatasetsFrom(self): - f = h5py.File(self.data_h5, mode='r') + f = h5py.File(self.data_h5, mode="r") try: widget = CustomNxdataWidget() model = widget.model() @@ -262,8 +260,8 @@ class TestCustomNxdataWidget(TestCaseQt): f.close() def testReplaceDatasetsFrom(self): - f = h5py.File(self.data_h5, mode='r') - f2 = h5py.File(self.data2_h5, mode='r') + f = h5py.File(self.data_h5, mode="r") + f2 = h5py.File(self.data2_h5, mode="r") try: widget = CustomNxdataWidget() model = widget.model() @@ -299,14 +297,18 @@ class TestCustomNxdataWidgetInteraction(TestCaseQt): def testSelectedNxdata(self): index = self.model.index(0, 0) - self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect) + self.selectionModel.setCurrentIndex( + index, qt.QItemSelectionModel.ClearAndSelect + ) nxdata = self.widget.selectedNxdata() self.assertEqual(len(nxdata), 1) self.assertIsNot(nxdata[0], None) def testSelectedItems(self): index = self.model.index(0, 0) - self.selectionModel.setCurrentIndex(index, qt.QItemSelectionModel.ClearAndSelect) + self.selectionModel.setCurrentIndex( + index, qt.QItemSelectionModel.ClearAndSelect + ) items = self.widget.selectedItems() self.assertEqual(len(items), 1) self.assertIsNot(items[0], None) |