Source code for helpers

######################################################################################################################
# Copyright (C) 2017 - 2019 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################

"""
General helper functions and classes.

:authors: P. Savolainen (VTT)
:date:   10.1.2018
"""

import sys
import logging
import datetime
import os
import time
import shutil
import glob
import json
import spinedb_api
from PySide2.QtCore import Qt, Slot, QFile, QIODevice, QSize, QRect, QPoint
from PySide2.QtCore import __version__ as qt_version
from PySide2.QtCore import __version_info__ as qt_version_info
from PySide2.QtWidgets import QApplication, QMessageBox, QGraphicsScene
from PySide2.QtGui import (
    QCursor,
    QImageReader,
    QPixmap,
    QPainter,
    QColor,
    QIcon,
    QIconEngine,
    QFont,
    QStandardItemModel,
    QStandardItem,
)
from config import DEFAULT_PROJECT_DIR, REQUIRED_SPINEDB_API_VERSION


[docs]def set_taskbar_icon(): """Set application icon to Windows taskbar.""" if os.name == "nt": import ctypes myappid = "{6E794A8A-E508-47C4-9319-1113852224D3}" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
@Slot(name="supported_img_formats")
[docs]def supported_img_formats(): """Function to check if reading .ico files is supported.""" img_formats = QImageReader().supportedImageFormats() img_formats_str = '\n'.join(str(x) for x in img_formats) logging.debug("Supported Image formats:\n%s", img_formats_str)
[docs]def pyside2_version_check(): """Check that PySide2 version is older than 5.12, since this is not supported yet. Issue #238 in GitLab. qt_version is the Qt version used to compile PySide2 as string. E.g. "5.11.2" qt_version_info is a tuple with each version component of Qt used to compile PySide2. E.g. (5, 11, 2) """ # print("Your QT version info is:{0} version string:{1}".format(qt_version_info, qt_version)) if qt_version_info[0] == 5 and qt_version_info[1] >= 12: print( """Sorry for the inconvenience but, Spine Toolbox does not support PySide2 version {0} yet. Please downgrade PySide2 to version 5.11.x and try to start the application again. To downgrade PySide2 to a compatible version, run pip install "pyside2<5.12" """.format( qt_version ) ) return False return True
[docs]def spinedb_api_version_check(): """Check if spinedb_api is the correct version and explain how to upgrade if it is not.""" try: current_version = spinedb_api.__version__ current_split = [int(x) for x in current_version.split(".")] required_split = [int(x) for x in REQUIRED_SPINEDB_API_VERSION.split(".")] if current_split >= required_split: return True except AttributeError: current_version = "not reported" script = "upgrade_spinedb_api.bat" if sys.platform == "win32" else "upgrade_spinedb_api.sh" print( """ERROR: Spine Toolbox failed to start because spinedb_api is outdated. (Required version is {0}, whereas current is {1}) Please upgrade spinedb_api to v{0} and start Spine Toolbox again. To upgrade, run script '{2}' in the '/bin' folder. Or upgrade it manually by running, pip install --upgrade git+https://github.com/Spine-project/Spine-Database-API.git """.format( REQUIRED_SPINEDB_API_VERSION, current_version, script ) ) return False
[docs]def busy_effect(func): """ Decorator to change the mouse cursor to 'busy' while a function is processed. Args: func: Decorated function. """ def new_function(*args, **kwargs): # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QApplication.setOverrideCursor(QCursor(Qt.BusyCursor)) try: return func(*args, **kwargs) finally: # noinspection PyArgumentList QApplication.restoreOverrideCursor() return new_function
[docs]def project_dir(qsettings): """Returns current project directory. Args: qsettings (QSettings): Settings object """ # NOTE: This is not actually used. The key is not saved to qsettings anywhere. This is a placeholder for code # if we want to be able to change the projects directory at some point. proj_dir = qsettings.value("appSettings/projectsDir", defaultValue="") if not proj_dir: return DEFAULT_PROJECT_DIR return proj_dir
[docs]def get_datetime(show): """Returns date and time string for appending into Event Log messages. Args: show (boolean): True returns date and time string. False returns empty string. """ if show: t = datetime.datetime.now() return "[{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}] ".format(t.day, t.month, t.year, t.hour, t.minute, t.second) return ""
[docs]def create_dir(base_path, folder='', verbosity=False): """Create (input/output) directories recursively. Args: base_path (str): Absolute path to wanted dir folder (str): (Optional) Folder name. Usually short name of item. verbosity (bool): True prints a message that tells if the directory already existed or if it was created. Returns: True if directory already exists or if it was created successfully. Raises: OSError if operation failed. """ directory = os.path.join(base_path, folder) if os.path.exists(directory) and verbosity: logging.debug("Directory found: %s", directory) else: os.makedirs(directory, exist_ok=True) if verbosity: logging.debug("Directory created: %s", directory) return True
[docs]def create_output_dir_timestamp(): """ Creates a new timestamp string that is used as Tool output directory. Returns: Timestamp string or empty string if failed. """ try: # Create timestamp stamp = datetime.datetime.fromtimestamp(time.time()) except OverflowError: logging.error('Timestamp out of range.') return '' extension = stamp.strftime('%Y-%m-%dT%H.%M.%S') return extension
[docs]def create_log_file_timestamp(): """ Creates a new timestamp string that is used as Data Interface and Data Store error log file. Returns: Timestamp string or empty string if failed. """ try: # Create timestamp stamp = datetime.datetime.fromtimestamp(time.time()) except OverflowError: logging.error('Timestamp out of range.') return '' extension = stamp.strftime('%Y%m%dT%H%M%S') return extension
@busy_effect
[docs]def copy_files(src_dir, dst_dir, includes=None, excludes=None): """Method for copying files. Does not copy folders. Args: src_dir (str): Source directory dst_dir (str): Destination directory includes (list): Included files (wildcards accepted) excludes (list): Excluded files (wildcards accepted) Returns: count (int): Number of files copied """ if not includes: includes = ['*'] if not excludes: excludes = [] src_files = [] for pattern in includes: src_files += glob.glob(os.path.join(src_dir, pattern)) exclude_files = [] for pattern in excludes: exclude_files += glob.glob(os.path.join(src_dir, pattern)) count = 0 for filename in src_files: if os.path.isdir(filename): continue if filename not in exclude_files: shutil.copy(filename, dst_dir) count += 1 return count
@busy_effect
[docs]def erase_dir(path, verbosity=False): """Delete directory and all its contents without prompt. Args: path (str): Path to directory verbosity (bool): Print logging messages or not """ if not os.path.exists(path): if verbosity: logging.debug("Path does not exist: %s", path) return False if verbosity: logging.debug("Deleting directory %s", path) shutil.rmtree(path) return True
@busy_effect
[docs]def copy_dir(widget, src_dir, dst_dir): """Make a copy of a directory. All files and folders are copied. Args: widget (QWidget): Parent widget for QMessageBoxes src_dir (str): Absolute path to directory that will be copied dst_dir (str): Absolute path to new directory """ title_msg = "Copying directory failed" try: shutil.copytree(src_dir, dst_dir) except FileExistsError: msg = "Directory<br/><b>{0}</b><br/>already exists".format(dst_dir) # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QMessageBox.information(widget, title_msg, msg) return False except PermissionError as e: logging.exception(e) msg = ( "Access to directory <br/><b>{0}</b><br/>denied." "<br/><br/>Possible reasons:" "<br/>1. Windows Explorer is open in the directory" "<br/>2. Permission error" "<br/><br/>Check these and try again.".format(dst_dir) ) # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QMessageBox.information(widget, title_msg, msg) return False except OSError: msg = ( "Copying directory failed. OSError in" "<br/><b>{0}</b><br/>Possibly because Windows " "Explorer is open in the directory".format(dst_dir) ) # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QMessageBox.information(widget, title_msg, msg) return False return True
[docs]def rename_dir(widget, old_dir, new_dir): """Rename directory. Note: This is not used in renaming projects due to unreliability. Looks like it works fine in renaming project items though. Args: widget (QWidget): Parent widget for QMessageBoxes old_dir (str): Absolute path to directory that will be renamed new_dir (str): Absolute path to new directory """ try: shutil.move(old_dir, new_dir) except FileExistsError: msg = "Directory<br/><b>{0}</b><br/>already exists".format(new_dir) # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QMessageBox.information(widget, "Renaming directory failed", msg) return False except PermissionError as e: logging.exception(e) msg = ( "Access to directory <br/><b>{0}</b><br/>denied." "<br/><br/>Possible reasons:" "<br/>1. Windows Explorer is open in the directory" "<br/>2. Permission error" "<br/><br/>Check these and try again.".format(old_dir) ) # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QMessageBox.information(widget, "Renaming directory failed", msg) return False except OSError: msg = ( "Renaming input directory failed. OSError in" "<br/><b>{0}</b><br/>Possibly because Windows " "Explorer is open in the directory".format(old_dir) ) # noinspection PyTypeChecker, PyArgumentList, PyCallByClass QMessageBox.information(widget, "Renaming directory failed", msg) return False return True
[docs]def fix_name_ambiguity(name_list, offset=0): """Modify repeated entries in name list by appending an increasing integer.""" ref_name_list = name_list.copy() ocurrences = {} for i, name in enumerate(name_list): n_ocurrences = ref_name_list.count(name) if n_ocurrences == 1: continue ocurrence = ocurrences.setdefault(name, 1) name_list[i] = name + str(offset + ocurrence) ocurrences[name] = ocurrence + 1
[docs]def tuple_itemgetter(itemgetter_func, num_indexes): """Change output of itemgetter to always be a tuple even for one index""" return (lambda item: (itemgetter_func(item),)) if num_indexes == 1 else itemgetter_func
[docs]def format_string_list(str_list): """ Return an unordered html list with all elements in str_list. Intended to print error logs as returned by spinedb_api. Args: str_list (list(str)) """ return "<ul>" + "".join(["<li>" + str(x) + "</li>" for x in str_list]) + "</ul>"
[docs]def get_db_map(url, upgrade=False): """Returns a DiffDatabaseMapping instance from url. If the db is not the latest version, asks the user if they want to upgrade it. """ try: db_map = do_get_db_map(url, upgrade) return db_map except spinedb_api.SpineDBVersionError: msg = QMessageBox() msg.setIcon(QMessageBox.Question) msg.setWindowTitle("Incompatible database version") msg.setText( "The database at <b>{}</b> is from an older version of Spine " "and needs to be upgraded in order to be used with the current version.".format(url) ) msg.setInformativeText( "Do you want to upgrade it now?" "<p><b>WARNING</b>: After the upgrade, " "the database may no longer be used " "with previous versions of Spine." ) msg.addButton(QMessageBox.Cancel) msg.addButton("Upgrade", QMessageBox.YesRole) ret = msg.exec_() # Show message box if ret == QMessageBox.Cancel: return None return get_db_map(url, upgrade=True)
@busy_effect
[docs]def do_get_db_map(url, upgrade): """Returns a DiffDatabaseMapping instance from url. Called by `get_db_map`. """ return spinedb_api.DiffDatabaseMapping(url, upgrade=upgrade)
[docs]def int_list_to_row_count_tuples(int_list): """Breaks a list of integers into a list of tuples (row, count) corresponding to chunks of successive elements. """ sorted_list = sorted(set(int_list)) break_points = [k + 1 for k in range(len(sorted_list) - 1) if sorted_list[k] + 1 != sorted_list[k + 1]] break_points = [0] + break_points + [len(sorted_list)] ranges = [(break_points[l], break_points[l + 1]) for l in range(len(break_points) - 1)] return [(sorted_list[start], stop - start) for start, stop in ranges]
[docs]class IconListManager: """A class to manage icons for icon list widgets.""" def __init__(self, icon_size): self._icon_size = icon_size self.searchterms = {} self.model = QStandardItemModel() self.model.data = self._model_data @busy_effect
[docs] def init_model(self): """Init model that can be used to display all icons in a list.""" if self.searchterms: return qfile = QFile(":/fonts/fontawesome5-searchterms.json") qfile.open(QIODevice.ReadOnly | QIODevice.Text) data = str(qfile.readAll().data(), "utf-8") qfile.close() self.searchterms = json.loads(data) items = [] for codepoint, searchterms in self.searchterms.items(): item = QStandardItem() display_icon = int(codepoint, 16) item.setData(display_icon, Qt.UserRole) item.setData(searchterms, Qt.UserRole + 1) items.append(item) self.model.invisibleRootItem().appendRows(items)
[docs] def _model_data(self, index, role): """ Replacement method for model.data(). Create pixmaps as they're requested by the data() method, to reduce loading time. """ if role == Qt.DisplayRole: return None if role != Qt.DecorationRole: return QStandardItemModel.data(self.model, index, role) display_icon = index.data(Qt.UserRole) pixmap = self.create_object_pixmap(display_icon) return QIcon(pixmap)
[docs] def create_object_pixmap(self, display_icon): """Create and return a pixmap corresponding to display_icon.""" icon_code, color_code = interpret_icon_id(display_icon) engine = CharIconEngine(chr(icon_code), color_code) return engine.pixmap(self._icon_size)
[docs]class IconManager: """A class to manage object class icons for data store forms."""
[docs] ICON_SIZE = QSize(512, 512)
def __init__(self): self.obj_cls_icon_cache = {} # A mapping from object class name to display icon self.icon_pixmap_cache = {} # A mapping from display_icon to associated pixmap self.rel_cls_icon_cache = {} # A mapping from object class name list to associated pixmap self.searchterms = {}
[docs] def create_object_pixmap(self, display_icon): """Create a pixmap corresponding to display_icon, cache it, and return it.""" pixmap = self.icon_pixmap_cache.get(display_icon, None) if pixmap is None: icon_code, color_code = interpret_icon_id(display_icon) engine = CharIconEngine(chr(icon_code), color_code) pixmap = engine.pixmap(self.ICON_SIZE) self.icon_pixmap_cache[display_icon] = pixmap return pixmap
[docs] def setup_object_pixmaps(self, object_classes): """Called after adding or updating object classes. Create the corresponding object pixmaps and clear obsolete entries from the relationship class icon cache.""" for object_class in object_classes: self.create_object_pixmap(object_class.display_icon) self.obj_cls_icon_cache[object_class.name] = object_class.display_icon object_class_names = [x.name for x in object_classes] dirty_keys = [k for k in self.rel_cls_icon_cache if any(x in object_class_names for x in k)] for k in dirty_keys: del self.rel_cls_icon_cache[k]
[docs] def object_pixmap(self, object_class_name): """A pixmap for the given object class.""" if object_class_name in self.obj_cls_icon_cache: display_icon = self.obj_cls_icon_cache[object_class_name] if display_icon in self.icon_pixmap_cache: return self.icon_pixmap_cache[display_icon] engine = CharIconEngine("\uf1b2", 0) return engine.pixmap(self.ICON_SIZE)
[docs] def object_icon(self, object_class_name): """An icon for the given object class.""" return QIcon(self.object_pixmap(object_class_name))
[docs] def relationship_pixmap(self, str_object_class_name_list): """A pixmap for the given object class name list, created by rendering several object pixmaps next to each other.""" if not str_object_class_name_list: engine = CharIconEngine("\uf1b3", 0) return engine.pixmap(self.ICON_SIZE) object_class_name_list = tuple(str_object_class_name_list.split(",")) if object_class_name_list in self.rel_cls_icon_cache: return self.rel_cls_icon_cache[object_class_name_list] scene = QGraphicsScene() x = 0 for j, object_class_name in enumerate(object_class_name_list): pixmap = self.object_pixmap(object_class_name) pixmap_item = scene.addPixmap(pixmap) if j % 2 == 0: y = 0 else: y = -0.875 * 0.75 * pixmap_item.boundingRect().height() pixmap_item.setZValue(-1) pixmap_item.setPos(x, y) x += 0.875 * 0.5 * pixmap_item.boundingRect().width() pixmap = QPixmap(scene.itemsBoundingRect().toRect().size()) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing, True) scene.render(painter) painter.end() self.rel_cls_icon_cache[object_class_name_list] = pixmap return pixmap
[docs] def relationship_icon(self, str_object_class_name_list): """An icon for the given object class name list.""" return QIcon(self.relationship_pixmap(str_object_class_name_list))
[docs]class CharIconEngine(QIconEngine): """Specialization of QIconEngine used to draw font-based icons.""" def __init__(self, char, color): super().__init__() self.char = char self.color = color self.font = QFont('Font Awesome 5 Free Solid')
[docs] def paint(self, painter, rect, mode=None, state=None): painter.save() size = 0.875 * round(rect.height()) self.font.setPixelSize(size) painter.setFont(self.font) painter.setPen(QColor(self.color)) painter.drawText(rect, Qt.AlignCenter | Qt.AlignVCenter, self.char) painter.restore()
[docs] def pixmap(self, size, mode=None, state=None): pm = QPixmap(size) pm.fill(Qt.transparent) self.paint(QPainter(pm), QRect(QPoint(0, 0), size), mode, state) return pm
[docs]def make_icon_id(icon_code, color_code): """Take icon and color codes, and return equivalent integer.""" return icon_code + (color_code << 16)
[docs]def interpret_icon_id(display_icon): """Take a display icon integer and return an equivalent tuple of icon and color code.""" if not isinstance(display_icon, int) or display_icon < 0: return 0xF1B2, 0 icon_code = display_icon & 65535 try: color_code = display_icon >> 16 except OverflowError: color_code = 0 return icon_code, color_code
[docs]def default_icon_id(): return make_icon_id(*interpret_icon_id(None))