Source code for sylib.library.plugins.data.table.importers.plugin_xlsx_importer

# This file is part of Sympathy for Data.
# Copyright (c) 2013, Combine Control Systems AB
#
# SYMPATHY FOR DATA COMMERCIAL LICENSE
# You should have received a link to the License with Sympathy for Data.
import contextlib
import datetime
import os

import numpy as np

from sympathy.api import node as synode
from sympathy.api import importers
from sympathy.api import qt2 as qt_compat
from sympathy.api import table
from sympathy.api.exceptions import sywarn, SyDataError
from sylib.xl_utils import is_xls, is_xlsx, get_xlsx_sheetnames, load_workbook
from sylib.table_sources import TableSourceModel, PreviewWorker
from sylib.table_importer_gui import (
    TableImportWidget, TableImportController)

QtCore = qt_compat.import_module('QtCore')
QtGui = qt_compat.import_module('QtGui')
QtWidgets = qt_compat.import_module('QtWidgets')


@contextlib.contextmanager
def open_workbook(filename):
    """Open workbook, on demand - if possible."""
    try:
        wb = load_workbook(filename, data_only=True)
    except Exception as exc:
        raise SyDataError(
            f"Could not open (possibly non-standard) Excel file: {filename}.\n"
            f"Open and Save using Excel may fix such problems."
        ) from exc
    else:
        try:
            yield wb
        finally:
            wb.close()


_read_to_end = 'Read to the end of file'
_read_selected = 'Read specified number of rows'
_read_from_end = 'Read to specified number of rows from the end'
_read_options = [_read_to_end, _read_selected, _read_from_end]


class TableSourceModelXLS(TableSourceModel):
    """Model layer between GUI and xlsx importer."""

    get_preview = qt_compat.Signal(
        str, bool, bool, int, int, bool, int, int, int)

    def __init__(self, parameters, fq_infilename, mode, valid, multi):
        super().__init__(
            parameters, fq_infilename, mode)
        self.data_table = None
        self._multi = multi
        self._valid = valid
        self._init_model_specific_parameters()

        self._init_preview_worker()

    def _init_model_specific_parameters(self):
        """Init special parameters for xlsx importer."""
        self.headers = self._parameters['headers']
        self.header_row = self._parameters['header_row']
        self.units = self._parameters['units']
        self.unit_row = self._parameters['unit_row']
        self.descriptions = self._parameters['descriptions']
        self.description_row = self._parameters['description_row']
        self.transposed = self._parameters['transposed']
        self.worksheet_name = self._parameters['worksheet_name']
        self.import_all = self._parameters['import_all']
        self.import_first = self._parameters['import_first']

    def _init_preview_worker(self):
        """Collect preview data from xlsx file."""
        if self._valid:
            sheet_names = get_xlsx_sheetnames(self._fq_infilename)
            self.worksheet_name.list = sheet_names
        self._importer = ImporterXLS(self._fq_infilename, False, True)

        self._preview_thread = QtCore.QThread()
        self._preview_worker = PreviewWorker(self._importer.import_xls)
        self._preview_worker.moveToThread(self._preview_thread)
        self._preview_thread.finished.connect(self._preview_worker.deleteLater)
        self.get_preview.connect(self._preview_worker.create_preview_table)
        self._preview_worker.preview_ready.connect(self.set_preview_table)
        self._preview_worker.preview_failed.connect(self.set_preview_failed)
        self._preview_thread.start()

        self.collect_preview_values()

    @qt_compat.Slot()
    def collect_preview_values(self):
        """Collect preview data from xlsx file."""
        data_start_row = self.data_offset.value - 1
        data_end_row = data_start_row + self.no_preview_rows.value

        sheet_name = self.worksheet_name.selected
        import_all = self.import_all.value
        import_first = self.import_first.value

        transposed = self.transposed.value

        if self.headers.value:
            headers_row = self.header_row.value - 1
        else:
            headers_row = -1
        if self.units.value:
            units_row = self.unit_row.value - 1
        else:
            units_row = -1
        if self.descriptions.value:
            descriptions_row = self.description_row.value - 1
        else:
            descriptions_row = -1
        if self._valid:
            self.get_preview.emit(
                sheet_name, import_first, import_all, data_start_row,
                data_end_row,
                transposed, headers_row, units_row, descriptions_row)
        else:
            self.data_table = table.File()
            self.update_table.emit()

    @qt_compat.Slot(table.File)
    def set_preview_table(self, data_table):
        self.data_table = data_table
        self.update_table.emit()

    def cleanup(self):
        self._preview_thread.quit()
        self._preview_thread.wait()


class ImporterXLS:
    """Class for importat of data in the xlsx format."""

    row_limit = 1048576  # 2 ** 20
    column_limit = 16384  # 2 ** 14

    def __init__(self, fq_infilename, multi, preview=False):
        self._warnings = set()
        self._multi = multi
        self._fq_infilename = fq_infilename
        self._preview = preview

        def raise_mixed_types(x):
            class MixedTypesError(Exception):
                pass

            raise MixedTypesError()

        def type_converters(allowed_types):
            """
            Takes a dict with convertable types and returns a new dict with all
            types represented. Unallowed types will be filled in with
            raise_mixed_types, except for NoneType which is always allowed and
            results in a None value.
            """
            defaults = {
                str: raise_mixed_types,
                bool: raise_mixed_types,
                int: raise_mixed_types,
                float: raise_mixed_types,
                datetime.datetime: raise_mixed_types,
                datetime.time: raise_mixed_types,
                datetime.timedelta: raise_mixed_types,
                type(None): lambda x: None,
            }
            return {**defaults, **allowed_types}

        def float_to_float(x):
            # Excel numbers never have more than 15 significant digits
            # so anything beyond that is just an effect of conversion
            # from excel format to python floats.
            return round(x, 15)

        def date_to_date(x):
            if x < datetime.datetime(1900, 3, 1):
                self._warnings.add(
                    "Ignoring any datetimes before 1900-03-01. All dates "
                    "before 1900-03-01 are ambiguous because of a bug in "
                    "Excel.")
                return None
            return x

        def date_to_str(x):
            date = date_to_date(x)
            if date is None:
                return None
            else:
                return str(date)

        def time_to_timedelta(x):
            return datetime.timedelta(
                hours=x.hour,
                minutes=x.minute,
                seconds=x.second,
                microseconds=x.microsecond)

        def no_conversion(x):
            return x

        self._xl_cell_to_str = type_converters({
            str: str,
            bool: lambda x: str(bool(x)),
            int: str,
            float: lambda x: str(float_to_float(x)),
            datetime.datetime: date_to_str,
            datetime.time: lambda x: str(time_to_timedelta(x)),
            type(None): lambda x: '',
        })
        self._xl_cell_to_bool = type_converters({
            bool: bool,
        })
        self._xl_cell_to_int = type_converters({
            bool: int,
            int: int,
        })
        self._xl_cell_to_float = type_converters({
            bool: float,
            int: float,
            float: float_to_float,
        })
        self._xl_cell_to_date = type_converters({
            datetime.datetime: date_to_date,
        })
        self._xl_cell_to_time = type_converters({
            datetime.time: time_to_timedelta,
            datetime.timedelta: no_conversion,
        })
        self._xl_cell_to_timedelta = self._xl_cell_to_time

    def get_row_to_array(
            self, ws, row, data_start_column, data_end_column):
        """Read a row in the xls file and returns it as an array."""
        return self._to_array(ws, 'row', row,
                              data_start=data_start_column,
                              data_end=data_end_column)

    def get_column_to_array(
            self, ws, column, data_start_row, data_end_row):
        """Read a column in the xls file and returns it as an array."""
        return self._to_array(ws, 'column', column,
                              data_start=data_start_row,
                              data_end=data_end_row)

    def _to_array(self, ws, alignment, i, data_start, data_end):
        """Read a row/column in the xls file and returns it as an array."""
        i += 1
        data_start += 1

        if alignment == 'row':
            iter_values = ws.iter_rows
            min_row = i
            max_row = i
            min_col = data_start
            max_col = data_end
        elif alignment == 'column':
            iter_values = ws.iter_cols
            min_col = i
            max_col = i
            min_row = data_start
            max_row = data_end
        else:
            raise ValueError("Invalid alignment: {}. Should be either "
                             "'row' or 'column'.".format(alignment))
        min_row = max(min_row, 1)
        min_col = max(min_col, 1)

        values_gen = iter_values(
            min_col=min_col,
            max_col=max_col,
            min_row=min_row,
            max_row=max_row,
            values_only=True,
        )
        if values_gen == tuple():
            # openpyxl documentation states that iter_rows/iter_cols can return
            # an empty tuple instead of a generator if there are no cells in
            # the worksheet. This doesn't seem to actually be the case in
            # openpyxl==3.0.2, but it does happen for Worksheet.columns so this
            # is just to be on the safe side.
            values = []
        else:
            values = list(next(values_gen))

        types = {type(v) for v in values} - {type(None)}
        if len(types) == 0:
            # Only emtpy cells in this row/column => convert to str
            target_type = str
        elif len(types) == 1:
            # Only a single type => convert to that type
            target_type = list(types)[0]
        else:
            if not types - {int, bool}:
                # Only integer types (including bool) => convert all to int
                target_type = int
            elif not types - {float, int, bool}:
                # Only numeric types (including bool) => convert all to float
                target_type = float
            else:
                # A combination of str/datetime/numeric => convert all to str
                target_type = str

        # Convert all values to target type
        converter = {
            str: self._xl_cell_to_str,
            int: self._xl_cell_to_int,
            float: self._xl_cell_to_float,
            datetime.datetime: self._xl_cell_to_date,
            datetime.time: self._xl_cell_to_time,
            datetime.timedelta: self._xl_cell_to_timedelta,
            bool: self._xl_cell_to_bool,
        }[target_type]
        array_values = [converter[type(v)](v) for v in values]
        mask = [v is None for v in array_values]

        if any(mask):
            # Deal with empty cells in row/column
            if target_type == datetime.datetime:
                empty_value = datetime.datetime(1900, 1, 1)
            else:
                empty_value = target_type()
            array_values = [empty_value if v is None else v
                            for v in array_values]
            return np.ma.MaskedArray(array_values, mask=mask)
        else:
            return np.array(array_values)

    def import_xls(self, out_data, sheet_name,
                   import_first,
                   import_all,
                   data_start, data_end,
                   transposed, headers_row_offset=-1,
                   units_row_offset=-1,
                   descriptions_row_offset=-1):
        """Adminstration method for import of data in xls format."""

        def sheet_by_name(sheet_name):
            ws = None
            try:
                ws = wb[sheet_name]
            except KeyError:
                if import_first:
                    ws = wb.worksheets[0]
                else:
                    raise
            return ws

        def test_cell(ws, row, column):
            try:
                ws.cell(row, column)
                return True
            except Exception:
                return False

        def import_sheet(ws, out_table):
            max_row = ws.max_row
            max_column = ws.max_column

            if max_row > self.row_limit or max_column > self.column_limit:
                # First, check if getting the last cell would fail.
                row_ok = test_cell(ws, max_row, 1)
                column_ok = test_cell(ws, 1, max_column)

                if not row_ok:
                    max_row = min(self.row_limit, max_row)
                if not column_ok:
                    max_column = min(self.column_limit, max_column)

                if not self._preview:
                    sywarn(
                        f'Data range exceeds limits defined by the '
                        f'format: {self.row_limit} rows x {self.column_limit} '
                        f'columns. Cells out of range may be ignored.')

            return _import_sheet(ws, out_table, max_row, max_column)

        def _import_sheet(ws, out_table, max_row, max_column):

            def get_row_to_array(ws, row, data_start_column=0,
                                 data_end_column=None):
                if data_end_column is None:
                    data_end_column = max_column
                data_end_column = min(data_end_column + 1, max_column)
                return self.get_row_to_array(
                    ws, row, data_start_column=data_start_column,
                    data_end_column=data_end_column)

            def get_column_to_array(ws, col, data_start_row=0,
                                    data_end_row=None):
                if data_end_row is None:
                    data_end_row = max_row
                data_end_row = min(data_end_row + 1, max_row)
                return self.get_column_to_array(
                    ws, col, data_start_row=data_start_row,
                    data_end_row=data_end_row)

            out_table.set_name(ws.title)

            if transposed:
                nr_rows = max_column
                nr_cols = max_row
                data_collector = get_row_to_array
                header_collector = get_column_to_array
            else:
                nr_rows = max_row
                nr_cols = max_column
                data_collector = get_column_to_array
                header_collector = get_row_to_array

            if data_end < 0:
                data_end_ = nr_rows + data_end
            elif data_end == 0:
                data_end_ = nr_rows
            else:
                data_end_ = data_start + data_end

            if headers_row_offset >= 0:
                headers = header_collector(ws, headers_row_offset)
                if isinstance(headers, np.ma.MaskedArray):
                    headers = headers.filled('')
            else:
                headers = ['f{0}'.format(index) for index in range(nr_cols)]

            if units_row_offset >= 0:
                units = [str(x) for x in
                         header_collector(ws, units_row_offset)]
            else:
                units = None

            if descriptions_row_offset >= 0:
                descs = [str(x) for x in
                         header_collector(ws, descriptions_row_offset)]
            else:
                descs = None

            for col in range(nr_cols):
                if not headers[col]:
                    continue
                attr = {}
                out_table.set_column_from_array(
                    headers[col], data_collector(
                        ws, col, data_start, data_end_))
                if units:
                    attr['unit'] = units[col]
                if descs:
                    attr['description'] = descs[col]

                out_table.set_column_attributes(headers[col], attr)

        if not os.path.exists(self._fq_infilename):
            raise SyDataError(
                "File does not exist: {}".format(self._fq_infilename))
        elif is_xls(self._fq_infilename):
            raise SyDataError(
                "Support for legacy Excel file format (XLS) has been removed "
                "from Sympathy 2.0.0. Please use Excel or another external "
                "tool to convert the file to the newer Excel file format "
                "(XLSX).")

        with open_workbook(self._fq_infilename) as wb:
            if self._multi:
                if import_all:
                    for ws in wb.worksheets:
                        out_table = out_data.create()
                        import_sheet(ws, out_table)
                        out_data.append(out_table)
                else:
                    out_table = out_data.create()
                    import_sheet(sheet_by_name(sheet_name), out_table)
                    out_data.append(out_table)
            else:
                out_table = out_data
                import_sheet(sheet_by_name(sheet_name), out_table)

        for warning in self._warnings:
            sywarn(warning)


class TableImportControllerXLS(TableImportController):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        self._table_source.transpose_state_changed.connect(
            self._model.collect_preview_values)

        self._table_source.worksheet_changed[int].connect(
            self._model.collect_preview_values)


class TableImportWidgetXLS(TableImportWidget):
    MODE = 'XLS'

    def __init__(self, parameters, fq_infilename, valid=True, multi=False):
        self._multi = multi
        self.model = TableSourceModelXLS(
            parameters, fq_infilename, self.MODE, valid, multi=multi)

        super().__init__(
            parameters, fq_infilename, self.MODE, valid)

    def _collect_table_source_widget(self, model):
        return TableSourceWidgetXLS(model, self._multi)

    def _collect_controller(self, **kwargs):
        return TableImportControllerXLS(**kwargs)


class TableSourceWidgetXLS(QtWidgets.QWidget):
    worksheet_changed = qt_compat.Signal(int)
    transpose_state_changed = qt_compat.Signal(int)

    def __init__(self, model, multi=False, parent=None):
        super().__init__(parent)
        self._multi = multi
        self._init_gui(model)

    def _init_gui(self, model):
        layout = QtWidgets.QVBoxLayout()

        # Worksheet Combobox
        self._worksheet_combobox = model.worksheet_name.gui()
        self._import_first_checkbox = model.import_first.gui()
        self._import_all_checkbox = model.import_all.gui()

        if self._multi:
            self._worksheet_combobox.setDisabled(model.import_all.value)
            self._import_first_checkbox.setDisabled(model.import_all.value)
            layout.addWidget(self._import_all_checkbox)

        self._worksheet_combobox.setFixedWidth(200)
        layout.addWidget(self._worksheet_combobox)
        layout.addWidget(self._import_first_checkbox)

        self._worksheet_combobox.editor().currentIndexChanged[int].connect(
            self.worksheet_changed)

        self._import_all_checkbox.valueChanged.connect(
            self._worksheet_combobox.setDisabled)

        self._import_all_checkbox.valueChanged.connect(
            self._import_first_checkbox.setDisabled)

        # Transpose checkbox
        self._transposed_checkbox = model.transposed.gui()
        layout.addWidget(self._transposed_checkbox)
        self._transposed_checkbox.stateChanged[int].connect(
            self.transpose_state_changed)

        self.setLayout(layout)


[docs] class DataImportXLS(importers.TableDataImporterBase): """Importer for Excel files.""" IMPORTER_NAME = 'XLS' DISPLAY_NAME = 'XLSX' PARAMETER_VIEW = TableImportWidgetXLS def __init__(self, fq_infilename, parameters): super().__init__(fq_infilename, parameters) if parameters is not None: self._init_parameters() def cardinalities(self): return [self.one_to_many, self.one_to_one] def _init_parameters(self): parameter_root = self._parameters nbr_of_rows = 99999 nbr_of_end_rows = 9999999 try: parameter_root['import_all'] except KeyError: parameter_root.set_boolean( 'import_all', label='Import all worksheets', description=( 'Ignore worksheet selection and import all worksheets. ' 'This requires an output capable of storing multiple ' 'output elements.'), value=False) try: parameter_root['worksheet_name'] except KeyError: parameter_root.set_list( 'worksheet_name', label='Select worksheet', description='The worksheet to import from.', editor=synode.editors.combo_editor()) try: parameter_root['import_first'] except KeyError: parameter_root.set_boolean( 'import_first', label='Use first worksheet if the selected is missing', value=True, description=( 'This option is used if the input data does not contain ' 'the selected worksheet.' '\n\n' 'Enabled: the first available worksheet will be ' 'imported instead of the selected.' '\n' 'Disabled: the import will fail and inform that ' 'the selected worksheet is missing.')) # Init header start row spinbox try: parameter_root['header_row'] except KeyError: parameter_root.set_integer( 'header_row', value=1, description='The row where the headers are located.', editor=synode.editors.bounded_spinbox_editor( 1, nbr_of_rows, 1)) # Init unit row spinbox try: parameter_root['unit_row'] except KeyError: parameter_root.set_integer( 'unit_row', value=1, description='The row where the units are located.', editor=synode.editors.bounded_spinbox_editor( 1, nbr_of_rows, 1)) # Init description row spinbox try: parameter_root['description_row'] except KeyError: parameter_root.set_integer( 'description_row', value=1, description='The row where the descriptions are located.', editor=synode.editors.bounded_spinbox_editor( 1, nbr_of_rows, 1)) # Init data start row spinbox try: parameter_root['data_start_row'] except KeyError: parameter_root.set_integer( 'data_start_row', value=2, description='The first row where data is stored.', editor=synode.editors.bounded_spinbox_editor( 1, nbr_of_rows, 1)) # Init data end row spinbox try: parameter_root['data_end_row'] except KeyError: parameter_root.set_integer( 'data_end_row', value=0, description='The last data row.', editor=synode.editors.bounded_spinbox_editor( 0, nbr_of_end_rows, 1)) # Init headers checkbox try: parameter_root['headers'] except KeyError: parameter_root.set_boolean( 'headers', value=True, description='File has headers.') # Init units checkbox try: parameter_root['units'] except KeyError: parameter_root.set_boolean( 'units', value=False, description='File has headers.') # Init descriptions checkbox try: parameter_root['descriptions'] except KeyError: parameter_root.set_boolean( 'descriptions', value=False, description='File has headers.') # Init transposed checkbox try: parameter_root['transposed'] except KeyError: parameter_root.set_boolean( 'transposed', value=False, label='Transpose input', description='Transpose the data.') try: parameter_root['end_of_file'] except KeyError: parameter_root.set_boolean( 'end_of_file', value=True, description='Select all rows to the end of the file.') try: parameter_root['read_selection'] except KeyError: parameter_root.set_list( 'read_selection', value=[0], list=_read_options, editor=synode.editors.combo_editor()) # Move value of old parameter to new the format. if not parameter_root['end_of_file'].value: parameter_root['read_selection'].value_names = [_read_from_end] try: parameter_root['preview_start_row'] except KeyError: parameter_root.set_integer( 'preview_start_row', value=1, label='Preview start row', description='The first row where data will review from.', editor=synode.editors.bounded_spinbox_editor(1, 200, 1)) try: parameter_root['no_preview_rows'] except KeyError: parameter_root.set_integer( 'no_preview_rows', value=20, label='Number of preview rows', description='The number of preview rows to show.', editor=synode.editors.bounded_spinbox_editor(1, 200, 1)) def name(self): return self.IMPORTER_NAME def valid_for_file(self): if self._fq_infilename is None or not os.path.isfile( self._fq_infilename): return False return is_xlsx(self._fq_infilename) def parameter_view(self, parameters): valid_for_file = self.valid_for_file() return TableImportWidgetXLS( parameters, self._fq_infilename, valid_for_file, self.cardinality() == self.one_to_many) def import_data(self, out_datafile, parameters=None, progress=None): parameter_root = parameters headers_bool = parameter_root['headers'].value headers_row_offset = parameter_root['header_row'].value - 1 units_bool = parameter_root['units'].value units_row_offset = parameter_root['unit_row'].value - 1 descs_bool = parameter_root['descriptions'].value descs_row_offset = parameter_root['description_row'].value - 1 data_start_row = parameter_root['data_start_row'].value - 1 read_selection = parameter_root['read_selection'].selected transposed = parameter_root['transposed'].value sheet_name = parameter_root['worksheet_name'].selected import_all = parameter_root['import_all'].value import_first = parameter_root['import_first'].value if not headers_bool: headers_row_offset = -1 if not units_bool: units_row_offset = -1 if not descs_bool: descs_row_offset = -1 if read_selection == _read_to_end: data_end_row = 0 elif read_selection == _read_selected: data_end_row = parameter_root['data_end_row'].value elif read_selection == _read_from_end: data_end_row = - parameter_root['data_end_row'].value else: raise ValueError('Unknown Read Selection.') importer = ImporterXLS( self._fq_infilename, self.cardinality() == self.one_to_many) importer.import_xls(out_datafile, sheet_name, import_first, import_all, data_start_row, data_end_row, transposed, headers_row_offset=headers_row_offset, units_row_offset=units_row_offset, descriptions_row_offset=descs_row_offset)