diff options
Diffstat (limited to 'src/silx/app')
-rw-r--r-- | src/silx/app/__init__.py | 29 | ||||
-rw-r--r-- | src/silx/app/convert.py | 548 | ||||
-rw-r--r-- | src/silx/app/setup.py | 41 | ||||
-rw-r--r-- | src/silx/app/test/__init__.py | 24 | ||||
-rw-r--r-- | src/silx/app/test/test_convert.py | 156 | ||||
-rw-r--r-- | src/silx/app/test_.py | 45 | ||||
-rw-r--r-- | src/silx/app/view/About.py | 258 | ||||
-rw-r--r-- | src/silx/app/view/ApplicationContext.py | 195 | ||||
-rw-r--r-- | src/silx/app/view/CustomNxdataWidget.py | 1002 | ||||
-rw-r--r-- | src/silx/app/view/DataPanel.py | 192 | ||||
-rw-r--r-- | src/silx/app/view/Viewer.py | 962 | ||||
-rw-r--r-- | src/silx/app/view/__init__.py | 28 | ||||
-rw-r--r-- | src/silx/app/view/main.py | 186 | ||||
-rw-r--r-- | src/silx/app/view/setup.py | 40 | ||||
-rw-r--r-- | src/silx/app/view/test/__init__.py | 24 | ||||
-rw-r--r-- | src/silx/app/view/test/test_launcher.py | 140 | ||||
-rw-r--r-- | src/silx/app/view/test/test_view.py | 388 | ||||
-rw-r--r-- | src/silx/app/view/utils.py | 45 |
18 files changed, 4303 insertions, 0 deletions
diff --git a/src/silx/app/__init__.py b/src/silx/app/__init__.py new file mode 100644 index 0000000..3af680c --- /dev/null +++ b/src/silx/app/__init__.py @@ -0,0 +1,29 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2016-2018 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. +# +# ###########################################################################*/ +"""This package contains the application provided by the launcher""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "30/03/2017" diff --git a/src/silx/app/convert.py b/src/silx/app/convert.py new file mode 100644 index 0000000..43baf7e --- /dev/null +++ b/src/silx/app/convert.py @@ -0,0 +1,548 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2017-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. +# +# ############################################################################*/ +"""Convert silx supported data files into HDF5 files""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "05/02/2019" + +import ast +import os +import argparse +from glob import glob +import logging +import re +import time +import numpy + +import silx.io +from silx.io.specfile import is_specfile +from silx.io.fioh5 import is_fiofile +from silx.io import fabioh5 + +_logger = logging.getLogger(__name__) +"""Module logger""" + + +def c_format_string_to_re(pattern_string): + """ + + :param pattern_string: C style format string with integer patterns + (e.g. "%d", "%04d"). + Not supported: fixed length padded with whitespaces (e.g "%4d", "%-4d") + :return: Equivalent regular expression (e.g. "\\d+", "\\d{4}") + """ + # escape dots and backslashes + pattern_string = pattern_string.replace("\\", "\\\\") + pattern_string = pattern_string.replace(".", r"\.") + + # %d + pattern_string = pattern_string.replace("%d", r"([-+]?\d+)") + + # %0nd + for sub_pattern in re.findall(r"%0\d+d", pattern_string): + n = int(re.search(r"%0(\d+)d", sub_pattern).group(1)) + if n == 1: + re_sub_pattern = r"([+-]?\d)" + else: + re_sub_pattern = r"([\d+-]\d{%d})" % (n - 1) + pattern_string = pattern_string.replace(sub_pattern, re_sub_pattern, 1) + + return pattern_string + + +def drop_indices_before_begin(filenames, regex, begin): + """ + + :param List[str] filenames: list of filenames + :param str regex: Regexp used to find indices in a filename + :param str begin: Comma separated list of begin indices + :return: List of filenames with only indices >= begin + """ + begin_indices = list(map(int, begin.split(","))) + output_filenames = [] + for fname in filenames: + 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.") + good_indices = True + for i, fidx in enumerate(file_indices): + if fidx < begin_indices[i]: + good_indices = False + if good_indices: + output_filenames.append(fname) + return output_filenames + + +def drop_indices_after_end(filenames, regex, end): + """ + + :param List[str] filenames: list of filenames + :param str regex: Regexp used to find indices in a filename + :param str end: Comma separated list of end indices + :return: List of filenames with only indices <= end + """ + end_indices = list(map(int, end.split(","))) + output_filenames = [] + for fname in filenames: + 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.") + good_indices = True + for i, fidx in enumerate(file_indices): + if fidx > end_indices[i]: + good_indices = False + if good_indices: + output_filenames.append(fname) + return output_filenames + + +def are_files_missing_in_series(filenames, regex): + """Return True if any file is missing in a list of filenames + that are supposed to follow a pattern. + + :param List[str] filenames: list of filenames + :param str regex: Regexp used to find indices in a filename + :return: boolean + :raises AssertionError: if a filename does not match the regexp + """ + 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) + 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) + return True + previous_indices = new_indices + return False + + +def are_all_specfile(filenames): + """Return True if all files in a list are SPEC files. + :param List[str] filenames: list of filenames + """ + for fname in filenames: + if not is_specfile(fname): + return False + return True + + +def contains_specfile(filenames): + """Return True if any file in a list are SPEC files. + :param List[str] filenames: list of filenames + """ + for fname in filenames: + if is_specfile(fname): + return True + return False + + +def contains_fiofile(filenames): + """Return True if any file in a list are FIO files. + :param List[str] filenames: list of filenames + """ + for fname in filenames: + if is_fiofile(fname): + return True + return False + + +def are_all_fiofile(filenames): + """Return True if all files in a list are FIO files. + :param List[str] filenames: list of filenames + """ + for fname in filenames: + if not is_fiofile(fname): + return False + return True + + +def main(argv): + """ + Main function to launch the converter as an application + + :param argv: Command line arguments + :returns: exit status + """ + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '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.') + # 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.') + 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') + parser.add_argument( + '-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)') + 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".') + 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"') + parser.add_argument( + '--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.') + parser.add_argument( + '--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").') + parser.add_argument( + '--min-size', + type=int, + default=500, + help='Minimum number of elements required to be in a dataset to ' + 'apply compression or chunking (default 500).') + parser.add_argument( + '--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). ') + parser.add_argument( + '--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.') + + 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") + return ivalue + + parser.add_argument( + '--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.') + parser.add_argument( + '--shuffle', + action="store_true", + help='Enables the byte shuffle filter. This may improve the compression ' + 'ratio for block oriented compressors like GZIP or LZF.') + parser.add_argument( + '--fletcher32', + action="store_true", + help='Adds a checksum to each chunk to detect data corruption.') + parser.add_argument( + '--debug', + action="store_true", + default=False, + help='Set logging system in debug mode') + + options = parser.parse_args(argv[1:]) + + if options.debug: + logging.root.setLevel(logging.DEBUG) + + # Import after parsing --debug + try: + # it should be loaded before h5py + import hdf5plugin # noqa + except ImportError: + _logger.debug("Backtrace", exc_info=True) + hdf5plugin = None + + import h5py + + try: + from silx.io.convert import write_to_h5 + except ImportError: + _logger.debug("Backtrace", exc_info=True) + 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\"." + _logger.debug(message) + + # Process input arguments (mutually exclusive arguments) + if bool(options.input_files) == bool(options.file_pattern is not None): + if not options.input_files: + message = "You must specify either input files (at least one), " + message += "or a file pattern." + else: + message = "You cannot specify input files and a file pattern" + message += " at the same time." + _logger.error(message) + return -1 + elif options.input_files: + # some shells (windows) don't interpret wildcard characters (*, ?, []) + old_input_list = list(options.input_files) + options.input_files = [] + for fname in old_input_list: + globbed_files = glob(fname) + if not globbed_files: + # no files found, keep the name as it is, to raise an error later + options.input_files += [fname] + else: + # glob does not sort files, but the bash shell does + options.input_files += sorted(globbed_files) + else: + # File series + 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(""" + 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))) + _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) + + 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): + _logger.error("File missing in the file series. Aborting.") + return -1 + + if not options.input_files: + _logger.error("No file matching --file-pattern found.") + return -1 + + # Test that the output path is writeable + if "::" in options.output_uri: + output_name, hdf5_path = options.output_uri.split("::") + else: + output_name, hdf5_path = options.output_uri, "/" + + 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) + return -1 + elif not os.access(output_name, os.W_OK): + _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) + elif options.mode in ["a", "r+"]: + _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) + return -1 + else: + _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) + bad_input = True + if bad_input: + _logger.error("Aborting.") + return -1 + + # create_dataset special args + create_dataset_args = {} + if options.chunks is not None: + if options.chunks.lower() in ["auto", "true"]: + create_dataset_args["chunks"] = True + else: + try: + chunks = ast.literal_eval(options.chunks) + except (ValueError, SyntaxError): + _logger.error("Invalid --chunks argument %s", options.chunks) + return -1 + if not isinstance(chunks, (tuple, list)): + _logger.error("--chunks argument str does not evaluate to a tuple") + return -1 + else: + 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.") + create_dataset_args["chunks"] = chunks + + if options.compression is not None: + try: + compression = int(options.compression) + except ValueError: + compression = options.compression + create_dataset_args["compression"] = compression + + if options.compression_opts is not None: + create_dataset_args["compression_opts"] = options.compression_opts + + if options.shuffle: + create_dataset_args["shuffle"] = True + + 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: + # 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: + # 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) + try: + 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) + 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) + + 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.") + 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"") + creator = "silx convert (v%s)" % silx.version + # only if it not already there + if creator not in previous_creator: + if not previous_creator: + new_creator = creator + else: + new_creator = previous_creator + "; " + creator + h5f.attrs["creator"] = numpy.array( + new_creator, + dtype=h5py.special_dtype(vlen=str)) + + return 0 diff --git a/src/silx/app/setup.py b/src/silx/app/setup.py new file mode 100644 index 0000000..85c3662 --- /dev/null +++ b/src/silx/app/setup.py @@ -0,0 +1,41 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 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. +# +# ############################################################################*/ + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "23/04/2018" + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('app', parent_package, top_path) + config.add_subpackage('test') + config.add_subpackage('view') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/src/silx/app/test/__init__.py b/src/silx/app/test/__init__.py new file mode 100644 index 0000000..7790ee5 --- /dev/null +++ b/src/silx/app/test/__init__.py @@ -0,0 +1,24 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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/test/test_convert.py b/src/silx/app/test/test_convert.py new file mode 100644 index 0000000..2148db5 --- /dev/null +++ b/src/silx/app/test/test_convert.py @@ -0,0 +1,156 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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. +# +# ###########################################################################*/ +"""Module testing silx.app.convert""" + +__authors__ = ["P. Knobel"] +__license__ = "MIT" +__date__ = "17/01/2018" + + +import os +import sys +import tempfile +import unittest +import io +import gc +import h5py + +import silx +from .. import convert +from silx.utils import testutils +from silx.io.utils import h5py_read_dataset + + +# content of a spec file +sftext = """#F /tmp/sf.dat +#E 1455180875 +#D Thu Feb 11 09:54:35 2016 +#C imaging User = opid17 +#O0 Pslit HGap MRTSlit UP MRTSlit DOWN +#O1 Sslit1 VOff Sslit1 HOff Sslit1 VGap +#o0 pshg mrtu mrtd +#o2 ss1vo ss1ho ss1vg + +#J0 Seconds IA ion.mono Current +#J1 xbpmc2 idgap1 Inorm + +#S 1 ascan ss1vo -4.55687 -0.556875 40 0.2 +#D Thu Feb 11 09:55:20 2016 +#T 0.2 (Seconds) +#P0 180.005 -0.66875 0.87125 +#P1 14.74255 16.197579 12.238283 +#N 4 +#L MRTSlit UP second column 3rd_col +-1.23 5.89 8 +8.478100E+01 5 1.56 +3.14 2.73 -3.14 +1.2 2.3 3.4 + +#S 1 aaaaaa +#D Thu Feb 11 10:00:32 2016 +#@MCADEV 1 +#@MCA %16C +#@CHANN 3 0 2 1 +#@CALIB 1 2 3 +#N 3 +#L uno duo +1 2 +@A 0 1 2 +@A 10 9 8 +3 4 +@A 3.1 4 5 +@A 7 6 5 +5 6 +@A 6 7.7 8 +@A 4 3 2 +""" + + +class TestConvertCommand(unittest.TestCase): + """Test command line parsing""" + + def testHelp(self): + # option -h must cause a `raise SystemExit` or a `return 0` + try: + result = convert.main(["convert", "--help"]) + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + def testWrongOption(self): + # presence of a wrong option must cause a SystemExit or a return + # with a non-zero status + try: + result = convert.main(["convert", "--foo"]) + except SystemExit as e: + result = e.args[0] + self.assertNotEqual(result, 0) + + @testutils.validate_logging(convert._logger.name, error=3) + # one error log per missing file + one "Aborted" error log + def testWrongFiles(self): + result = convert.main(["convert", "foo.spec", "bar.edf"]) + self.assertNotEqual(result, 0) + + def testFile(self): + # create a writable temp directory + tempdir = tempfile.mkdtemp() + + # 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')) + + # convert it + h5name = os.path.join(tempdir, "output.h5") + assert not os.path.isfile(h5name) + command_list = ["convert", "-m", "w", + specname, "-o", h5name] + result = convert.main(command_list) + + self.assertEqual(result, 0) + self.assertTrue(os.path.isfile(h5name)) + + 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") + + 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 + gc.collect() # necessary to free spec file on Windows + os.unlink(specname) + os.unlink(h5name) + os.rmdir(tempdir) diff --git a/src/silx/app/test_.py b/src/silx/app/test_.py new file mode 100644 index 0000000..2b6bdf8 --- /dev/null +++ b/src/silx/app/test_.py @@ -0,0 +1,45 @@ +# coding: utf-8 +# /*########################################################################## +# 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. +# +# ############################################################################*/ +"""Launch unittests of the library""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/01/2018" + + +def main(argv): + """ + Main function to launch the unittests as an application + + :param argv: Command line arguments + :returns: exit status + """ + import silx.test + import pytest + + if silx.test.run_tests(args=argv[1:]) == pytest.ExitCode.OK: + exit_status = 0 + else: + exit_status = 1 + return exit_status diff --git a/src/silx/app/view/About.py b/src/silx/app/view/About.py new file mode 100644 index 0000000..85f1450 --- /dev/null +++ b/src/silx/app/view/About.py @@ -0,0 +1,258 @@ +# coding: utf-8 +# /*########################################################################## +# 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. +# +# ############################################################################*/ +"""About box for Silx viewer""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "05/07/2018" + +import os +import sys + +from silx.gui import qt +from silx.gui import icons + +_LICENSE_TEMPLATE = """<p align="center"> +<b>Copyright (C) {year} European Synchrotron Radiation Facility</b> +</p> + +<p align="justify"> +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: +</p> + +<p align="justify"> +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. +</p> + +<p align="justify"> +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. +</p> +""" + + +class About(qt.QDialog): + """ + Util dialog to display an common about box for all the silx GUIs. + """ + + def __init__(self, parent=None): + """ + :param files_: List of HDF5 or Spec files (pathes or + :class:`silx.io.spech5.SpecH5` or :class:`h5py.File` + instances) + """ + super(About, self).__init__(parent) + self.__createLayout() + self.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) + self.setModal(True) + self.setApplicationName(None) + + def __createLayout(self): + layout = qt.QVBoxLayout(self) + layout.setContentsMargins(24, 15, 24, 20) + layout.setSpacing(8) + + self.__label = qt.QLabel(self) + self.__label.setWordWrap(True) + flags = self.__label.textInteractionFlags() + flags = flags | qt.Qt.TextSelectableByKeyboard + flags = flags | qt.Qt.TextSelectableByMouse + self.__label.setTextInteractionFlags(flags) + self.__label.setOpenExternalLinks(True) + self.__label.setSizePolicy(qt.QSizePolicy.Minimum, qt.QSizePolicy.Preferred) + + licenseButton = qt.QPushButton(self) + licenseButton.setText("License...") + licenseButton.clicked.connect(self.__displayLicense) + licenseButton.setAutoDefault(False) + + self.__options = qt.QDialogButtonBox() + self.__options.addButton(licenseButton, qt.QDialogButtonBox.ActionRole) + okButton = self.__options.addButton(qt.QDialogButtonBox.Ok) + okButton.setDefault(True) + okButton.clicked.connect(self.accept) + + layout.addWidget(self.__label) + layout.addWidget(self.__options) + layout.setStretch(0, 100) + layout.setStretch(1, 0) + + def getHtmlLicense(self): + """Returns the text license in HTML format. + + :rtype: str + """ + from silx._version import __date__ as date + year = date.split("/")[2] + info = dict( + year=year + ) + textLicense = _LICENSE_TEMPLATE.format(**info) + return textLicense + + def __displayLicense(self): + """Displays the license used by silx.""" + text = self.getHtmlLicense() + licenseDialog = qt.QMessageBox(self) + licenseDialog.setWindowTitle("License") + licenseDialog.setText(text) + licenseDialog.exec() + + def setApplicationName(self, name): + self.__applicationName = name + if name is None: + self.setWindowTitle("About") + else: + self.setWindowTitle("About %s" % name) + self.__updateText() + + @staticmethod + def __formatOptionalLibraries(name, isAvailable): + """Utils to format availability of features""" + if isAvailable: + template = '<b>%s</b> is <font color="green">loaded</font>' + else: + template = '<b>%s</b> is <font color="red">not loaded</font>' + return template % name + + @staticmethod + def __formatOptionalFilters(name, isAvailable): + """Utils to format availability of features""" + if isAvailable: + template = '<b>%s</b> is <font color="green">available</font>' + else: + template = '<b>%s</b> is <font color="red">not available</font>' + return template % name + + def __updateText(self): + """Update the content of the dialog according to the settings.""" + import silx._version + import h5py.version + + message = """<table> + <tr><td width="50%" align="center" valign="middle"> + <img src="{silx_image_path}" width="100" /> + </td><td width="50%" align="center" valign="middle"> + <b>{application_name}</b> + <br /> + <br />{silx_version} + <br /> + <br /><a href="{project_url}">Upstream project on GitHub</a> + </td></tr> + </table> + <dl> + <dt><b>Silx version</b></dt><dd>{silx_version}</dd> + <dt><b>HDF5 version</b></dt><dd>{hdf5_version}</dd> + <dt><b>h5py version</b></dt><dd>{h5py_version}</dd> + <dt><b>Qt version</b></dt><dd>{qt_version}</dd> + <dt><b>Qt binding</b></dt><dd>{qt_binding}</dd> + <dt><b>Python version</b></dt><dd>{python_version}</dd> + <dt><b>Optional libraries</b></dt><dd>{optional_lib}</dd> + </dl> + <p> + Copyright (C) <a href="{esrf_url}">European Synchrotron Radiation Facility</a> + </p> + """ + + optionals = [] + if h5py.version.hdf5_version_tuple >= (1, 10, 2): + # 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 = [ + ("HDF5 LZ4 filter", FILTER_LZ4), + ("HDF5 Bitshuffle filter", FILTER_BITSHUFFLE), + ] + for name, filterId in filters: + isAvailable = h5py.h5z.filter_avail(filterId) + optionals.append(self.__formatOptionalFilters(name, isAvailable)) + else: + 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")) + + info = dict( + application_name=self.__applicationName, + esrf_url="http://www.esrf.eu", + project_url="https://github.com/silx-kit/silx", + silx_version=silx._version.version, + h5py_version=h5py.version.version, + hdf5_version=h5py.version.hdf5_version, + qt_binding=qt.BINDING, + qt_version=qt.qVersion(), + python_version=sys.version.replace("\n", "<br />"), + optional_lib="<br />".join(optionals), + silx_image_path=logo.fileName() + ) + + self.__label.setText(message.format(**info)) + self.__updateSize() + + 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() + else: # Qt6 + screenSize = qt.QApplication.instance().primaryScreen().availableGeometry().size() + hardLimit = min(screenSize.width() - 480, 1000) + if screenSize.width() <= 1024: + hardLimit = screenSize.width() + softLimit = min(screenSize.width() / 2, 420) + + layoutMinimumSize = self.layout().totalMinimumSize() + width = layoutMinimumSize.width() + if width > softLimit: + width = softLimit + if width > hardLimit: + width = hardLimit + + height = layoutMinimumSize.height() + self.setFixedSize(width, height) + + @staticmethod + def about(parent, applicationName): + """Displays a silx about box with title and text text. + + :param qt.QWidget parent: The parent widget + :param str title: The title of the dialog + :param str applicationName: The content of the dialog + """ + dialog = About(parent) + dialog.setApplicationName(applicationName) + dialog.exec() diff --git a/src/silx/app/view/ApplicationContext.py b/src/silx/app/view/ApplicationContext.py new file mode 100644 index 0000000..324f3b8 --- /dev/null +++ b/src/silx/app/view/ApplicationContext.py @@ -0,0 +1,195 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "23/05/2018" + +import weakref +import logging + +import silx +from silx.gui.data.DataViews import DataViewHooks +from silx.gui.colors import Colormap +from silx.gui.dialog.ColormapDialog import ColormapDialog + + +_logger = logging.getLogger(__name__) + + +class ApplicationContext(DataViewHooks): + """ + Store the conmtext of the application + + It overwrites the DataViewHooks to custom the use of the DataViewer for + the silx view application. + + - Create a single colormap shared with all the views + - Create a single colormap dialog shared with all the views + """ + + def __init__(self, parent, settings=None): + self.__parent = weakref.ref(parent) + self.__defaultColormap = None + self.__defaultColormapDialog = None + self.__settings = settings + self.__recentFiles = [] + + def getSettings(self): + """Returns actual application settings. + + :rtype: qt.QSettings + """ + return self.__settings + + def restoreLibrarySettings(self): + """Restore the library settings, which must be done early""" + settings = self.__settings + if settings is None: + return + settings.beginGroup("library") + 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" + if plotImageYAxisOrientation != "": + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = plotImageYAxisOrientation + + def restoreSettings(self): + """Restore the settings of all the application""" + settings = self.__settings + if settings is None: + return + parent = self.__parent() + parent.restoreSettings(settings) + + settings.beginGroup("colormap") + byteArray = settings.value("default", None) + if byteArray is not None: + try: + colormap = Colormap() + colormap.restoreState(byteArray) + self.__defaultColormap = colormap + except Exception: + _logger.debug("Backtrace", exc_info=True) + settings.endGroup() + + self.__recentFiles = [] + settings.beginGroup("recent-files") + for index in range(1, 10 + 1): + if not settings.contains("path%d" % index): + break + filePath = settings.value("path%d" % index) + self.__recentFiles.append(filePath) + settings.endGroup() + + def saveSettings(self): + """Save the settings of all the application""" + settings = self.__settings + if settings is None: + return + parent = self.__parent() + parent.saveSettings(settings) + + if self.__defaultColormap is not None: + settings.beginGroup("colormap") + settings.setValue("default", self.__defaultColormap.saveState()) + 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.endGroup() + + settings.beginGroup("recent-files") + for index in range(0, 11): + key = "path%d" % (index + 1) + if index < len(self.__recentFiles): + filePath = self.__recentFiles[index] + settings.setValue(key, filePath) + else: + settings.remove(key) + settings.endGroup() + + def getRecentFiles(self): + """Returns the list of recently opened files. + + The list is limited to the last 10 entries. The newest file path is + in first. + + :rtype: List[str] + """ + return self.__recentFiles + + def pushRecentFile(self, filePath): + """Push a new recent file to the list. + + If the file is duplicated in the list, all duplications are removed + before inserting the new filePath. + + If the list becan bigger than 10 items, oldest paths are removed. + + :param filePath: File path to push + """ + # Remove old occurencies + self.__recentFiles[:] = (f for f in self.__recentFiles if f != filePath) + self.__recentFiles.insert(0, filePath) + while len(self.__recentFiles) > 10: + self.__recentFiles.pop() + + def clearRencentFiles(self): + """Clear the history of the rencent files. + """ + self.__recentFiles[:] = [] + + def getColormap(self, view): + """Returns a default colormap. + + Override from DataViewHooks + + :rtype: Colormap + """ + if self.__defaultColormap is None: + self.__defaultColormap = Colormap(name="viridis") + return self.__defaultColormap + + def getColormapDialog(self, view): + """Returns a shared color dialog as default for all the views. + + Override from DataViewHooks + + :rtype: ColorDialog + """ + if self.__defaultColormapDialog is None: + parent = self.__parent() + if parent is None: + return None + dialog = ColormapDialog(parent=parent) + dialog.setModal(False) + self.__defaultColormapDialog = dialog + return self.__defaultColormapDialog diff --git a/src/silx/app/view/CustomNxdataWidget.py b/src/silx/app/view/CustomNxdataWidget.py new file mode 100644 index 0000000..8c6cd39 --- /dev/null +++ b/src/silx/app/view/CustomNxdataWidget.py @@ -0,0 +1,1002 @@ +# coding: utf-8 +# /*########################################################################## +# 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. +# +# ############################################################################*/ + +"""Widget to custom NXdata groups""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "15/06/2018" + +import logging +import numpy +import weakref + +from silx.gui import qt +from silx.io import commonh5 +import silx.io.nxdata +from silx.gui.hdf5._utils import Hdf5DatasetMimeData +from silx.gui.data.TextFormatter import TextFormatter +from silx.gui.hdf5.Hdf5Formatter import Hdf5Formatter +from silx.gui import icons + + +_logger = logging.getLogger(__name__) +_formatter = TextFormatter() +_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter) + + +class _RowItems(qt.QStandardItem): + """Define the list of items used for a specific row.""" + + def type(self): + return qt.QStandardItem.UserType + 1 + + def getRowItems(self): + """Returns the list of items used for a specific row. + + The first item should be this class. + + :rtype: List[qt.QStandardItem] + """ + raise NotImplementedError() + + +class _DatasetItemRow(_RowItems): + """Define a row which can contain a dataset.""" + + def __init__(self, label="", dataset=None): + """Constructor""" + super(_DatasetItemRow, self).__init__(label) + self.setEditable(False) + self.setDropEnabled(False) + self.setDragEnabled(False) + + self.__name = qt.QStandardItem() + self.__name.setEditable(False) + self.__name.setDropEnabled(True) + + self.__type = qt.QStandardItem() + self.__type.setEditable(False) + self.__type.setDropEnabled(False) + self.__type.setDragEnabled(False) + + self.__shape = qt.QStandardItem() + self.__shape.setEditable(False) + self.__shape.setDropEnabled(False) + self.__shape.setDragEnabled(False) + + self.setDataset(dataset) + + def getDefaultFormatter(self): + """Get the formatter used to display dataset informations. + + :rtype: Hdf5Formatter + """ + return _hdf5Formatter + + def setDataset(self, dataset): + """Set the dataset stored in this item. + + :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset: + The dataset to store. + """ + self.__dataset = dataset + if self.__dataset is not None: + name = self.__dataset.name + + if silx.io.is_dataset(dataset): + type_ = self.getDefaultFormatter().humanReadableType(dataset) + shape = self.getDefaultFormatter().humanReadableShape(dataset) + + if dataset.shape is None: + icon_name = "item-none" + elif len(dataset.shape) < 4: + icon_name = "item-%ddim" % len(dataset.shape) + else: + icon_name = "item-ndim" + icon = icons.getQIcon(icon_name) + else: + type_ = "" + shape = "" + icon = qt.QIcon() + else: + name = "" + type_ = "" + shape = "" + icon = qt.QIcon() + + self.__icon = icon + self.__name.setText(name) + self.__name.setDragEnabled(self.__dataset is not None) + self.__name.setIcon(self.__icon) + self.__type.setText(type_) + self.__shape.setText(shape) + + parent = self.parent() + if parent is not None: + self.parent()._datasetUpdated() + + def getDataset(self): + """Returns the dataset stored within the item.""" + return self.__dataset + + def getRowItems(self): + """Returns the list of items used for a specific row. + + The first item should be this class. + + :rtype: List[qt.QStandardItem] + """ + return [self, self.__name, self.__type, self.__shape] + + +class _DatasetAxisItemRow(_DatasetItemRow): + """Define a row describing an axis.""" + + def __init__(self): + """Constructor""" + super(_DatasetAxisItemRow, self).__init__() + + def setAxisId(self, axisId): + """Set the id of the axis (the first axis is 0) + + :param int axisId: Identifier of this axis. + """ + self.__axisId = axisId + label = "Axis %d" % (axisId + 1) + self.setText(label) + + def getAxisId(self): + """Returns the identifier of this axis. + + :rtype: int + """ + return self.__axisId + + +class _NxDataItem(qt.QStandardItem): + """ + Define a custom NXdata. + """ + + def __init__(self): + """Constructor""" + qt.QStandardItem.__init__(self) + self.__error = None + self.__title = None + self.__axes = [] + self.__virtual = None + + item = _DatasetItemRow("Signal", None) + self.appendRow(item.getRowItems()) + self.__signal = item + + self.setEditable(False) + self.setDragEnabled(False) + self.setDropEnabled(False) + self.__setError(None) + + def getRowItems(self): + """Returns the list of items used for a specific row. + + The first item should be this class. + + :rtype: List[qt.QStandardItem] + """ + row = [self] + for _ in range(3): + item = qt.QStandardItem("") + item.setEditable(False) + item.setDragEnabled(False) + item.setDropEnabled(False) + row.append(item) + return row + + def _datasetUpdated(self): + """Called when the NXdata contained of the item have changed. + + It invalidates the NXdata stored and send an event `sigNxdataUpdated`. + """ + self.__virtual = None + self.__setError(None) + model = self.model() + if model is not None: + model.sigNxdataUpdated.emit(self.index()) + + def createVirtualGroup(self): + """Returns a new virtual Group using a NeXus NXdata structure to store + data + + :rtype: silx.io.commonh5.Group + """ + name = "" + if self.__title is not None: + name = self.__title + virtual = commonh5.Group(name) + virtual.attrs["NX_class"] = "NXdata" + + if self.__title is not None: + virtual.attrs["title"] = self.__title + + if self.__signal is not None: + signal = self.__signal.getDataset() + if signal is not None: + # Could be done using a link instead of a copy + node = commonh5.DatasetProxy("signal", target=signal) + virtual.attrs["signal"] = "signal" + virtual.add_node(node) + + axesAttr = [] + for i, axis in enumerate(self.__axes): + if axis is None: + name = "." + else: + axis = axis.getDataset() + if axis is None: + name = "." + else: + name = "axis%d" % i + node = commonh5.DatasetProxy(name, target=axis) + virtual.add_node(node) + axesAttr.append(name) + + if axesAttr != []: + virtual.attrs["axes"] = numpy.array(axesAttr) + + validator = silx.io.nxdata.NXdata(virtual) + if not validator.is_valid: + message = "<html>" + message += "This NXdata is not consistant" + message += "<ul>" + for issue in validator.issues: + message += "<li>%s</li>" % issue + message += "</ul>" + message += "</html>" + self.__setError(message) + else: + self.__setError(None) + return virtual + + def isValid(self): + """Returns true if the stored NXdata is valid + + :rtype: bool + """ + return self.__error is None + + def getVirtualGroup(self): + """Returns a cached virtual Group using a NeXus NXdata structure to + store data. + + If the stored NXdata was invalidated, :meth:`createVirtualGroup` is + internally called to update the cache. + + :rtype: silx.io.commonh5.Group + """ + if self.__virtual is None: + self.__virtual = self.createVirtualGroup() + return self.__virtual + + def getTitle(self): + """Returns the title of the NXdata + + :rtype: str + """ + return self.text() + + def setTitle(self, title): + """Set the title of the NXdata + + :param str title: The title of this NXdata + """ + self.setText(title) + + def __setError(self, error): + """Set the error message in case of the current state of the stored + NXdata is not valid. + + :param str error: Message to display + """ + self.__error = error + style = qt.QApplication.style() + if error is None: + message = "" + icon = style.standardIcon(qt.QStyle.SP_DirLinkIcon) + else: + message = error + icon = style.standardIcon(qt.QStyle.SP_MessageBoxCritical) + self.setIcon(icon) + self.setToolTip(message) + + def getError(self): + """Returns the error message in case the NXdata is not valid. + + :rtype: str""" + return self.__error + + def setSignalDataset(self, dataset): + """Set the dataset to use as signal with this NXdata. + + :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset: + The dataset to use as signal. + """ + + self.__signal.setDataset(dataset) + self._datasetUpdated() + + def getSignalDataset(self): + """Returns the dataset used as signal. + + :rtype: Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] + """ + return self.__signal.getDataset() + + def setAxesDatasets(self, datasets): + """Set all the available dataset used as axes. + + Axes will be created or removed from the GUI in order to provide the + same amount of requested axes. + + A `None` element is an axes with no dataset. + + :param List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]] datasets: + List of dataset to use as axes. + """ + for i, dataset in enumerate(datasets): + if i < len(self.__axes): + mustAppend = False + item = self.__axes[i] + else: + mustAppend = True + item = _DatasetAxisItemRow() + item.setAxisId(i) + item.setDataset(dataset) + if mustAppend: + self.__axes.append(item) + self.appendRow(item.getRowItems()) + + # Clean up extra axis + for i in range(len(datasets), len(self.__axes)): + item = self.__axes.pop(len(datasets)) + self.removeRow(item.row()) + + self._datasetUpdated() + + def getAxesDatasets(self): + """Returns available axes as dataset. + + A `None` element is an axes with no dataset. + + :rtype: List[Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset,None]] + """ + datasets = [] + for axis in self.__axes: + datasets.append(axis.getDataset()) + return datasets + + +class _Model(qt.QStandardItemModel): + """Model storing a list of custom NXdata items. + + Supports drag and drop of datasets. + """ + + sigNxdataUpdated = qt.Signal(qt.QModelIndex) + """Emitted when stored NXdata was edited""" + + def __init__(self, parent=None): + """Constructor""" + qt.QStandardItemModel.__init__(self, parent) + root = self.invisibleRootItem() + root.setDropEnabled(True) + root.setDragEnabled(False) + + def supportedDropActions(self): + """Inherited method to redefine supported drop actions.""" + return qt.Qt.CopyAction | qt.Qt.MoveAction + + def mimeTypes(self): + """Inherited method to redefine draggable mime types.""" + return [Hdf5DatasetMimeData.MIME_TYPE] + + def mimeData(self, indexes): + """ + Returns an object that contains serialized items of data corresponding + to the list of indexes specified. + + :param List[qt.QModelIndex] indexes: List of indexes + :rtype: qt.QMimeData + """ + if len(indexes) > 1: + return None + if len(indexes) == 0: + return None + + qindex = indexes[0] + qindex = self.index(qindex.row(), 0, parent=qindex.parent()) + item = self.itemFromIndex(qindex) + if isinstance(item, _DatasetItemRow): + dataset = item.getDataset() + if dataset is None: + return None + else: + mimeData = Hdf5DatasetMimeData(dataset=item.getDataset()) + else: + mimeData = None + return mimeData + + def dropMimeData(self, mimedata, action, row, column, parentIndex): + """Inherited method to handle a drop operation to this model.""" + if action == qt.Qt.IgnoreAction: + return True + + if mimedata.hasFormat(Hdf5DatasetMimeData.MIME_TYPE): + if row != -1 or column != -1: + # It is not a drop on a specific item + return False + item = self.itemFromIndex(parentIndex) + if item is None or item is self.invisibleRootItem(): + # Drop at the end + dataset = mimedata.dataset() + if silx.io.is_dataset(dataset): + self.createFromSignal(dataset) + elif silx.io.is_group(dataset): + nxdata = dataset + try: + self.createFromNxdata(nxdata) + except ValueError: + _logger.error("Error while dropping a group as an NXdata") + _logger.debug("Backtrace", exc_info=True) + return False + else: + _logger.error("Dropping a wrong object") + return False + else: + item = item.parent().child(item.row(), 0) + if not isinstance(item, _DatasetItemRow): + # Dropped at a bad place + return False + dataset = mimedata.dataset() + if silx.io.is_dataset(dataset): + item.setDataset(dataset) + else: + _logger.error("Dropping a wrong object") + return False + return True + + return False + + def __getNxdataByTitle(self, title): + """Returns an NXdata item by its title, else None. + + :rtype: Union[_NxDataItem,None] + """ + for row in range(self.rowCount()): + qindex = self.index(row, 0) + item = self.itemFromIndex(qindex) + if item.getTitle() == title: + return item + return None + + def findFreeNxdataTitle(self): + """Returns an NXdata title which is not yet used. + + :rtype: str + """ + for i in range(self.rowCount() + 1): + name = "NXData #%d" % (i + 1) + group = self.__getNxdataByTitle(name) + if group is None: + break + return name + + def createNewNxdata(self, name=None): + """Create a new NXdata item. + + :param Union[str,None] name: A title for the new NXdata + """ + item = _NxDataItem() + if name is None: + name = self.findFreeNxdataTitle() + item.setTitle(name) + self.appendRow(item.getRowItems()) + + def createFromSignal(self, dataset): + """Create a new NXdata item from a signal dataset. + + This signal will also define an amount of axes according to its number + of dimensions. + + :param Union[numpy.ndarray,h5py.Dataset,silx.io.commonh5.Dataset] dataset: + A dataset uses as signal. + """ + + item = _NxDataItem() + name = self.findFreeNxdataTitle() + item.setTitle(name) + item.setSignalDataset(dataset) + item.setAxesDatasets([None] * len(dataset.shape)) + self.appendRow(item.getRowItems()) + + def createFromNxdata(self, nxdata): + """Create a new custom NXdata item from an existing NXdata group. + + If the NXdata is not valid, nothing is created, and an exception is + returned. + + :param Union[h5py.Group,silx.io.commonh5.Group] nxdata: An h5py group + following the NXData specification. + :raise ValueError:If `nxdata` is not valid. + """ + validator = silx.io.nxdata.NXdata(nxdata) + if validator.is_valid: + item = _NxDataItem() + title = validator.title + if title in [None or ""]: + title = self.findFreeNxdataTitle() + item.setTitle(title) + item.setSignalDataset(validator.signal) + item.setAxesDatasets(validator.axes) + self.appendRow(item.getRowItems()) + else: + raise ValueError("Not a valid NXdata") + + def removeNxdataItem(self, item): + """Remove an NXdata item from this model. + + :param _NxDataItem item: An item + """ + if isinstance(item, _NxDataItem): + parent = item.parent() + assert(parent is None) + model = item.model() + model.removeRow(item.row()) + else: + _logger.error("Unexpected item") + + def appendAxisToNxdataItem(self, item): + """Append a new axes to this item (or the NXdata item own by this item). + + :param Union[_NxDataItem,qt.QStandardItem] item: An item + """ + if item is not None and not isinstance(item, _NxDataItem): + item = item.parent() + nxdataItem = item + if isinstance(item, _NxDataItem): + datasets = nxdataItem.getAxesDatasets() + datasets.append(None) + nxdataItem.setAxesDatasets(datasets) + else: + _logger.error("Unexpected item") + + def removeAxisItem(self, item): + """Remove an axis item from this model. + + :param _DatasetAxisItemRow item: An axis item + """ + if isinstance(item, _DatasetAxisItemRow): + axisId = item.getAxisId() + nxdataItem = item.parent() + datasets = nxdataItem.getAxesDatasets() + del datasets[axisId] + nxdataItem.setAxesDatasets(datasets) + else: + _logger.error("Unexpected item") + + +class CustomNxDataToolBar(qt.QToolBar): + """A specialised toolbar to manage custom NXdata model and items.""" + + def __init__(self, parent=None): + """Constructor""" + super(CustomNxDataToolBar, self).__init__(parent=parent) + self.__nxdataWidget = None + self.__initContent() + # Initialize action state + self.__currentSelectionChanged(qt.QModelIndex(), qt.QModelIndex()) + + def __initContent(self): + """Create all expected actions and set the content of this toolbar.""" + action = qt.QAction("Create a new custom NXdata", self) + action.setIcon(icons.getQIcon("nxdata-create")) + action.triggered.connect(self.__createNewNxdata) + self.addAction(action) + self.__addNxDataAction = action + + action = qt.QAction("Remove the selected NXdata", self) + action.setIcon(icons.getQIcon("nxdata-remove")) + action.triggered.connect(self.__removeSelectedNxdata) + self.addAction(action) + self.__removeNxDataAction = action + + self.addSeparator() + + action = qt.QAction("Create a new axis to the selected NXdata", self) + action.setIcon(icons.getQIcon("nxdata-axis-add")) + action.triggered.connect(self.__appendNewAxisToSelectedNxdata) + self.addAction(action) + self.__addNxDataAxisAction = action + + action = qt.QAction("Remove the selected NXdata axis", self) + action.setIcon(icons.getQIcon("nxdata-axis-remove")) + action.triggered.connect(self.__removeSelectedAxis) + self.addAction(action) + self.__removeNxDataAxisAction = action + + def __getSelectedItem(self): + """Get the selected item from the linked CustomNxdataWidget. + + :rtype: qt.QStandardItem + """ + selectionModel = self.__nxdataWidget.selectionModel() + index = selectionModel.currentIndex() + if not index.isValid(): + return + model = self.__nxdataWidget.model() + index = model.index(index.row(), 0, index.parent()) + item = model.itemFromIndex(index) + return item + + def __createNewNxdata(self): + """Create a new NXdata item to the linked CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + model.createNewNxdata() + + def __removeSelectedNxdata(self): + """Remove the NXdata item currently selected in the linked + CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + item = self.__getSelectedItem() + model.removeNxdataItem(item) + + def __appendNewAxisToSelectedNxdata(self): + """Append a new axis to the NXdata item currently selected in the + linked CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + item = self.__getSelectedItem() + model.appendAxisToNxdataItem(item) + + def __removeSelectedAxis(self): + """Remove the axis item currently selected in the linked + CustomNxdataWidget.""" + if self.__nxdataWidget is None: + return + model = self.__nxdataWidget.model() + item = self.__getSelectedItem() + model.removeAxisItem(item) + + def setCustomNxDataWidget(self, widget): + """Set the linked CustomNxdataWidget to this toolbar.""" + assert(isinstance(widget, CustomNxdataWidget)) + if self.__nxdataWidget is not None: + selectionModel = self.__nxdataWidget.selectionModel() + selectionModel.currentChanged.disconnect(self.__currentSelectionChanged) + self.__nxdataWidget = widget + if self.__nxdataWidget is not None: + selectionModel = self.__nxdataWidget.selectionModel() + selectionModel.currentChanged.connect(self.__currentSelectionChanged) + + def __currentSelectionChanged(self, current, previous): + """Update the actions according to the linked CustomNxdataWidget + item selection""" + if not current.isValid(): + item = None + else: + model = self.__nxdataWidget.model() + index = model.index(current.row(), 0, current.parent()) + 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)) + + +class _HashDropZones(qt.QStyledItemDelegate): + """Delegate item displaying a drop zone when the item do not contains + dataset.""" + + def __init__(self, parent=None): + """Constructor""" + super(_HashDropZones, self).__init__(parent) + pen = qt.QPen() + pen.setColor(qt.QColor("#D0D0D0")) + pen.setStyle(qt.Qt.DotLine) + pen.setWidth(2) + self.__dropPen = pen + + def paint(self, painter, option, index): + """ + Paint the item + + :param qt.QPainter painter: A painter + :param qt.QStyleOptionViewItem option: Options of the item to paint + :param qt.QModelIndex index: Index of the item to paint + """ + displayDropZone = False + if index.isValid(): + model = index.model() + rowIndex = model.index(index.row(), 0, index.parent()) + rowItem = model.itemFromIndex(rowIndex) + if isinstance(rowItem, _DatasetItemRow): + displayDropZone = rowItem.getDataset() is None + + if displayDropZone: + painter.save() + + # Draw background if selected + if option.state & qt.QStyle.State_Selected: + colorGroup = qt.QPalette.Inactive + if option.state & qt.QStyle.State_Active: + colorGroup = qt.QPalette.Active + if not option.state & qt.QStyle.State_Enabled: + colorGroup = qt.QPalette.Disabled + brush = option.palette.brush(colorGroup, qt.QPalette.Highlight) + painter.fillRect(option.rect, brush) + + painter.setPen(self.__dropPen) + painter.drawRect(option.rect.adjusted(3, 3, -3, -3)) + painter.restore() + else: + qt.QStyledItemDelegate.paint(self, painter, option, index) + + +class CustomNxdataWidget(qt.QTreeView): + """Widget providing a table displaying and allowing to custom virtual + NXdata.""" + + sigNxdataItemUpdated = qt.Signal(qt.QStandardItem) + """Emitted when the NXdata from an NXdata item was edited""" + + sigNxdataItemRemoved = qt.Signal(qt.QStandardItem) + """Emitted when an NXdata item was removed""" + + def __init__(self, parent=None): + """Constructor""" + qt.QTreeView.__init__(self, parent=None) + self.__model = _Model(self) + self.__model.setColumnCount(4) + self.__model.setHorizontalHeaderLabels(["Name", "Dataset", "Type", "Shape"]) + self.setModel(self.__model) + + self.setItemDelegateForColumn(1, _HashDropZones(self)) + + self.__model.sigNxdataUpdated.connect(self.__nxdataUpdate) + self.__model.rowsAboutToBeRemoved.connect(self.__rowsAboutToBeRemoved) + self.__model.rowsAboutToBeInserted.connect(self.__rowsAboutToBeInserted) + + header = self.header() + header.setSectionResizeMode(0, qt.QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, qt.QHeaderView.Stretch) + header.setSectionResizeMode(2, qt.QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, qt.QHeaderView.ResizeToContents) + + self.setSelectionMode(qt.QAbstractItemView.SingleSelection) + self.setDropIndicatorShown(True) + self.setDragDropOverwriteMode(True) + self.setDragEnabled(True) + self.viewport().setAcceptDrops(True) + + self.setContextMenuPolicy(qt.Qt.CustomContextMenu) + self.customContextMenuRequested[qt.QPoint].connect(self.__executeContextMenu) + + def __rowsAboutToBeInserted(self, parentIndex, start, end): + # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919 + # Uses of ResizeToContents looks to break nice update of cells with Qt5 + # This patch make the view blinking + self.repaint() + + def __rowsAboutToBeRemoved(self, parentIndex, start, end): + """Called when an item was removed from the model.""" + items = [] + model = self.model() + for index in range(start, end): + qindex = model.index(index, 0, parent=parentIndex) + item = self.__model.itemFromIndex(qindex) + if isinstance(item, _NxDataItem): + items.append(item) + for item in items: + self.sigNxdataItemRemoved.emit(item) + + # FIXME: workaround for https://github.com/silx-kit/silx/issues/1919 + # Uses of ResizeToContents looks to break nice update of cells with Qt5 + # This patch make the view blinking + self.repaint() + + def __nxdataUpdate(self, index): + """Called when a virtual NXdata was updated from the model.""" + model = self.model() + item = model.itemFromIndex(index) + self.sigNxdataItemUpdated.emit(item) + + def createDefaultContextMenu(self, index): + """Create a default context menu at this position. + + :param qt.QModelIndex index: Index of the item + """ + index = self.__model.index(index.row(), 0, parent=index.parent()) + item = self.__model.itemFromIndex(index) + + menu = qt.QMenu() + + weakself = weakref.proxy(self) + + if isinstance(item, _NxDataItem): + action = qt.QAction("Add a new axis", menu) + action.triggered.connect(lambda: weakself.model().appendAxisToNxdataItem(item)) + action.setIcon(icons.getQIcon("nxdata-axis-add")) + action.setIconVisibleInMenu(True) + menu.addAction(action) + menu.addSeparator() + action = qt.QAction("Remove this NXdata", menu) + action.triggered.connect(lambda: weakself.model().removeNxdataItem(item)) + action.setIcon(icons.getQIcon("remove")) + action.setIconVisibleInMenu(True) + menu.addAction(action) + else: + if isinstance(item, _DatasetItemRow): + if item.getDataset() is not None: + action = qt.QAction("Remove this dataset", menu) + action.triggered.connect(lambda: item.setDataset(None)) + menu.addAction(action) + + if isinstance(item, _DatasetAxisItemRow): + menu.addSeparator() + action = qt.QAction("Remove this axis", menu) + action.triggered.connect(lambda: weakself.model().removeAxisItem(item)) + action.setIcon(icons.getQIcon("remove")) + action.setIconVisibleInMenu(True) + menu.addAction(action) + + return menu + + def __executeContextMenu(self, point): + """Execute the context menu at this position.""" + index = self.indexAt(point) + menu = self.createDefaultContextMenu(index) + if menu is None or menu.isEmpty(): + return + menu.exec(qt.QCursor.pos()) + + def removeDatasetsFrom(self, root): + """ + Remove all datasets provided by this root + + :param root: The root file of datasets to remove + """ + for row in range(self.__model.rowCount()): + qindex = self.__model.index(row, 0) + item = self.model().itemFromIndex(qindex) + + edited = False + datasets = item.getAxesDatasets() + for i, dataset in enumerate(datasets): + if dataset is not None: + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if dataset.file.filename == root.file.filename: + datasets[i] = None + edited = True + if edited: + item.setAxesDatasets(datasets) + + dataset = item.getSignalDataset() + if dataset is not None: + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if dataset.file.filename == root.file.filename: + item.setSignalDataset(None) + + def replaceDatasetsFrom(self, removedRoot, loadedRoot): + """ + Replace any dataset from any NXdata items using the same dataset name + from another root. + + Usually used when a file was synchronized. + + :param removedRoot: The h5py root file which is replaced + (which have to be removed) + :param loadedRoot: The new h5py root file which have to be used + instread. + """ + for row in range(self.__model.rowCount()): + qindex = self.__model.index(row, 0) + item = self.model().itemFromIndex(qindex) + + edited = False + datasets = item.getAxesDatasets() + for i, dataset in enumerate(datasets): + newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot) + if dataset is not newDataset: + datasets[i] = newDataset + edited = True + if edited: + item.setAxesDatasets(datasets) + + dataset = item.getSignalDataset() + newDataset = self.__replaceDatasetRoot(dataset, removedRoot, loadedRoot) + if dataset is not newDataset: + item.setSignalDataset(newDataset) + + def __replaceDatasetRoot(self, dataset, fromRoot, toRoot): + """ + Replace the dataset by the same dataset name from another root. + """ + if dataset is None: + return None + + if dataset.file is None: + # Not from the expected root + return dataset + + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if dataset.file.filename == fromRoot.file.filename: + # Try to find the same dataset name + try: + return toRoot[dataset.name] + except Exception: + _logger.debug("Backtrace", exc_info=True) + return None + else: + # Not from the expected root + return dataset + + def selectedItems(self): + """Returns the list of selected items containing NXdata + + :rtype: List[qt.QStandardItem] + """ + result = [] + for qindex in self.selectedIndexes(): + if qindex.column() != 0: + continue + if not qindex.isValid(): + continue + item = self.__model.itemFromIndex(qindex) + if not isinstance(item, _NxDataItem): + continue + result.append(item) + return result + + def selectedNxdata(self): + """Returns the list of selected NXdata + + :rtype: List[silx.io.commonh5.Group] + """ + result = [] + for qindex in self.selectedIndexes(): + if qindex.column() != 0: + continue + if not qindex.isValid(): + continue + item = self.__model.itemFromIndex(qindex) + if not isinstance(item, _NxDataItem): + continue + result.append(item.getVirtualGroup()) + return result diff --git a/src/silx/app/view/DataPanel.py b/src/silx/app/view/DataPanel.py new file mode 100644 index 0000000..5d87381 --- /dev/null +++ b/src/silx/app/view/DataPanel.py @@ -0,0 +1,192 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "12/10/2018" + +import logging +import os.path + +from silx.gui import qt +from silx.gui.data.DataViewerFrame import DataViewerFrame + + +_logger = logging.getLogger(__name__) + + +class _HeaderLabel(qt.QLabel): + + def __init__(self, parent=None): + qt.QLabel.__init__(self, parent=parent) + self.setFrameShape(qt.QFrame.StyledPanel) + + def sizeHint(self): + return qt.QSize(10, 30) + + def minimumSizeHint(self): + return qt.QSize(10, 30) + + def setData(self, filename, path): + if filename == "" and path == "": + text = "" + elif filename == "": + text = path + else: + text = "%s::%s" % (filename, path) + self.setText(text) + tooltip = "" + template = "<li><b>%s</b>: %s</li>" + tooltip += template % ("Directory", os.path.dirname(filename)) + tooltip += template % ("File name", os.path.basename(filename)) + tooltip += template % ("Data path", path) + tooltip = "<ul>%s</ul>" % tooltip + tooltip = "<html>%s</html>" % tooltip + self.setToolTip(tooltip) + + def paintEvent(self, event): + painter = qt.QPainter(self) + + opt = qt.QStyleOptionHeader() + opt.orientation = qt.Qt.Horizontal + opt.text = self.text() + opt.textAlignment = self.alignment() + opt.direction = self.layoutDirection() + opt.fontMetrics = self.fontMetrics() + opt.palette = self.palette() + opt.state = qt.QStyle.State_Active + opt.position = qt.QStyleOptionHeader.Beginning + style = self.style() + + # Background + margin = -1 + opt.rect = self.rect().adjusted(margin, margin, -margin, -margin) + style.drawControl(qt.QStyle.CE_HeaderSection, opt, painter, None) + + # Frame border and text + super(_HeaderLabel, self).paintEvent(event) + + +class DataPanel(qt.QWidget): + + def __init__(self, parent=None, context=None): + qt.QWidget.__init__(self, parent=parent) + + self.__customNxdataItem = None + + self.__dataTitle = _HeaderLabel(self) + self.__dataTitle.setVisible(False) + + self.__dataViewer = DataViewerFrame(self) + self.__dataViewer.setGlobalHooks(context) + + layout = qt.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.__dataTitle) + layout.addWidget(self.__dataViewer) + + def getData(self): + return self.__dataViewer.data() + + def getCustomNxdataItem(self): + return self.__customNxdataItem + + def setData(self, data): + self.__customNxdataItem = None + self.__dataViewer.setData(data) + self.__dataTitle.setVisible(data is not None) + if data is not None: + self.__dataTitle.setVisible(True) + if hasattr(data, "name"): + if hasattr(data, "file"): + filename = str(data.file.filename) + else: + filename = "" + path = data.name + else: + filename = "" + path = "" + self.__dataTitle.setData(filename, path) + + def setCustomDataItem(self, item): + self.__customNxdataItem = item + if item is not None: + data = item.getVirtualGroup() + else: + data = None + self.__dataViewer.setData(data) + self.__dataTitle.setVisible(item is not None) + if item is not None: + text = item.text() + self.__dataTitle.setText(text) + + def removeDatasetsFrom(self, root): + """ + Remove all datasets provided by this root + + .. note:: This function do not update data stored inside + customNxdataItem cause in the silx-view context this item is + already updated on his own. + + :param root: The root file of datasets to remove + """ + data = self.__dataViewer.data() + if data is not None: + if data.file is not None: + # That's an approximation, IS can't be used as h5py generates + # To objects for each requests to a node + if data.file.filename == root.file.filename: + self.__dataViewer.setData(None) + + def replaceDatasetsFrom(self, removedH5, loadedH5): + """ + Replace any dataset from any NXdata items using the same dataset name + from another root. + + Usually used when a file was synchronized. + + .. note:: This function do not update data stored inside + customNxdataItem cause in the silx-view context this item is + already updated on his own. + + :param removedRoot: The h5py root file which is replaced + (which have to be removed) + :param loadedRoot: The new h5py root file which have to be used + instread. + """ + + data = self.__dataViewer.data() + if data is not None: + if data.file is not None: + if data.file.filename == removedH5.file.filename: + # Try to synchonize the viewed data + try: + # TODO: It have to update the data without changing the + # view which is not so easy + newData = loadedH5[data.name] + self.__dataViewer.setData(newData) + except Exception: + _logger.debug("Backtrace", exc_info=True) diff --git a/src/silx/app/view/Viewer.py b/src/silx/app/view/Viewer.py new file mode 100644 index 0000000..7e5e4c9 --- /dev/null +++ b/src/silx/app/view/Viewer.py @@ -0,0 +1,962 @@ +# coding: utf-8 +# /*########################################################################## +# 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "15/01/2019" + + +import os +import collections +import logging +import functools + +import silx.io.nxdata +from silx.gui import qt +from silx.gui import icons +import silx.gui.hdf5 +from .ApplicationContext import ApplicationContext +from .CustomNxdataWidget import CustomNxdataWidget +from .CustomNxdataWidget import CustomNxDataToolBar +from . import utils +from silx.gui.utils import projecturl +from .DataPanel import DataPanel + + +_logger = logging.getLogger(__name__) + + +class Viewer(qt.QMainWindow): + """ + This window allows to browse a data file like images or HDF5 and it's + content. + """ + + def __init__(self, parent=None, settings=None): + """ + Constructor + """ + + qt.QMainWindow.__init__(self, parent) + self.setWindowTitle("Silx viewer") + + silxIcon = icons.getQIcon("silx") + self.setWindowIcon(silxIcon) + + self.__context = self.createApplicationContext(settings) + self.__context.restoreLibrarySettings() + + self.__dialogState = None + self.__customNxDataItem = None + self.__treeview = silx.gui.hdf5.Hdf5TreeView(self) + self.__treeview.setExpandsOnDoubleClick(False) + """Silx HDF5 TreeView""" + + rightPanel = qt.QSplitter(self) + rightPanel.setOrientation(qt.Qt.Vertical) + self.__splitter2 = rightPanel + + self.__displayIt = None + self.__treeWindow = self.__createTreeWindow(self.__treeview) + + # Custom the model to be able to manage the life cycle of the files + treeModel = silx.gui.hdf5.Hdf5TreeModel(self.__treeview, ownFiles=False) + treeModel.sigH5pyObjectLoaded.connect(self.__h5FileLoaded) + treeModel.sigH5pyObjectRemoved.connect(self.__h5FileRemoved) + treeModel.sigH5pyObjectSynchronized.connect(self.__h5FileSynchonized) + treeModel.setDatasetDragEnabled(True) + 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) + + self.__treeview.setModel(self.__treeModelSorted) + rightPanel.addWidget(self.__treeWindow) + + self.__customNxdata = CustomNxdataWidget(self) + self.__customNxdata.setSelectionBehavior(qt.QAbstractItemView.SelectRows) + # optimise the rendering + self.__customNxdata.setUniformRowHeights(True) + self.__customNxdata.setIconSize(qt.QSize(16, 16)) + self.__customNxdata.setExpandsOnDoubleClick(False) + + self.__customNxdataWindow = self.__createCustomNxdataWindow(self.__customNxdata) + self.__customNxdataWindow.setVisible(False) + rightPanel.addWidget(self.__customNxdataWindow) + + rightPanel.setStretchFactor(1, 1) + rightPanel.setCollapsible(0, False) + rightPanel.setCollapsible(1, False) + + self.__dataPanel = DataPanel(self, self.__context) + + spliter = qt.QSplitter(self) + spliter.addWidget(rightPanel) + spliter.addWidget(self.__dataPanel) + spliter.setStretchFactor(1, 1) + spliter.setCollapsible(0, False) + spliter.setCollapsible(1, False) + self.__splitter = spliter + + main_panel = qt.QWidget(self) + layout = qt.QVBoxLayout() + layout.addWidget(spliter) + layout.setStretchFactor(spliter, 1) + main_panel.setLayout(layout) + + self.setCentralWidget(main_panel) + + self.__treeview.activated.connect(self.displaySelectedData) + self.__customNxdata.activated.connect(self.displaySelectedCustomData) + self.__customNxdata.sigNxdataItemRemoved.connect(self.__customNxdataRemoved) + self.__customNxdata.sigNxdataItemUpdated.connect(self.__customNxdataUpdated) + self.__treeview.addContextMenuCallback(self.customContextMenu) + + treeModel = self.__treeview.findHdf5TreeModel() + columns = list(treeModel.COLUMN_IDS) + columns.remove(treeModel.VALUE_COLUMN) + columns.remove(treeModel.NODE_COLUMN) + columns.remove(treeModel.DESCRIPTION_COLUMN) + columns.insert(1, treeModel.DESCRIPTION_COLUMN) + self.__treeview.header().setSections(columns) + + self._iconUpward = icons.getQIcon('plot-yup') + self._iconDownward = icons.getQIcon('plot-ydown') + + self.createActions() + self.createMenus() + self.__context.restoreSettings() + + def createApplicationContext(self, settings): + return ApplicationContext(self, settings) + + def __createTreeWindow(self, treeView): + toolbar = qt.QToolBar(self) + toolbar.setIconSize(qt.QSize(16, 16)) + toolbar.setStyleSheet("QToolBar { border: 0px }") + + action = qt.QAction(toolbar) + action.setIcon(icons.getQIcon("view-refresh")) + action.setText("Refresh") + action.setToolTip("Refresh all selected items") + action.triggered.connect(self.__refreshSelected) + action.setShortcut(qt.QKeySequence(qt.Qt.Key_F5)) + toolbar.addAction(action) + treeView.addAction(action) + self.__refreshAction = action + + # Another shortcut for refresh + action = qt.QAction(toolbar) + action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + 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)) + treeView.addAction(action) + self.__closeAction = action + + toolbar.addSeparator() + + action = qt.QAction(toolbar) + action.setIcon(icons.getQIcon("tree-expand-all")) + action.setText("Expand all") + action.setToolTip("Expand all selected items") + action.triggered.connect(self.__expandAllSelected) + action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Plus)) + toolbar.addAction(action) + treeView.addAction(action) + self.__expandAllAction = action + + action = qt.QAction(toolbar) + action.setIcon(icons.getQIcon("tree-collapse-all")) + action.setText("Collapse all") + action.setToolTip("Collapse all selected items") + action.triggered.connect(self.__collapseAllSelected) + action.setShortcut(qt.QKeySequence(qt.Qt.ControlModifier + qt.Qt.Key_Minus)) + toolbar.addAction(action) + treeView.addAction(action) + self.__collapseAllAction = action + + action = qt.QAction("&Sort file content", toolbar) + action.setIcon(icons.getQIcon("tree-sort")) + action.setToolTip("Toggle sorting of file content") + action.setCheckable(True) + action.setChecked(True) + action.triggered.connect(self.setContentSorted) + toolbar.addAction(action) + treeView.addAction(action) + self._sortContentAction = action + + widget = qt.QWidget(self) + layout = qt.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(toolbar) + layout.addWidget(treeView) + return widget + + def __removeSelected(self): + """Close selected items""" + qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor) + + selection = self.__treeview.selectionModel() + indexes = selection.selectedIndexes() + selectedItems = [] + model = self.__treeview.model() + h5files = set([]) + while len(indexes) > 0: + index = indexes.pop(0) + if index.column() != 0: + continue + h5 = model.data(index, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + rootIndex = index + # Reach the root of the tree + while rootIndex.parent().isValid(): + rootIndex = rootIndex.parent() + rootRow = rootIndex.row() + relativePath = self.__getRelativePath(model, rootIndex, index) + selectedItems.append((rootRow, relativePath)) + h5files.add(h5.file) + + if len(h5files) != 0: + model = self.__treeview.findHdf5TreeModel() + for h5 in h5files: + row = model.h5pyObjectRow(h5) + model.removeH5pyObject(h5) + + qt.QApplication.restoreOverrideCursor() + + def __refreshSelected(self): + """Refresh all selected items + """ + qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor) + + selection = self.__treeview.selectionModel() + indexes = selection.selectedIndexes() + selectedItems = [] + model = self.__treeview.model() + h5files = set([]) + while len(indexes) > 0: + index = indexes.pop(0) + if index.column() != 0: + continue + h5 = model.data(index, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE) + rootIndex = index + # Reach the root of the tree + while rootIndex.parent().isValid(): + rootIndex = rootIndex.parent() + rootRow = rootIndex.row() + relativePath = self.__getRelativePath(model, rootIndex, index) + selectedItems.append((rootRow, relativePath)) + h5files.add(h5.file) + + if len(h5files) == 0: + qt.QApplication.restoreOverrideCursor() + return + + model = self.__treeview.findHdf5TreeModel() + for h5 in h5files: + self.__synchronizeH5pyObject(h5) + + model = self.__treeview.model() + itemSelection = qt.QItemSelection() + for rootRow, relativePath in selectedItems: + rootIndex = model.index(rootRow, 0, qt.QModelIndex()) + index = self.__indexFromPath(model, rootIndex, relativePath) + if index is None: + continue + indexEnd = model.index(index.row(), model.columnCount() - 1, index.parent()) + itemSelection.select(index, indexEnd) + selection.select(itemSelection, qt.QItemSelectionModel.ClearAndSelect) + + qt.QApplication.restoreOverrideCursor() + + def __synchronizeH5pyObject(self, h5): + model = self.__treeview.findHdf5TreeModel() + # This is buggy right now while h5py do not allow to close a file + # while references are still used. + # FIXME: The architecture have to be reworked to support this feature. + # model.synchronizeH5pyObject(h5) + + filename = h5.filename + row = model.h5pyObjectRow(h5) + index = self.__treeview.model().index(row, 0, qt.QModelIndex()) + paths = self.__getPathFromExpandedNodes(self.__treeview, index) + model.removeH5pyObject(h5) + model.insertFile(filename, row) + index = self.__treeview.model().index(row, 0, qt.QModelIndex()) + self.__expandNodesFromPaths(self.__treeview, index, paths) + + def __getRelativePath(self, model, rootIndex, index): + """Returns a relative path from an index to his rootIndex. + + If the path is empty the index is also the rootIndex. + """ + path = "" + while index.isValid(): + if index == rootIndex: + return path + name = model.data(index) + if path == "": + path = name + else: + path = name + "/" + path + index = index.parent() + + # index is not a children of rootIndex + raise ValueError("index is not a children of the rootIndex") + + def __getPathFromExpandedNodes(self, view, rootIndex): + """Return relative path from the root index of the extended nodes""" + model = view.model() + rootPath = None + paths = [] + indexes = [rootIndex] + while len(indexes): + index = indexes.pop(0) + if not view.isExpanded(index): + continue + + node = model.data(index, role=silx.gui.hdf5.Hdf5TreeModel.H5PY_ITEM_ROLE) + path = node._getCanonicalName() + if rootPath is None: + rootPath = path + path = path[len(rootPath):] + paths.append(path) + + for child in range(model.rowCount(index)): + childIndex = model.index(child, 0, index) + indexes.append(childIndex) + return paths + + def __indexFromPath(self, model, rootIndex, path): + elements = path.split("/") + if elements[0] == "": + elements.pop(0) + index = rootIndex + while len(elements) != 0: + element = elements.pop(0) + found = False + for child in range(model.rowCount(index)): + childIndex = model.index(child, 0, index) + name = model.data(childIndex) + if element == name: + index = childIndex + found = True + break + if not found: + return None + return index + + def __expandNodesFromPaths(self, view, rootIndex, paths): + model = view.model() + for path in paths: + index = self.__indexFromPath(model, rootIndex, path) + if index is not None: + view.setExpanded(index, True) + + def __expandAllSelected(self): + """Expand all selected items of the tree. + + The depth is fixed to avoid infinite loop with recurssive links. + """ + qt.QApplication.setOverrideCursor(qt.Qt.WaitCursor) + + selection = self.__treeview.selectionModel() + indexes = selection.selectedIndexes() + model = self.__treeview.model() + while len(indexes) > 0: + index = indexes.pop(0) + if isinstance(index, tuple): + index, depth = index + else: + depth = 0 + if index.column() != 0: + continue + + if depth > 10: + # Avoid infinite loop with recursive links + break + + if model.hasChildren(index): + self.__treeview.setExpanded(index, True) + for row in range(model.rowCount(index)): + childIndex = model.index(row, 0, index) + indexes.append((childIndex, depth + 1)) + qt.QApplication.restoreOverrideCursor() + + def __collapseAllSelected(self): + """Collapse all selected items of the tree. + + The depth is fixed to avoid infinite loop with recurssive links. + """ + selection = self.__treeview.selectionModel() + indexes = selection.selectedIndexes() + model = self.__treeview.model() + while len(indexes) > 0: + index = indexes.pop(0) + if isinstance(index, tuple): + index, depth = index + else: + depth = 0 + if index.column() != 0: + continue + + if depth > 10: + # Avoid infinite loop with recursive links + break + + if model.hasChildren(index): + self.__treeview.setExpanded(index, False) + for row in range(model.rowCount(index)): + childIndex = model.index(row, 0, index) + indexes.append((childIndex, depth + 1)) + + def __createCustomNxdataWindow(self, customNxdataWidget): + toolbar = CustomNxDataToolBar(self) + toolbar.setCustomNxDataWidget(customNxdataWidget) + toolbar.setIconSize(qt.QSize(16, 16)) + toolbar.setStyleSheet("QToolBar { border: 0px }") + + widget = qt.QWidget(self) + layout = qt.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(toolbar) + layout.addWidget(customNxdataWidget) + return widget + + def __h5FileLoaded(self, loadedH5): + self.__context.pushRecentFile(loadedH5.file.filename) + if loadedH5.file.filename == self.__displayIt: + self.__displayIt = None + self.displayData(loadedH5) + + def __h5FileRemoved(self, removedH5): + self.__dataPanel.removeDatasetsFrom(removedH5) + self.__customNxdata.removeDatasetsFrom(removedH5) + removedH5.close() + + def __h5FileSynchonized(self, removedH5, loadedH5): + self.__dataPanel.replaceDatasetsFrom(removedH5, loadedH5) + self.__customNxdata.replaceDatasetsFrom(removedH5, loadedH5) + removedH5.close() + + def closeEvent(self, event): + self.__context.saveSettings() + + # Clean up as much as possible Python objects + self.displayData(None) + customModel = self.__customNxdata.model() + customModel.clear() + hdf5Model = self.__treeview.findHdf5TreeModel() + hdf5Model.clear() + + 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("mainwindow") + settings.setValue("size", self.size()) + settings.setValue("pos", self.pos()) + settings.setValue("full-screen", isFullScreen) + settings.endGroup() + + settings.beginGroup("mainlayout") + settings.setValue("spliter", self.__splitter.sizes()) + settings.setValue("spliter2", self.__splitter2.sizes()) + isVisible = self.__customNxdataWindow.isVisible() + settings.setValue("custom-nxdata-window-visible", isVisible) + settings.endGroup() + + settings.beginGroup("content") + isSorted = self._sortContentAction.isChecked() + settings.setValue("is-sorted", isSorted) + 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("mainwindow") + 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 + settings.endGroup() + + settings.beginGroup("mainlayout") + try: + data = settings.value("spliter") + data = [int(d) for d in data] + self.__splitter.setSizes(data) + except Exception: + _logger.debug("Backtrace", exc_info=True) + try: + data = settings.value("spliter2") + data = [int(d) for d in data] + self.__splitter2.setSizes(data) + 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 + 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 + self.setContentSorted(isSorted) + settings.endGroup() + + if not pos.isNull(): + self.move(pos) + if not size.isNull(): + self.resize(size) + if isFullScreen: + self.showFullScreen() + + def createActions(self): + action = qt.QAction("E&xit", self) + action.setShortcuts(qt.QKeySequence.Quit) + action.setStatusTip("Exit the application") + action.triggered.connect(self.close) + self._exitAction = action + + action = qt.QAction("&Open...", self) + action.setStatusTip("Open a file") + action.triggered.connect(self.open) + self._openAction = action + + menu = qt.QMenu("Open Recent", self) + menu.setStatusTip("Open a recently opened file") + self._openRecentMenu = menu + + action = qt.QAction("Close All", self) + action.setStatusTip("Close all opened files") + action.triggered.connect(self.closeAll) + self._closeAllAction = action + + action = qt.QAction("&About", self) + action.setStatusTip("Show the application's About box") + action.triggered.connect(self.about) + self._aboutAction = action + + action = qt.QAction("&Documentation", self) + action.setStatusTip("Show the Silx library's documentation") + action.triggered.connect(self.showDocumentation) + self._documentationAction = action + + # Plot backend + + self._plotBackendMenu = qt.QMenu("Plot rendering backend", self) + self._plotBackendMenu.setStatusTip("Select plot rendering backend") + + group = qt.QActionGroup(self) + group.setExclusive(True) + + action = qt.QAction("matplotlib", self) + action.setStatusTip("Plot will be rendered using matplotlib") + action.setCheckable(True) + action.triggered.connect(self.__forceMatplotlibBackend) + group.addAction(action) + self._plotBackendMenu.addAction(action) + self._usePlotWithMatplotlib = action + + action = qt.QAction("OpenGL", self) + action.setStatusTip("Plot will be rendered using OpenGL") + action.setCheckable(True) + action.triggered.connect(self.__forceOpenglBackend) + group.addAction(action) + self._plotBackendMenu.addAction(action) + self._usePlotWithOpengl = action + + # Plot image orientation + + self._plotImageOrientationMenu = qt.QMenu( + "Default plot image y-axis orientation", self) + self._plotImageOrientationMenu.setStatusTip( + "Select the default y-axis orientation used by plot displaying images") + + group = qt.QActionGroup(self) + group.setExclusive(True) + + action = qt.QAction("Downward, origin on top", self) + action.setIcon(self._iconDownward) + action.setStatusTip("Plot images will use a downward Y-axis orientation") + action.setCheckable(True) + action.triggered.connect(self.__forcePlotImageDownward) + group.addAction(action) + self._plotImageOrientationMenu.addAction(action) + self._useYAxisOrientationDownward = action + + action = qt.QAction("Upward, origin on bottom", self) + action.setIcon(self._iconUpward) + action.setStatusTip("Plot images will use a upward Y-axis orientation") + action.setCheckable(True) + action.triggered.connect(self.__forcePlotImageUpward) + group.addAction(action) + self._plotImageOrientationMenu.addAction(action) + self._useYAxisOrientationUpward = 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.setCheckable(True) + action.setShortcut(qt.QKeySequence(qt.Qt.Key_F6)) + action.toggled.connect(self.__toggleCustomNxdataWindow) + self._displayCustomNxdataWindow = action + + def __toggleCustomNxdataWindow(self): + isVisible = self._displayCustomNxdataWindow.isChecked() + self.__customNxdataWindow.setVisible(isVisible) + + def __updateFileMenu(self): + files = self.__context.getRecentFiles() + self._openRecentMenu.clear() + self._openRecentMenu.setEnabled(len(files) != 0) + if len(files) != 0: + for filePath in files: + baseName = os.path.basename(filePath) + action = qt.QAction(baseName, self) + action.setToolTip(filePath) + action.triggered.connect(functools.partial(self.__openRecentFile, filePath)) + self._openRecentMenu.addAction(action) + self._openRecentMenu.addSeparator() + baseName = os.path.basename(filePath) + action = qt.QAction("Clear history", self) + action.setToolTip("Clear the history of the recent files") + action.triggered.connect(self.__clearRecentFile) + self._openRecentMenu.addAction(action) + + def __clearRecentFile(self): + self.__context.clearRencentFiles() + + def __openRecentFile(self, filePath): + self.appendFile(filePath) + + def __updateOptionMenu(self): + """Update the state of the checked options as it is based on global + environment values.""" + + # plot backend + + title = self._plotBackendMenu.title().split(": ", 1)[0] + self._plotBackendMenu.setTitle("%s: %s" % (title, silx.config.DEFAULT_PLOT_BACKEND)) + + action = self._usePlotWithMatplotlib + action.setChecked(silx.config.DEFAULT_PLOT_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"]) + title = action.text().split(" (", 1)[0] + if not action.isChecked(): + title += " (applied after application restart)" + action.setText(title) + + # plot orientation + + menu = self._plotImageOrientationMenu + if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == "downward": + menu.setIcon(self._iconDownward) + else: + menu.setIcon(self._iconUpward) + + action = self._useYAxisOrientationDownward + 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") + title = action.text().split(" (", 1)[0] + if not action.isChecked(): + title += " (applied after application restart)" + action.setText(title) + + def createMenus(self): + fileMenu = self.menuBar().addMenu("&File") + fileMenu.addAction(self._openAction) + fileMenu.addMenu(self._openRecentMenu) + fileMenu.addAction(self._closeAllAction) + fileMenu.addSeparator() + fileMenu.addAction(self._exitAction) + fileMenu.aboutToShow.connect(self.__updateFileMenu) + + optionMenu = self.menuBar().addMenu("&Options") + optionMenu.addMenu(self._plotImageOrientationMenu) + optionMenu.addMenu(self._plotBackendMenu) + optionMenu.aboutToShow.connect(self.__updateOptionMenu) + + viewMenu = self.menuBar().addMenu("&Views") + viewMenu.addAction(self._displayCustomNxdataWindow) + + helpMenu = self.menuBar().addMenu("&Help") + helpMenu.addAction(self._aboutAction) + helpMenu.addAction(self._documentationAction) + + def open(self): + dialog = self.createFileDialog() + if self.__dialogState is None: + currentDirectory = os.getcwd() + dialog.setDirectory(currentDirectory) + else: + dialog.restoreState(self.__dialogState) + + result = dialog.exec() + if not result: + return + + self.__dialogState = dialog.saveState() + + filenames = dialog.selectedFiles() + for filename in filenames: + self.appendFile(filename) + + def closeAll(self): + """Close all currently opened files""" + model = self.__treeview.findHdf5TreeModel() + model.clear() + + def createFileDialog(self): + dialog = qt.QFileDialog(self) + dialog.setWindowTitle("Open") + dialog.setModal(True) + + # NOTE: hdf5plugin have to be loaded before + extensions = collections.OrderedDict() + for description, ext in silx.io.supported_extensions().items(): + extensions[description] = " ".join(sorted(list(ext))) + + # Add extensions supported by fabio + extensions["NeXus layout from EDF files"] = "*.edf" + extensions["NeXus layout from TIFF image files"] = "*.tif *.tiff" + extensions["NeXus layout from CBF files"] = "*.cbf" + extensions["NeXus layout from MarCCD image files"] = "*.mccd" + + all_supported_extensions = set() + for name, exts in extensions.items(): + exts = exts.split(" ") + all_supported_extensions.update(exts) + all_supported_extensions = sorted(list(all_supported_extensions)) + + filters = [] + filters.append("All supported files (%s)" % " ".join(all_supported_extensions)) + for name, extension in extensions.items(): + filters.append("%s (%s)" % (name, extension)) + filters.append("All files (*)") + + dialog.setNameFilters(filters) + dialog.setFileMode(qt.QFileDialog.ExistingFiles) + return dialog + + def about(self): + from .About import About + About.about(self, "Silx viewer") + + def showDocumentation(self): + subpath = "index.html" + url = projecturl.getDocumentationUrl(subpath) + qt.QDesktopServices.openUrl(qt.QUrl(url)) + + def setContentSorted(self, sort): + """Set whether file content should be sorted or not. + + :param bool sort: + """ + sort = bool(sort) + if sort != self.isContentSorted(): + + # save expanded nodes + pathss = [] + root = qt.QModelIndex() + model = self.__treeview.model() + for i in range(model.rowCount(root)): + index = model.index(i, 0, root) + paths = self.__getPathFromExpandedNodes(self.__treeview, index) + pathss.append(paths) + + self.__treeview.setModel( + self.__treeModelSorted if sort else self.__treeModelSorted.sourceModel()) + self._sortContentAction.setChecked(self.isContentSorted()) + + # restore expanded nodes + model = self.__treeview.model() + for i in range(model.rowCount(root)): + index = model.index(i, 0, root) + paths = pathss.pop(0) + self.__expandNodesFromPaths(self.__treeview, index, paths) + + def isContentSorted(self): + """Returns whether the file content is sorted or not. + + :rtype: bool + """ + return self.__treeview.model() is self.__treeModelSorted + + def __forcePlotImageDownward(self): + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "downward" + + def __forcePlotImageUpward(self): + silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION = "upward" + + def __forceMatplotlibBackend(self): + silx.config.DEFAULT_PLOT_BACKEND = "matplotlib" + + def __forceOpenglBackend(self): + silx.config.DEFAULT_PLOT_BACKEND = "opengl" + + def appendFile(self, filename): + if self.__displayIt is None: + # Store the file to display it (loading could be async) + self.__displayIt = filename + self.__treeview.findHdf5TreeModel().appendFile(filename) + + def displaySelectedData(self): + """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 + data = selected[0] + self.__dataPanel.setData(data) + else: + _logger.debug("Too many data selected") + + def displayData(self, data): + """Called to update the dataviewer with a secific data. + """ + self.__dataPanel.setData(data) + + def displaySelectedCustomData(self): + selected = list(self.__customNxdata.selectedItems()) + if len(selected) == 1: + # Update the viewer for a single selection + item = selected[0] + self.__dataPanel.setCustomDataItem(item) + else: + _logger.debug("Too many items selected") + + def __customNxdataRemoved(self, item): + if self.__dataPanel.getCustomNxdataItem() is item: + self.__dataPanel.setCustomDataItem(None) + + def __customNxdataUpdated(self, item): + if self.__dataPanel.getCustomNxdataItem() is item: + self.__dataPanel.setCustomDataItem(item) + + def __makeSureCustomNxDataWindowIsVisible(self): + if not self.__customNxdataWindow.isVisible(): + self.__customNxdataWindow.setVisible(True) + self._displayCustomNxdataWindow.setChecked(True) + + def useAsNewCustomSignal(self, h5dataset): + self.__makeSureCustomNxDataWindowIsVisible() + model = self.__customNxdata.model() + model.createFromSignal(h5dataset) + + def useAsNewCustomNxdata(self, h5nxdata): + self.__makeSureCustomNxDataWindowIsVisible() + model = self.__customNxdata.model() + model.createFromNxdata(h5nxdata) + + def customContextMenu(self, event): + """Called to populate the context menu + + :param silx.gui.hdf5.Hdf5ContextMenuEvent event: Event + containing expected information to populate the context menu + """ + selectedObjects = event.source().selectedH5Nodes(ignoreBrokenLinks=False) + menu = event.menu() + + if not menu.isEmpty(): + menu.addSeparator() + + for obj in selectedObjects: + h5 = obj.h5py_object + + name = obj.name + if name.startswith("/"): + name = name[1:] + if name == "": + name = "the root" + + action = qt.QAction("Show %s" % name, event.source()) + action.triggered.connect(lambda: self.displayData(h5)) + menu.addAction(action) + + if silx.io.is_dataset(h5): + action = qt.QAction("Use as a new custom signal", event.source()) + action.triggered.connect(lambda: self.useAsNewCustomSignal(h5)) + menu.addAction(action) + + if silx.io.is_group(h5) and silx.io.nxdata.is_valid_nxdata(h5): + action = qt.QAction("Use as a new custom NXdata", event.source()) + action.triggered.connect(lambda: self.useAsNewCustomNxdata(h5)) + menu.addAction(action) + + 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)) + menu.addAction(action) + action = qt.QAction("Synchronize %s" % obj.local_filename, event.source()) + action.triggered.connect(lambda: self.__synchronizeH5pyObject(h5)) + menu.addAction(action) diff --git a/src/silx/app/view/__init__.py b/src/silx/app/view/__init__.py new file mode 100644 index 0000000..229c44e --- /dev/null +++ b/src/silx/app/view/__init__.py @@ -0,0 +1,28 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016-2018 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 source code of the `silx view` application""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" diff --git a/src/silx/app/view/main.py b/src/silx/app/view/main.py new file mode 100644 index 0000000..dbc6a2b --- /dev/null +++ b/src/silx/app/view/main.py @@ -0,0 +1,186 @@ +# coding: utf-8 +# /*########################################################################## +# 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. +# +# ############################################################################*/ +"""Module containing launcher of the `silx view` application""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "17/01/2019" + +import argparse +import logging +import os +import signal +import sys + + +_logger = logging.getLogger(__name__) +"""Module logger""" + + +def createParser(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + 'files', + nargs=argparse.ZERO_OR_MORE, + help='Data file to show (h5 file, edf files, spec files)') + 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)') + parser.add_argument( + '-f', '--fresh', + dest="fresh_preferences", + action="store_true", + default=False, + help='Start the application using new fresh user preferences') + parser.add_argument( + '--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)') + return parser + + +def createWindow(parent, settings): + from .Viewer import Viewer + window = Viewer(parent=None, settings=settings) + return window + + +def mainQt(options): + """Part of the main depending on Qt""" + if options.debug: + logging.root.setLevel(logging.DEBUG) + + # + # Import most of the things here to be sure to use the right logging level + # + + # Use max opened files hard limit as soft limit + try: + import resource + except ImportError: + _logger.debug("No resource module available") + else: + if hasattr(resource, 'RLIMIT_NOFILE'): + try: + hard_nofile = resource.getrlimit(resource.RLIMIT_NOFILE)[1] + resource.setrlimit(resource.RLIMIT_NOFILE, (hard_nofile, hard_nofile)) + except (ValueError, OSError): + _logger.warning("Failed to retrieve and set the max opened files limit") + else: + _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 + + try: + # it should be loaded before h5py + import hdf5plugin # noqa + except ImportError: + _logger.debug("Backtrace", exc_info=True) + + import h5py + + import silx + import silx.utils.files + 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 + + app = qt.QApplication([]) + qt.QLocale.setDefault(qt.QLocale.c()) + + def sigintHandler(*args): + """Handler for the SIGINT signal.""" + qt.QApplication.quit() + + signal.signal(signal.SIGINT, sigintHandler) + sys.excepthook = qt.exceptionHandler + + timer = qt.QTimer() + timer.start(500) + # Application have to wake up Python interpreter, else SIGINT is not + # catched + timer.timeout.connect(lambda: 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) + + if options.use_opengl_plot: + # It have to be done after the settings (after the Viewer creation) + silx.config.DEFAULT_PLOT_BACKEND = "opengl" + + # NOTE: under Windows, cmd does not convert `*.tif` into existing files + options.files = silx.utils.files.expand_filenames(options.files) + + for filename in options.files: + # TODO: Would be nice to add a process widget and a cancel button + try: + window.appendFile(filename) + except IOError as e: + _logger.error(e.args[0]) + _logger.debug("Backtrace", exc_info=True) + + window.show() + result = app.exec() + # remove ending warnings relative to QTimer + app.deleteLater() + return result + + +def main(argv): + """ + Main function to launch the viewer as an application + + :param argv: Command line arguments + :returns: exit status + """ + parser = createParser() + options = parser.parse_args(argv[1:]) + mainQt(options) + + +if __name__ == '__main__': + main(sys.argv) diff --git a/src/silx/app/view/setup.py b/src/silx/app/view/setup.py new file mode 100644 index 0000000..fa076cb --- /dev/null +++ b/src/silx/app/view/setup.py @@ -0,0 +1,40 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2016 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. +# +# ############################################################################*/ + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "06/06/2018" + +from numpy.distutils.misc_util import Configuration + + +def configuration(parent_package='', top_path=None): + config = Configuration('view', parent_package, top_path) + config.add_subpackage('test') + return config + + +if __name__ == "__main__": + from numpy.distutils.core import setup + setup(configuration=configuration) diff --git a/src/silx/app/view/test/__init__.py b/src/silx/app/view/test/__init__.py new file mode 100644 index 0000000..7790ee5 --- /dev/null +++ b/src/silx/app/view/test/__init__.py @@ -0,0 +1,24 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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/view/test/test_launcher.py b/src/silx/app/view/test/test_launcher.py new file mode 100644 index 0000000..4f7aaa5 --- /dev/null +++ b/src/silx/app/view/test/test_launcher.py @@ -0,0 +1,140 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2018" + + +import os +import shutil +import sys +import tempfile +import unittest +import logging +import subprocess +import pytest + +from .. import main +from silx import __main__ as silx_main + +_logger = logging.getLogger(__name__) + + +@pytest.mark.usefixtures("qapp") +class TestLauncher(unittest.TestCase): + """Test command line parsing""" + + def testHelp(self): + # option -h must cause a raise SystemExit or a return 0 + try: + parser = main.createParser() + parser.parse_args(["view", "--help"]) + result = 0 + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + def testWrongOption(self): + try: + parser = main.createParser() + parser.parse_args(["view", "--foo"]) + self.fail() + except SystemExit as e: + result = e.args[0] + self.assertNotEqual(result, 0) + + def testWrongFile(self): + try: + parser = main.createParser() + result = parser.parse_args(["view", "__file.not.found__"]) + result = 0 + except SystemExit as e: + result = e.args[0] + self.assertEqual(result, 0) + + def executeAsScript(self, filename, *args): + """Execute a command line. + + Log output as debug in case of bad return code. + """ + env = self.createTestEnv() + + with tempfile.TemporaryDirectory() as tmpdir: + # Copy file to temporary dir to avoid import from current dir. + 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) + 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.info("stdout:") + _logger.info("%s", out) + _logger.info("stderr:") + _logger.info("%s", err) + else: + _logger.debug("stdout:") + _logger.debug("%s", out) + _logger.debug("stderr:") + _logger.debug("%s", err) + self.assertEqual(p.returncode, 0) + + def createTestEnv(self): + """ + 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 + + def testExecuteViewHelp(self): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + self.executeAsScript(main.__file__, "--help") + + def testExecuteSilxViewHelp(self): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + self.executeAsScript(silx_main.__file__, "view", "--help") diff --git a/src/silx/app/view/test/test_view.py b/src/silx/app/view/test/test_view.py new file mode 100644 index 0000000..e236e42 --- /dev/null +++ b/src/silx/app/view/test/test_view.py @@ -0,0 +1,388 @@ +# coding: utf-8 +# /*########################################################################## +# +# 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/2018" + + +import weakref +import numpy +import h5py +import pytest + +from silx.gui import qt +from silx.app.view.Viewer import Viewer +from silx.app.view.About import About +from silx.app.view.DataPanel import DataPanel +from silx.app.view.CustomNxdataWidget import CustomNxdataWidget +from silx.gui.hdf5._utils import Hdf5DatasetMimeData +from silx.gui.utils.testutils import TestCaseQt +from silx.io import commonh5 + + +@pytest.fixture(scope="module") +def data_h5(tmpdir_factory): + filename = tmpdir_factory.mktemp("data").join("data.h5") + filename = str(filename) + f = h5py.File(filename, "w") + g = f.create_group("arrays") + g.create_dataset("scalar", data=10) + g.create_dataset("integers", data=numpy.array([10, 20, 30])) + f.close() + return filename + + +@pytest.fixture(scope="module") +def data2_h5(tmpdir_factory): + filename = tmpdir_factory.mktemp("data").join("data2.h5") + filename = str(filename) + f = h5py.File(filename, "w") + g = f.create_group("arrays") + g.create_dataset("scalar", data=20) + g.create_dataset("integers", data=numpy.array([10, 20, 30])) + f.close() + return filename + + +@pytest.fixture(scope="class") +def data_class_attr(request, data_h5, data2_h5): + """Provides test_options as class attribute + + Used as transition from TestCase to pytest + """ + request.cls.data_h5 = data_h5 + request.cls.data2_h5 = data2_h5 + + +@pytest.mark.usefixtures("qapp") +class TestViewer(TestCaseQt): + """Test for Viewer class""" + + def testConstruct(self): + widget = Viewer() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = Viewer() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + +@pytest.mark.usefixtures("qapp") +class TestAbout(TestCaseQt): + """Test for About box class""" + + def testConstruct(self): + widget = About() + self.qWaitForWindowExposed(widget) + + def testLicense(self): + widget = About() + widget.getHtmlLicense() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = About() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + +@pytest.mark.usefixtures("qapp") +@pytest.mark.usefixtures("data_class_attr") +class TestDataPanel(TestCaseQt): + + def testConstruct(self): + widget = DataPanel() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = DataPanel() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + def testHeaderLabelPaintEvent(self): + widget = DataPanel() + data = numpy.array([1, 2, 3, 4, 5]) + widget.setData(data) + # Expected to execute HeaderLabel.paintEvent + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testData(self): + widget = DataPanel() + data = numpy.array([1, 2, 3, 4, 5]) + widget.setData(data) + self.assertIs(widget.getData(), data) + self.assertIs(widget.getCustomNxdataItem(), None) + + def testDataNone(self): + widget = DataPanel() + widget.setData(None) + self.assertIs(widget.getData(), None) + self.assertIs(widget.getCustomNxdataItem(), None) + + def testCustomDataItem(self): + class CustomDataItemMock(object): + def getVirtualGroup(self): + return None + + def text(self): + return "" + + data = CustomDataItemMock() + widget = DataPanel() + widget.setCustomDataItem(data) + self.assertIs(widget.getData(), None) + self.assertIs(widget.getCustomNxdataItem(), data) + + def testCustomDataItemNone(self): + data = None + widget = DataPanel() + widget.setCustomDataItem(data) + self.assertIs(widget.getData(), None) + self.assertIs(widget.getCustomNxdataItem(), data) + + def testRemoveDatasetsFrom(self): + f = h5py.File(self.data_h5, mode='r') + try: + widget = DataPanel() + widget.setData(f["arrays/scalar"]) + widget.removeDatasetsFrom(f) + self.assertIs(widget.getData(), None) + finally: + widget.setData(None) + f.close() + + def testReplaceDatasetsFrom(self): + f = h5py.File(self.data_h5, mode='r') + f2 = h5py.File(self.data2_h5, mode='r') + try: + widget = DataPanel() + widget.setData(f["arrays/scalar"]) + self.assertEqual(widget.getData()[()], 10) + widget.replaceDatasetsFrom(f, f2) + self.assertEqual(widget.getData()[()], 20) + finally: + widget.setData(None) + f.close() + f2.close() + + +@pytest.mark.usefixtures("qapp") +@pytest.mark.usefixtures("data_class_attr") +class TestCustomNxdataWidget(TestCaseQt): + + def testConstruct(self): + widget = CustomNxdataWidget() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = CustomNxdataWidget() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) + + def testCreateNxdata(self): + widget = CustomNxdataWidget() + model = widget.model() + model.createNewNxdata() + model.createNewNxdata("Foo") + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testCreateNxdataFromDataset(self): + widget = CustomNxdataWidget() + model = widget.model() + signal = commonh5.Dataset("foo", data=numpy.array([[[5]]])) + model.createFromSignal(signal) + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testCreateNxdataFromNxdata(self): + widget = CustomNxdataWidget() + model = widget.model() + data = numpy.array([[[5]]]) + nxdata = commonh5.Group("foo") + nxdata.attrs["NX_class"] = "NXdata" + nxdata.attrs["signal"] = "signal" + nxdata.create_dataset("signal", data=data) + model.createFromNxdata(nxdata) + widget.setVisible(True) + self.qWaitForWindowExposed(widget) + + def testCreateBadNxdata(self): + widget = CustomNxdataWidget() + model = widget.model() + signal = commonh5.Dataset("foo", data=numpy.array([[[5]]])) + model.createFromSignal(signal) + axis = commonh5.Dataset("foo", data=numpy.array([[[5]]])) + nxdataIndex = model.index(0, 0) + item = model.itemFromIndex(nxdataIndex) + item.setAxesDatasets([axis]) + nxdata = item.getVirtualGroup() + self.assertIsNotNone(nxdata) + self.assertFalse(item.isValid()) + + def testRemoveDatasetsFrom(self): + f = h5py.File(self.data_h5, mode='r') + try: + widget = CustomNxdataWidget() + model = widget.model() + dataset = f["arrays/integers"] + model.createFromSignal(dataset) + widget.removeDatasetsFrom(f) + finally: + model.clear() + f.close() + + def testReplaceDatasetsFrom(self): + f = h5py.File(self.data_h5, mode='r') + f2 = h5py.File(self.data2_h5, mode='r') + try: + widget = CustomNxdataWidget() + model = widget.model() + dataset = f["arrays/integers"] + model.createFromSignal(dataset) + widget.replaceDatasetsFrom(f, f2) + finally: + model.clear() + f.close() + f2.close() + + +@pytest.mark.usefixtures("qapp") +class TestCustomNxdataWidgetInteraction(TestCaseQt): + """Test CustomNxdataWidget with user interaction""" + + def setUp(self): + TestCaseQt.setUp(self) + + self.widget = CustomNxdataWidget() + self.model = self.widget.model() + data = numpy.array([[[5]]]) + dataset = commonh5.Dataset("foo", data=data) + self.model.createFromSignal(dataset) + self.selectionModel = self.widget.selectionModel() + + def tearDown(self): + self.selectionModel = None + self.model.clear() + self.model = None + self.widget = None + TestCaseQt.tearDown(self) + + def testSelectedNxdata(self): + index = self.model.index(0, 0) + 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) + items = self.widget.selectedItems() + self.assertEqual(len(items), 1) + self.assertIsNot(items[0], None) + self.assertIsInstance(items[0], qt.QStandardItem) + + def testRowsAboutToBeRemoved(self): + self.model.removeRow(0) + self.qWaitForWindowExposed(self.widget) + + def testPaintItems(self): + self.widget.expandAll() + self.widget.setVisible(True) + self.qWaitForWindowExposed(self.widget) + + def testCreateDefaultContextMenu(self): + nxDataIndex = self.model.index(0, 0) + menu = self.widget.createDefaultContextMenu(nxDataIndex) + self.assertIsNot(menu, None) + self.assertIsInstance(menu, qt.QMenu) + + signalIndex = self.model.index(0, 0, nxDataIndex) + menu = self.widget.createDefaultContextMenu(signalIndex) + self.assertIsNot(menu, None) + self.assertIsInstance(menu, qt.QMenu) + + axesIndex = self.model.index(1, 0, nxDataIndex) + menu = self.widget.createDefaultContextMenu(axesIndex) + self.assertIsNot(menu, None) + self.assertIsInstance(menu, qt.QMenu) + + def testDropNewDataset(self): + dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4])) + mimedata = Hdf5DatasetMimeData(dataset=dataset) + self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex()) + self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2) + + def testDropNewNxdata(self): + data = numpy.array([[[5]]]) + nxdata = commonh5.Group("foo") + nxdata.attrs["NX_class"] = "NXdata" + nxdata.attrs["signal"] = "signal" + nxdata.create_dataset("signal", data=data) + mimedata = Hdf5DatasetMimeData(dataset=nxdata) + self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, qt.QModelIndex()) + self.assertEqual(self.model.rowCount(qt.QModelIndex()), 2) + + def testDropAxisDataset(self): + dataset = commonh5.Dataset("foo", numpy.array([1, 2, 3, 4])) + mimedata = Hdf5DatasetMimeData(dataset=dataset) + nxDataIndex = self.model.index(0, 0) + axesIndex = self.model.index(1, 0, nxDataIndex) + self.model.dropMimeData(mimedata, qt.Qt.CopyAction, -1, -1, axesIndex) + self.assertEqual(self.model.rowCount(qt.QModelIndex()), 1) + item = self.model.itemFromIndex(axesIndex) + self.assertIsNot(item.getDataset(), None) + + def testMimeData(self): + nxDataIndex = self.model.index(0, 0) + signalIndex = self.model.index(0, 0, nxDataIndex) + mimeData = self.model.mimeData([signalIndex]) + self.assertIsNot(mimeData, None) + self.assertIsInstance(mimeData, qt.QMimeData) + + def testRemoveNxdataItem(self): + nxdataIndex = self.model.index(0, 0) + item = self.model.itemFromIndex(nxdataIndex) + self.model.removeNxdataItem(item) + + def testAppendAxisToNxdataItem(self): + nxdataIndex = self.model.index(0, 0) + item = self.model.itemFromIndex(nxdataIndex) + self.model.appendAxisToNxdataItem(item) + + def testRemoveAxisItem(self): + nxdataIndex = self.model.index(0, 0) + axesIndex = self.model.index(1, 0, nxdataIndex) + item = self.model.itemFromIndex(axesIndex) + self.model.removeAxisItem(item) diff --git a/src/silx/app/view/utils.py b/src/silx/app/view/utils.py new file mode 100644 index 0000000..80167c8 --- /dev/null +++ b/src/silx/app/view/utils.py @@ -0,0 +1,45 @@ +# coding: utf-8 +# /*########################################################################## +# Copyright (C) 2018 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. +# +# ############################################################################*/ +"""Browse a data file with a GUI""" + +__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) |