diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 81c4e0533ce..8b22c9929ad 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -5,6 +5,7 @@ """ import sys +import types import os import csv import enum @@ -27,17 +28,19 @@ import typing from typing import ( List, Tuple, Dict, Optional, Any, Callable, Iterable, - Union, AnyStr, BinaryIO, Set + Union, AnyStr, BinaryIO, Set, Type, Mapping, Sequence, NamedTuple ) from PyQt5.QtCore import ( Qt, QFileInfo, QTimer, QSettings, QObject, QSize, QMimeDatabase, QMimeType ) -from PyQt5.QtGui import QStandardItem, QStandardItemModel, QPalette +from PyQt5.QtGui import ( + QStandardItem, QStandardItemModel, QPalette, QColor, QIcon +) from PyQt5.QtWidgets import ( QLabel, QComboBox, QPushButton, QDialog, QDialogButtonBox, QGridLayout, QVBoxLayout, QSizePolicy, QStyle, QFileIconProvider, QFileDialog, - QApplication, QMessageBox, QTextBrowser + QApplication, QMessageBox, QTextBrowser, QMenu ) from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal @@ -52,7 +55,11 @@ from Orange.widgets import widget, gui, settings from Orange.widgets.utils.concurrent import PyOwned from Orange.widgets.utils import ( - textimport, concurrent as qconcurrent, unique_everseen + textimport, concurrent as qconcurrent, unique_everseen, enum_get, qname +) +from Orange.widgets.utils.combobox import ItemStyledComboBox +from Orange.widgets.utils.pathutils import ( + PathItem, VarPath, AbsPath, samepath, prettyfypath, isprefixed, ) from Orange.widgets.utils.overlay import OverlayWidget from Orange.widgets.utils.settings import ( @@ -60,11 +67,11 @@ ) from Orange.widgets.utils.state_summary import format_summary_details - if typing.TYPE_CHECKING: # pylint: disable=invalid-name T = typing.TypeVar("T") K = typing.TypeVar("K") + E = typing.TypeVar("E", bound=enum.Enum) __all__ = ["OWCSVFileImport"] @@ -74,17 +81,6 @@ RowSpec = textimport.RowSpec -def enum_lookup(enumtype, name): - # type: (typing.Type[T], str) -> Optional[T] - """ - Return an value from `enumtype` by its symbolic name or None if not found. - """ - try: - return enumtype[name] - except LookupError: - return None - - def dialect_eq(lhs, rhs): # type: (csv.Dialect, csv.Dialect) -> bool """Compare 2 `csv.Dialect` instances for equality.""" @@ -121,19 +117,18 @@ class Options: RowSpec = RowSpec ColumnType = ColumnType - def __init__(self, encoding='utf-8', dialect=csv.excel(), columntypes=[], + def __init__(self, encoding='utf-8', dialect=csv.excel(), + columntypes: Iterable[Tuple[range, 'ColumnType']] = (), rowspec=((range(0, 1), RowSpec.Header),), - decimal_separator=".", group_separator=""): - # type: (str, csv.Dialect, List[Tuple[range, ColumnType]], ...) -> None + decimal_separator=".", group_separator="") -> None: self.encoding = encoding self.dialect = dialect - self.columntypes = list(columntypes) - self.rowspec = list(rowspec) # type: List[Tuple[range, Options.RowSpec]] + self.columntypes = list(columntypes) # type: List[Tuple[range, ColumnType]] + self.rowspec = list(rowspec) # type: List[Tuple[range, RowSpec]] self.decimal_separator = decimal_separator self.group_separator = group_separator def __eq__(self, other): - # type: (Options) -> bool """ Compare this instance to `other` for equality. """ @@ -205,13 +200,13 @@ def from_dict(mapping): @staticmethod def spec_as_encodable(spec): - # type: (List[Tuple[range, enum.Enum]]) -> List[Dict[str, Any]] + # type: (Iterable[Tuple[range, enum.Enum]]) -> List[Dict[str, Any]] return [{"start": r.start, "stop": r.stop, "value": value.name} for r, value in spec] @staticmethod def spec_from_encodable(spec, enumtype): - # type: (List[Dict[str, Any]], typing.Type[T]) -> List[Tuple[range, T]] + # type: (Iterable[Dict[str, Any]], Type[E]) -> List[Tuple[range, E]] r = [] for v in spec: try: @@ -219,7 +214,7 @@ def spec_from_encodable(spec, enumtype): except (KeyError, ValueError): pass else: - r.append((range(start, stop), enum_lookup(enumtype, name))) + r.append((range(start, stop), enum_get(enumtype, name, None))) return r @@ -391,62 +386,214 @@ def dialog_button_box_set_enabled(buttonbox, enabled): b.setEnabled(state) -class ImportItem(QStandardItem): +def icon_for_path(path: str) -> QIcon: + iconprovider = QFileIconProvider() + finfo = QFileInfo(path) + if finfo.exists(): + return iconprovider.icon(finfo) + else: + return iconprovider.icon(QFileIconProvider.File) + + +class VarPathItem(QStandardItem): + PathRole = Qt.UserRole + 4502 + VarPathRole = PathRole + 1 + + def path(self) -> str: + """Return the resolved path or '' if unresolved or missing""" + path = self.data(VarPathItem.PathRole) + return path if isinstance(path, str) else "" + + def setPath(self, path: str) -> None: + """Set absolute path.""" + self.setData(PathItem.AbsPath(path), VarPathItem.VarPathRole) + + def varPath(self) -> Optional[PathItem]: + vpath = self.data(VarPathItem.VarPathRole) + return vpath if isinstance(vpath, PathItem) else None + + def setVarPath(self, vpath: PathItem) -> None: + """Set variable path item.""" + self.setData(vpath, VarPathItem.VarPathRole) + + def resolve(self, vpath: PathItem) -> Optional[str]: + """ + Resolve `vpath` item. This implementation dispatches to parent model's + (:func:`VarPathItemModel.resolve`) + """ + model = self.model() + if isinstance(model, VarPathItemModel): + return model.resolve(vpath) + else: + return vpath.resolve({}) + + def data(self, role=Qt.UserRole + 1) -> Any: + if role == Qt.DisplayRole: + value = super().data(role) + if value is not None: + return value + vpath = self.varPath() + if isinstance(vpath, PathItem.AbsPath): + return os.path.basename(vpath.path) + elif isinstance(vpath, PathItem.VarPath): + return os.path.basename(vpath.relpath) + else: + return None + elif role == Qt.DecorationRole: + return icon_for_path(self.path()) + elif role == VarPathItem.PathRole: + vpath = self.data(VarPathItem.VarPathRole) + if isinstance(vpath, PathItem.AbsPath): + return vpath.path + elif isinstance(vpath, VarPath): + path = self.resolve(vpath) + if path is not None: + return path + return super().data(role) + elif role == Qt.ToolTipRole: + vpath = self.data(VarPathItem.VarPathRole) + if isinstance(vpath, VarPath.AbsPath): + return vpath.path + elif isinstance(vpath, VarPath): + text = f"${{{vpath.name}}}/{vpath.relpath}" + p = self.resolve(vpath) + if p is None or not os.path.exists(p): + text += " (missing)" + return text + elif role == Qt.ForegroundRole: + vpath = self.data(VarPathItem.VarPathRole) + if isinstance(vpath, PathItem): + p = self.resolve(vpath) + if p is None or not os.path.exists(p): + return QColor(Qt.red) + return super().data(role) + + +class ImportItem(VarPathItem): """ An item representing a file path and associated load options """ - PathRole = Qt.UserRole + 12 OptionsRole = Qt.UserRole + 14 + IsSessionItemRole = Qt.UserRole + 15 - def path(self): - # type: () -> str - path = self.data(ImportItem.PathRole) - return path if isinstance(path, str) else "" - - def setPath(self, path): - # type: (str) -> None - self.setData(path, ImportItem.PathRole) - - def options(self): - # type: () -> Optional[Options] + def options(self) -> Optional[Options]: options = self.data(ImportItem.OptionsRole) return options if isinstance(options, Options) else None - def setOptions(self, options): - # type: (Options) -> None + def setOptions(self, options: Options) -> None: self.setData(options, ImportItem.OptionsRole) + def setIsSessionItem(self, issession: bool) -> None: + self.setData(issession, ImportItem.IsSessionItemRole) + + def isSessionItem(self) -> bool: + return bool(self.data(ImportItem.IsSessionItemRole)) + @classmethod - def fromPath(cls, path): - # type: (str) -> ImportItem + def fromPath(cls, path: Union[str, PathItem]) -> 'ImportItem': """ Create a `ImportItem` from a local file system path. """ - iconprovider = QFileIconProvider() - basename = os.path.basename(path) - item = cls() - item.setText(basename) - item.setToolTip(path) - finfo = QFileInfo(path) - if finfo.exists(): - item.setIcon(iconprovider.icon(finfo)) + if isinstance(path, str): + path = PathItem.AbsPath(path) + if isinstance(path, PathItem.VarPath): + basename = os.path.basename(path.relpath) + text = f"${{{path.name}}}/{path.relpath}" + elif isinstance(path, PathItem.AbsPath): + basename = os.path.basename(path.path) + text = path.path else: - item.setIcon(iconprovider.icon(QFileIconProvider.File)) + raise TypeError - item.setData(path, ImportItem.PathRole) - if not os.path.isfile(path): - item.setEnabled(False) - item.setToolTip(item.toolTip() + " (missing from filesystem)") + item = cls() + item.setText(basename) + item.setToolTip(text) + item.setData(path, ImportItem.VarPathRole) return item -def qname(type_): - # type: (type) -> str - """ - Return the fully qualified name for a `type_`. - """ +class VarPathItemModel(QStandardItemModel): + def __init__(self, *args, replacementEnv=types.MappingProxyType({}), + **kwargs): + self.__replacements = types.MappingProxyType(dict(replacementEnv)) + super().__init__(*args, **kwargs) + + def setReplacementEnv(self, env: Mapping[str, str]) -> None: + self.__replacements = types.MappingProxyType(dict(env)) + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount() - 1, self.columnCount() - 1) + ) + + def replacementEnv(self) -> Mapping[str, str]: + return self.__replacements + + def resolve(self, vpath: PathItem) -> Optional[str]: + return vpath.resolve(self.replacementEnv()) + + +def move_item_to_index(model: QStandardItemModel, item: QStandardItem, index: int): + if item.row() == index: + return + assert item.model() is model + [item_] = model.takeRow(item.row()) + assert item_ is item + model.insertRow(index, [item]) + + +class FileFormat(NamedTuple): + mime_type: str + name: str + globs: Sequence[str] + + +FileFormats = [ + FileFormat("text/csv", "Text - comma separated", ("*.csv", "*")), + FileFormat("text/tab-separated-values", "Text - tab separated", ("*.tsv", "*")), + FileFormat("text/plain", "Text - all files", ("*.txt", "*")), +] + + +class FileDialog(QFileDialog): + __formats: Sequence[FileFormat] = () + + @staticmethod + def filterStr(f: FileFormat) -> str: + return f"{f.name} ({', '.join(f.globs)})" - return "{0.__module__}.{0.__qualname__}".format(type_) + def setFileFormats(self, formats: Sequence[FileFormat]): + filters = [FileDialog.filterStr(f) for f in formats] + self.__formats = tuple(formats) + self.setNameFilters(filters) + + def fileFormats(self) -> Sequence[FileFormat]: + return self.__formats + + def selectedFileFormat(self) -> FileFormat: + filter_ = self.selectedNameFilter() + index = index_where( + self.__formats, lambda f: FileDialog.filterStr(f) == filter_ + ) + return self.__formats[index] + + +def default_options_for_mime_type( + path: str, mime_type: str +) -> Tuple[csv.Dialect, bool]: + defaults = { + "text/csv": (csv.excel(), True), + "text/tab-separated-values": (csv.excel_tab(), True) + } + dialect, header = csv.excel(), True + delimiters = None + if mime_type in defaults: + dialect, header = defaults[mime_type] + delimiters = [dialect.delimiter] + try: + dialect, header = sniff_csv_with_path(path, delimiters=delimiters) + except (OSError, UnicodeDecodeError, csv.Error): + pass + return dialect, header class OWCSVFileImport(widget.OWWidget): @@ -483,6 +630,8 @@ class Error(widget.OWWidget.Error): _session_items = settings.Setting( [], schema_only=True) # type: List[Tuple[str, dict]] + _session_items_v2 = settings.Setting( + [], schema_only=True) # type: List[Tuple[Dict[str, str], dict]] #: Saved dialog state (last directory and selected filter) dialog_state = settings.Setting({ "directory": "", @@ -492,7 +641,7 @@ class Error(widget.OWWidget.Error): # we added column type guessing to this widget, which breaks compatibility # with older saved workflows, where types not guessed differently, when # compatibility_mode=True widget have older guessing behaviour - settings_version = 2 + settings_version = 3 compatibility_mode = settings.Setting(False, schema_only=True) MaxHistorySize = 50 @@ -503,6 +652,7 @@ class Error(widget.OWWidget.Error): def __init__(self, *args, **kwargs): super().__init__(self, *args, **kwargs) + self.settingsAboutToBePacked.connect(self._saveState) self.__committimer = QTimer(self, singleShot=True) self.__committimer.timeout.connect(self.commit) @@ -514,11 +664,12 @@ def __init__(self, *args, **kwargs): grid = QGridLayout() grid.addWidget(QLabel("File:", self), 0, 0, 1, 1) - self.import_items_model = QStandardItemModel(self) - self.recent_combo = QComboBox( + self.import_items_model = VarPathItemModel(self) + self.import_items_model.setReplacementEnv(self._replacements()) + self.recent_combo = ItemStyledComboBox( self, objectName="recent-combo", toolTip="Recent files.", sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, - minimumContentsLength=16, + minimumContentsLength=16, placeholderText="Recent files…" ) self.recent_combo.setModel(self.import_items_model) self.recent_combo.activated.connect(self.activate_recent) @@ -528,6 +679,20 @@ def __init__(self, *args, **kwargs): "…", icon=self.style().standardIcon(QStyle.SP_DirOpenIcon), toolTip="Browse filesystem", autoDefault=False, ) + # A button drop down menu with selection of explicit workflow dir + # relative import. This is only enabled when 'basedir' workflow env + # is set. XXX: Always use menu, disable Import relative... action? + self.browse_menu = menu = QMenu(self.browse_button) + ac = menu.addAction("Import any file…") + ac.triggered.connect(self.browse) + + ac = menu.addAction("Import relative to workflow file…") + ac.setToolTip("Import a file within the workflow file directory") + ac.triggered.connect(lambda: self.browse_relative("basedir")) + + if "basedir" in self._replacements(): + self.browse_button.setMenu(menu) + self.browse_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.browse_button.clicked.connect(self.browse) grid.addWidget(self.recent_combo, 0, 1, 1, 1) @@ -587,90 +752,170 @@ def update_buttons(cbindex): self._restoreState() item = self.current_item() if item is not None: - self.set_selected_file(item.path(), item.options()) + self._invalidate() + + def workflowEnvChanged(self, key, value, oldvalue): + super().workflowEnvChanged(key, value, oldvalue) + if key == "basedir": + self.browse_button.setMenu(self.browse_menu) + self.import_items_model.setReplacementEnv(self._replacements()) @Slot(int) def activate_recent(self, index): """ Activate an item from the recent list. """ - if 0 <= index < self.import_items_model.rowCount(): - item = self.import_items_model.item(index) - assert item is not None - path = item.data(ImportItem.PathRole) - opts = item.data(ImportItem.OptionsRole) - if not isinstance(opts, Options): - opts = None - self.set_selected_file(path, opts) + model = self.import_items_model + cb = self.recent_combo + if 0 <= index < model.rowCount(): + item = model.item(index) + assert isinstance(item, ImportItem) + path = item.path() + item.setData(True, ImportItem.IsSessionItemRole) + move_item_to_index(model, item, 0) + if not os.path.exists(path): + self._browse_for_missing( + item, onfinished=lambda status: self._invalidate() + ) + else: + cb.setCurrentIndex(0) + self._invalidate() else: self.recent_combo.setCurrentIndex(-1) - @Slot() - def browse(self): - """ - Open a file dialog and select a user specified file. - """ - formats = [ - "Text - comma separated (*.csv, *)", - "Text - tab separated (*.tsv, *)", - "Text - all files (*)" - ] - - dlg = QFileDialog( - self, windowTitle="Open Data File", + def _browse_for_missing( + self, item: ImportItem, *, onfinished: Optional[Callable[[int], Any]] = None): + dlg = self._browse_dialog() + model = self.import_items_model + + if onfinished is None: + onfinished = lambda status: None + + vpath = item.varPath() + prefixpath = None + if isinstance(vpath, PathItem.VarPath): + prefixpath = self._replacements().get(vpath.name) + if prefixpath is not None: + dlg.setDirectory(prefixpath) + dlg.setAttribute(Qt.WA_DeleteOnClose) + + def accepted(): + path = dlg.selectedFiles()[0] + if isinstance(vpath, VarPath) and not isprefixed(prefixpath, path): + mb = self._path_must_be_relative_mb(prefixpath) + mb.show() + mb.finished.connect(lambda _: onfinished(QDialog.Rejected)) + return + + # pre-flight check; try to determine the nature of the file + mtype = _mime_type_for_path(path) + if not mtype.inherits("text/plain"): + mb = self._might_be_binary_mb(path) + if mb.exec() == QMessageBox.Cancel: + if onfinished: + onfinished(QDialog.Rejected) + return + + if isinstance(vpath, VarPath): + vpath_ = VarPath(vpath.name, os.path.relpath(path, prefixpath)) + else: + vpath_ = AbsPath(path) + item.setVarPath(vpath_) + if item.row() != 0: + move_item_to_index(model, item, 0) + item.setData(True, ImportItem.IsSessionItemRole) + self.set_selected_file(path, item.options()) + self._note_recent(path, item.options()) + onfinished(QDialog.Accepted) + + dlg.accepted.connect(accepted) + dlg.open() + + def _browse_dialog(self): + dlg = FileDialog( + self, windowTitle=self.tr("Open Data File"), acceptMode=QFileDialog.AcceptOpen, fileMode=QFileDialog.ExistingFile ) - dlg.setNameFilters(formats) + + dlg.setFileFormats(FileFormats) state = self.dialog_state lastdir = state.get("directory", "") lastfilter = state.get("filter", "") - if lastdir and os.path.isdir(lastdir): dlg.setDirectory(lastdir) if lastfilter: dlg.selectNameFilter(lastfilter) + def store_state(): + state["directory"] = dlg.directory().absolutePath() + state["filter"] = dlg.selectedNameFilter() + dlg.accepted.connect(store_state) + return dlg + + def _might_be_binary_mb(self, path) -> QMessageBox: + mb = QMessageBox( + parent=self, + windowTitle=self.tr(""), + icon=QMessageBox.Question, + text=self.tr("The '{basename}' may be a binary file.\n" + "Are you sure you want to continue?").format( + basename=os.path.basename(path)), + standardButtons=QMessageBox.Cancel | QMessageBox.Yes + ) + mb.setWindowModality(Qt.WindowModal) + return mb + + def _path_must_be_relative_mb(self, prefix: str) -> QMessageBox: + mb = QMessageBox( + parent=self, windowTitle=self.tr("Invalid path"), + icon=QMessageBox.Warning, + text=self.tr("Selected path is not within '{prefix}'").format( + prefix=prefix + ), + ) + mb.setAttribute(Qt.WA_DeleteOnClose) + return mb + + @Slot(str) + def browse_relative(self, prefixname): + path = self._replacements().get(prefixname) + self.browse(prefixname=prefixname, directory=path) + + @Slot() + def browse(self, prefixname=None, directory=None): + """ + Open a file dialog and select a user specified file. + """ + dlg = self._browse_dialog() + if directory is not None: + dlg.setDirectory(directory) + status = dlg.exec_() dlg.deleteLater() if status == QFileDialog.Accepted: - self.dialog_state["directory"] = dlg.directory().absolutePath() - self.dialog_state["filter"] = dlg.selectedNameFilter() - - selected_filter = dlg.selectedNameFilter() + selected_filter = dlg.selectedFileFormat() path = dlg.selectedFiles()[0] + if prefixname: + _prefixpath = self._replacements().get(prefixname, "") + if not isprefixed(_prefixpath, path): + mb = self._path_must_be_relative_mb(_prefixpath) + mb.show() + return + varpath = VarPath(prefixname, os.path.relpath(path, _prefixpath)) + else: + varpath = PathItem.AbsPath(path) + # pre-flight check; try to determine the nature of the file mtype = _mime_type_for_path(path) if not mtype.inherits("text/plain"): - mb = QMessageBox( - parent=self, - windowTitle="", - icon=QMessageBox.Question, - text="The '{basename}' may be a binary file.\n" - "Are you sure you want to continue?".format( - basename=os.path.basename(path)), - standardButtons=QMessageBox.Cancel | QMessageBox.Yes - ) - mb.setWindowModality(Qt.WindowModal) + mb = self._might_be_binary_mb(path) if mb.exec() == QMessageBox.Cancel: return - - # initialize dialect based on selected extension - if selected_filter in formats[:-1]: - filter_idx = formats.index(selected_filter) - if filter_idx == 0: - dialect = csv.excel() - elif filter_idx == 1: - dialect = csv.excel_tab() - else: - dialect = csv.excel_tab() - header = True - else: - try: - dialect, header = sniff_csv_with_path(path) - except Exception: # pylint: disable=broad-except - dialect, header = csv.excel(), True - + # initialize dialect based on selected format + dialect, header = default_options_for_mime_type( + path, selected_filter.mime_type, + ) options = None # Search for path in history. # If found use the stored params to initialize the import dialog @@ -698,6 +943,7 @@ def browse(self): dlg.deleteLater() if status == QDialog.Accepted: self.set_selected_file(path, dlg.options()) + self.current_item().setVarPath(varpath) def current_item(self): # type: () -> Optional[ImportItem] @@ -724,9 +970,8 @@ def _activate_import_dialog(self): ) dlg.setWindowModality(Qt.WindowModal) dlg.setAttribute(Qt.WA_DeleteOnClose) - settings = QSettings() - qualname = qname(type(self)) - settings.beginGroup(qualname) + settings = self._local_settings() + settings.beginGroup(qname(type(dlg))) size = settings.value("size", QSize(), type=QSize) # type: QSize if size.isValid(): dlg.resize(size) @@ -740,8 +985,8 @@ def _activate_import_dialog(self): def update(): newoptions = dlg.options() item.setData(newoptions, ImportItem.OptionsRole) - # update the stored item - self._add_recent(path, newoptions) + # update local recent paths list + self._note_recent(path, newoptions) if newoptions != options: self._invalidate() dlg.accepted.connect(update) @@ -788,31 +1033,30 @@ def _add_recent(self, filename, options=None): else: item = ImportItem.fromPath(filename) + # item.setData(VarPath(filename), ImportItem.VarPathRole) + item.setData(True, ImportItem.IsSessionItemRole) model.insertRow(0, item) if options is not None: item.setOptions(options) self.recent_combo.setCurrentIndex(0) - # store items to local persistent settings + + if not os.path.exists(filename): + return + self._note_recent(filename, options) + + def _note_recent(self, filename, options): + # store item to local persistent settings s = self._local_settings() arr = QSettings_readArray(s, "recent", OWCSVFileImport.SCHEMA) item = {"path": filename} if options is not None: item["options"] = json.dumps(options.as_dict()) - - arr = [item for item in arr if item.get("path") != filename] + arr = [item for item in arr if not samepath(item.get("path"), filename)] arr.append(item) QSettings_writeArray(s, "recent", arr) - # update workflow session items - items = self._session_items[:] - idx = index_where(items, lambda t: samepath(t[0], filename)) - if idx is not None: - del items[idx] - items.insert(0, (filename, options.as_dict())) - self._session_items = items[:OWCSVFileImport.MaxHistorySize] - def _invalidate(self): # Invalidate the current output and schedule a new commit call. # (NOTE: The widget enters a blocking state) @@ -837,8 +1081,8 @@ def commit(self): item = self.current_item() if item is None: return - path = item.data(ImportItem.PathRole) - opts = item.data(ImportItem.OptionsRole) + path = item.path() + opts = item.options() if not isinstance(opts, Options): return @@ -1017,66 +1261,105 @@ def itemsFromSettings(self): items.append((path, opts)) return items[::-1] + def _replacements(self) -> Mapping[str, str]: + replacements = [] + basedir = self.workflowEnv().get("basedir", None) + if basedir is not None: + replacements += [('basedir', basedir)] + return dict(replacements) + + def _saveState(self): + session_items = [] + model = self.import_items_model + for item in map(model.item, range(model.rowCount())): + if isinstance(item, ImportItem) and item.data(ImportItem.IsSessionItemRole): + vp = item.data(VarPathItem.VarPathRole) + session_items.append((vp.as_dict(), item.options().as_dict())) + self._session_items_v2 = session_items + def _restoreState(self): # Restore the state. Merge session (workflow) items with the # local history. model = self.import_items_model + model.setReplacementEnv(self._replacements()) + # local history items = self.itemsFromSettings() # stored session items sitems = [] - for p, m in self._session_items: + # replacements = self._replacements() + for p, m in self._session_items_v2: try: - item_ = (p, Options.from_dict(m)) - except (csv.Error, LookupError): - # Is it better to fail then to lose a item slot? + p, m = (PathItem.from_dict(p), Options.from_dict(m)) + except (csv.Error, LookupError, ValueError): _log.error("Failed to restore '%s'", p, exc_info=True) else: - sitems.append(item_) - - items = sitems + items - items = unique_everseen(items, key=lambda t: pathnormalize(t[0])) + sitems.append((p, m, True)) + items = sitems + [(PathItem.AbsPath(p), m, False) for p, m in items] + items = unique_everseen(items, key=lambda t: t[0]) curr = self.recent_combo.currentIndex() if curr != -1: currentpath = self.recent_combo.currentData(ImportItem.PathRole) else: currentpath = None - for path, options in items: + + for path, options, is_session in items: item = ImportItem.fromPath(path) item.setOptions(options) + item.setData(is_session, ImportItem.IsSessionItemRole) model.appendRow(item) - if currentpath is not None: + if currentpath: idx = self.recent_combo.findData(currentpath, ImportItem.PathRole) - if idx != -1: - self.recent_combo.setCurrentIndex(idx) + elif model.data(model.index(0, 0), ImportItem.IsSessionItemRole): + # restore last (current) session item + idx = 0 + else: + idx = -1 + self.recent_combo.setCurrentIndex(idx) @classmethod def migrate_settings(cls, settings, version): if not version or version < 2: settings["compatibility_mode"] = True + if version is not None and version < 3: + items_ = settings.pop("_session_items", []) + items_v2 = [(PathItem.AbsPath(p).as_dict(), m) for p, m in items_] + settings["_session_items_v2"] = items_v2 + @singledispatch -def sniff_csv(file, samplesize=2 ** 20): +def sniff_csv(file, samplesize=2 ** 20, delimiters=None): sniffer = csv.Sniffer() sample = file.read(samplesize) - dialect = sniffer.sniff(sample) + dialect = sniffer.sniff(sample, delimiters=delimiters) dialect = textimport.Dialect( dialect.delimiter, dialect.quotechar, dialect.escapechar, dialect.doublequote, dialect.skipinitialspace, dialect.quoting ) - has_header = sniffer.has_header(sample) + has_header = HeaderSniffer(dialect).has_header(sample) return dialect, has_header +class HeaderSniffer(csv.Sniffer): + def __init__(self, dialect: csv.Dialect): + super().__init__() + self.dialect = dialect + + def sniff(self, *_args, **_kwargs): # pylint: disable=signature-differs + # return fixed constant dialect, has_header sniffs dialect itself, + # so it can't detect headers for a predefined dialect + return self.dialect + + @sniff_csv.register(str) @sniff_csv.register(bytes) -def sniff_csv_with_path(path, encoding="utf-8", samplesize=2 ** 20): +def sniff_csv_with_path(path, encoding="utf-8", samplesize=2 ** 20, delimiters=None): with _open(path, "rt", encoding=encoding) as f: - return sniff_csv(f, samplesize) + return sniff_csv(f, samplesize, delimiters) def _open(path, mode, encoding=None): @@ -1180,7 +1463,7 @@ def _mime_type_for_path(path): def load_csv(path, opts, progress_callback=None, compatibility_mode=False): - # type: (Union[AnyStr, BinaryIO], Options, ..., bool) -> pd.DataFrame + # type: (Union[AnyStr, BinaryIO], Options, Optional[Callable[[int, int], None]], bool) -> pd.DataFrame def dtype(coltype): # type: (ColumnType) -> Optional[str] if coltype == ColumnType.Numeric: @@ -1493,40 +1776,6 @@ def index_where(iterable, pred): return None - -def samepath(p1, p2): - # type: (str, str) -> bool - """ - Return True if the paths `p1` and `p2` match after case and path - normalization. - """ - return pathnormalize(p1) == pathnormalize(p2) - - -def pathnormalize(p): - """ - Normalize a path (apply both path and case normalization. - """ - return os.path.normcase(os.path.normpath(p)) - - -def prettyfypath(path): - """ - Return the path with the $HOME prefix shortened to '~/' if applicable. - - Example - ------- - >>> prettyfypath("/home/user/file.dat") - '~/file.dat' - """ - home = os.path.expanduser("~/") - home_n = pathnormalize(home) - path_n = pathnormalize(path) - if path_n.startswith(home_n): - path = os.path.join("~", os.path.relpath(path, home)) - return path - - def pandas_to_table(df): # type: (pd.DataFrame) -> Orange.data.Table """ diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 088d3a660dc..c935a73cd1d 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -1,17 +1,24 @@ -# pylint: disable=no-self-use,protected-access +# pylint: disable=no-self-use,protected-access,invalid-name,arguments-differ import unittest from unittest import mock -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager import os import io import csv import json +from typing import Type, TypeVar, Optional import numpy as np from numpy.testing import assert_array_equal -from AnyQt.QtCore import QSettings +from AnyQt.QtCore import QSettings, Qt +from AnyQt.QtGui import QIcon +from AnyQt.QtWidgets import QFileDialog +from AnyQt.QtTest import QSignalSpy + +from orangewidget.tests.utils import simulate +from orangewidget.widget import OWBaseWidget from Orange.data import DiscreteVariable, TimeVariable, ContinuousVariable, \ StringVariable @@ -19,14 +26,35 @@ from Orange.widgets.tests.base import WidgetTest, GuiTest from Orange.widgets.data import owcsvimport from Orange.widgets.data.owcsvimport import ( - pandas_to_table, ColumnType, RowSpec + OWCSVFileImport, pandas_to_table, ColumnType, RowSpec, ) +from Orange.widgets.utils.pathutils import PathItem, samepath from Orange.widgets.utils.settings import QSettings_writeArray from Orange.widgets.utils.state_summary import format_summary_details +W = TypeVar("W", bound=OWBaseWidget) + class TestOWCSVFileImport(WidgetTest): + def create_widget( + self, cls: Type[W], stored_settings: Optional[dict] = None, + reset_default_settings=True, **kwargs) -> W: + if reset_default_settings: + self.reset_default_settings(cls) + widget = cls.__new__(cls, signal_manager=self.signal_manager, + stored_settings=stored_settings, **kwargs) + widget.__init__() + + def delete(): + widget.onDeleteWidget() + widget.close() + widget.deleteLater() + + self._stack.callback(delete) + return widget + def setUp(self): + super().setUp() self._stack = ExitStack().__enter__() # patch `_local_settings` to avoid side effects, across tests fname = self._stack.enter_context(named_file("")) @@ -37,10 +65,9 @@ def setUp(self): self.widget = self.create_widget(owcsvimport.OWCSVFileImport) def tearDown(self): - self.widgets.remove(self.widget) - self.widget.onDeleteWidget() - self.widget = None + del self.widget self._stack.close() + super().tearDown() def test_basic(self): w = self.widget @@ -58,6 +85,8 @@ def test_basic(self): (range(1, 3), RowSpec.Skipped), ], ) + data_regions_path = os.path.join( + os.path.dirname(__file__), "data-regions.tab") def _check_data_regions(self, table): self.assertEqual(len(table), 3) @@ -82,7 +111,7 @@ def test_restore(self): } ) item = w.current_item() - self.assertEqual(item.path(), path) + self.assertTrue(samepath(item.path(), path)) self.assertEqual(item.options(), self.data_regions_options) out = self.get_output("Data", w) self._check_data_regions(out) @@ -102,12 +131,19 @@ def test_restore_from_local(self): owcsvimport.OWCSVFileImport, ) item = w.current_item() - self.assertEqual(item.path(), path) + self.assertIsNone(item) + simulate.combobox_activate_index(w.recent_combo, 0) + item = w.current_item() + self.assertTrue(samepath(item.path(), path)) self.assertEqual(item.options(), self.data_regions_options) + data = w.settingsHandler.pack_data(w) self.assertEqual( - w._session_items, [(path, self.data_regions_options.as_dict())], - "local settings item must be recorded in _session_items when " - "activated in __init__", + data['_session_items_v2'], [ + (PathItem.AbsPath(path).as_dict(), + self.data_regions_options.as_dict()) + ], + "local settings item must be recorded in _session_items_v2 when " + "activated", ) self._check_data_regions(self.get_output("Data", w)) @@ -189,6 +225,134 @@ def test_backward_compatibility(self): self.assertIsInstance(domain["numeric2"], ContinuousVariable) self.assertIsInstance(domain["string"], StringVariable) + @staticmethod + @contextmanager + def _browse_setup(widget: OWCSVFileImport, path: str): + browse_dialog = widget._browse_dialog + with mock.patch.object(widget, "_browse_dialog") as r: + dlg = browse_dialog() + dlg.setOption(QFileDialog.DontUseNativeDialog) + dlg.selectFile(path) + dlg.exec_ = dlg.exec = lambda: QFileDialog.Accepted + r.return_value = dlg + with mock.patch.object(owcsvimport.CSVImportDialog, "exec_", + lambda _: QFileDialog.Accepted): + yield + + def test_browse(self): + widget = self.widget + path = self.data_regions_path + with self._browse_setup(widget, path): + widget.browse() + cur = widget.current_item() + self.assertIsNotNone(cur) + self.assertTrue(samepath(cur.path(), path)) + + def test_browse_prefix(self): + widget = self.widget + path = self.data_regions_path + with self._browse_setup(widget, path): + basedir = os.path.dirname(__file__) + widget.workflowEnv = lambda: {"basedir": basedir} + widget.workflowEnvChanged("basedir", basedir, "") + widget.browse_relative(prefixname="basedir") + + cur = widget.current_item() + self.assertIsNotNone(cur) + self.assertTrue(samepath(cur.path(), path)) + self.assertIsInstance(cur.varPath(), PathItem.VarPath) + + def test_browse_prefix_parent(self): + widget = self.widget + path = self.data_regions_path + + with self._browse_setup(widget, path): + basedir = os.path.join(os.path.dirname(__file__), "bs") + widget.workflowEnv = lambda: {"basedir": basedir} + widget.workflowEnvChanged("basedir", basedir, "") + mb = widget._path_must_be_relative_mb = mock.Mock() + widget.browse_relative(prefixname="basedir") + mb.assert_called() + self.assertIsNone(widget.current_item()) + + def test_browse_for_missing(self): + missing = os.path.dirname(__file__) + "/this file does not exist.csv" + widget = self.create_widget( + owcsvimport.OWCSVFileImport, stored_settings={ + "_session_items": [ + (missing, self.data_regions_options.as_dict()) + ] + } + ) + widget.activate_recent(0) + dlg = widget.findChild(QFileDialog) + assert dlg is not None + # calling selectFile when using native (macOS) dialog does not have + # an effect - at least not immediately; + dlg.setOption(QFileDialog.DontUseNativeDialog) + dlg.selectFile(self.data_regions_path) + dlg.accept() + cur = widget.current_item() + self.assertTrue(samepath(self.data_regions_path, cur.path())) + self.assertEqual( + self.data_regions_options.as_dict(), cur.options().as_dict() + ) + + def test_browse_for_missing_prefixed(self): + path = self.data_regions_path + basedir = os.path.dirname(path) + widget = self.create_widget( + owcsvimport.OWCSVFileImport, stored_settings={ + "__version__": 3, + "_session_items_v2": [ + (PathItem.VarPath("basedir", "this file does not exist.csv").as_dict(), + self.data_regions_options.as_dict())] + }, + env={"basedir": basedir} + ) + widget.activate_recent(0) + dlg = widget.findChild(QFileDialog) + assert dlg is not None + # calling selectFile when using native (macOS) dialog does not have + # an effect - at least not immediately; + dlg.setOption(QFileDialog.DontUseNativeDialog) + dlg.selectFile(path) + dlg.accept() + cur = widget.current_item() + self.assertTrue(samepath(path, cur.path())) + self.assertEqual( + cur.varPath(), PathItem.VarPath("basedir", "data-regions.tab")) + self.assertEqual( + self.data_regions_options.as_dict(), cur.options().as_dict() + ) + + def test_browse_for_missing_prefixed_parent(self): + path = self.data_regions_path + basedir = os.path.join(os.path.dirname(path), "origin1") + item = (PathItem.VarPath("basedir", + "this file does not exist.csv"), + self.data_regions_options) + widget = self.create_widget( + owcsvimport.OWCSVFileImport, stored_settings={ + "__version__": 3, + "_session_items_v2": [(item[0].as_dict(), item[1].as_dict())] + }, + env={"basedir": basedir} + ) + mb = widget._path_must_be_relative_mb = mock.Mock() + widget.activate_recent(0) + dlg = widget.findChild(QFileDialog) + assert dlg is not None + # calling selectFile when using native (macOS) dialog does not have + # an effect - at least not immediately; + dlg.setOption(QFileDialog.DontUseNativeDialog) + dlg.selectFile(path) + dlg.accept() + mb.assert_called() + cur = widget.current_item() + self.assertEqual(item[0], cur.varPath()) + self.assertEqual(item[1].as_dict(), cur.options().as_dict()) + class TestImportDialog(GuiTest): @staticmethod @@ -219,6 +383,42 @@ def test_dialog(): opts1 = d.options() +class TestModel(GuiTest): + def test_model(self): + path = TestOWCSVFileImport.data_regions_path + model = owcsvimport.VarPathItemModel() + model.setItemPrototype(owcsvimport.ImportItem()) + it1 = owcsvimport.ImportItem() + it1.setVarPath(PathItem.VarPath("prefix", "data-regions.tab")) + it2 = owcsvimport.ImportItem() + it2.setVarPath(PathItem.AbsPath(path)) + model.appendRow([it1]) + model.appendRow([it2]) + + def data(row, role): + return model.data(model.index(row, 0), role) + + self.assertIsInstance(data(0, Qt.DecorationRole), QIcon) + self.assertIsInstance(data(1, Qt.DecorationRole), QIcon) + + self.assertEqual(data(0, Qt.DisplayRole), "data-regions.tab") + self.assertEqual(data(1, Qt.DisplayRole), "data-regions.tab") + + self.assertEqual(data(0, Qt.ToolTipRole), "${prefix}/data-regions.tab (missing)") + self.assertTrue(samepath(data(1, Qt.ToolTipRole), path)) + + self.assertIsNotNone(data(0, Qt.ForegroundRole)) + self.assertIsNone(data(1, Qt.ForegroundRole)) + spy = QSignalSpy(model.dataChanged) + model.setReplacementEnv({"prefix": os.path.dirname(path)}) + self.assertSequenceEqual( + [[model.index(0, 0), model.index(1, 0), []]], + list(spy) + ) + self.assertEqual(data(0, Qt.ToolTipRole), "${prefix}/data-regions.tab") + self.assertIsNone(data(0, Qt.ForegroundRole)) + + class TestUtils(unittest.TestCase): def test_load_csv(self): contents = ( @@ -347,6 +547,14 @@ def test_open_compressed(self): with owcsvimport._open(fname, "rt", encoding="ascii") as f: self.assertEqual(content, f.read()) + def test_sniff_csv(self): + f = io.StringIO("A|B|C\n1|2|3\n1|2|3") + dialect, header = owcsvimport.sniff_csv(f) + self.assertEqual(dialect.delimiter, "|") + self.assertTrue(header) + with self.assertRaises(csv.Error): + owcsvimport.sniff_csv(f, delimiters=["."]) + def _open_write(path, mode, encoding=None): # pylint: disable=import-outside-toplevel diff --git a/Orange/widgets/utils/__init__.py b/Orange/widgets/utils/__init__.py index 21029bf8c59..6ae7adbd0ce 100644 --- a/Orange/widgets/utils/__init__.py +++ b/Orange/widgets/utils/__init__.py @@ -1,7 +1,10 @@ +import enum import inspect import sys from collections import deque -from typing import TypeVar, Callable, Any, Iterable, Optional, Hashable +from typing import ( + TypeVar, Callable, Any, Iterable, Optional, Hashable, Type, Union +) from AnyQt.QtCore import QObject @@ -81,7 +84,13 @@ def mypredicate(x): return inspect.getmembers(obj, mypredicate) -_T1 = TypeVar("_T1") +def qname(type_: type) -> str: + """Return the fully qualified name for a `type_`.""" + return "{0.__module__}.{0.__qualname__}".format(type_) + + +_T1 = TypeVar("_T1") # pylint: disable=invalid-name +_E = TypeVar("_E", bound=enum.Enum) # pylint: disable=invalid-name def apply_all(seq, op): @@ -116,3 +125,14 @@ def unique_everseen(iterable, key=None): if el_k not in seen: seen.add(el_k) yield el + + +def enum_get(etype: Type[_E], name: str, default: _T1) -> Union[_E, _T1]: + """ + Return an Enum member by `name`. If no such member exists in `etype` + return `default`. + """ + try: + return etype[name] + except LookupError: + return default diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index c41fa5d7f06..616f5e6b6c6 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -1,5 +1,73 @@ +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QBrush, QColor, QPalette, QPen, QFont, QFontMetrics +from AnyQt.QtWidgets import QStylePainter, QStyleOptionComboBox, QStyle + from orangewidget.utils.combobox import ComboBoxSearch, ComboBox __all__ = [ - "ComboBoxSearch", "ComboBox" + "ComboBoxSearch", "ComboBox", "ItemStyledComboBox" ] + + +class ItemStyledComboBox(ComboBox): + """ + A QComboBox that draws its text using current item's foreground and font + role. + + Note + ---- + Stylesheets etc. can completely ignore this. + """ + def __init__(self, *args, placeholderText="", **kwargs): + self.__placeholderText = placeholderText + super().__init__(*args, **kwargs) + + def paintEvent(self, _event) -> None: + painter = QStylePainter(self) + option = QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QStyle.CC_ComboBox, option) + foreground = self.currentData(Qt.ForegroundRole) + if isinstance(foreground, (QBrush, QColor)): + foreground = QBrush(foreground) + if foreground.style() != Qt.NoBrush: + # some styles take WindowText some just use current pen? + option.palette.setBrush(QPalette.WindowText, foreground) + option.palette.setBrush(QPalette.ButtonText, foreground) + option.palette.setBrush(QPalette.Text, foreground) + painter.setPen(QPen(foreground, painter.pen().widthF())) + font = self.currentData(Qt.FontRole) + if isinstance(font, QFont): + option.fontMetrics = QFontMetrics(font) + painter.setFont(font) + painter.drawControl(QStyle.CE_ComboBoxLabel, option) + + def placeholderText(self) -> str: + """ + Return the placeholder text. + + Returns + ------- + text : str + """ + return self.__placeholderText + + def setPlaceholderText(self, text: str): + """ + Set the placeholder text. + + This text is displayed on the checkbox when the currentIndex() == -1 + + Parameters + ---------- + text : str + """ + if self.__placeholderText != text: + self.__placeholderText = text + self.update() + + def initStyleOption(self, option: 'QStyleOptionComboBox') -> None: + super().initStyleOption(option) + if self.currentIndex() == -1: + option.currentText = self.__placeholderText + option.palette.setCurrentColorGroup(QPalette.Disabled) diff --git a/Orange/widgets/utils/pathutils.py b/Orange/widgets/utils/pathutils.py new file mode 100644 index 00000000000..46176c5016e --- /dev/null +++ b/Orange/widgets/utils/pathutils.py @@ -0,0 +1,177 @@ +import abc +import os +from typing import Optional, Mapping, NamedTuple, Type, Dict + + +class _DataType: + def __eq__(self, other): + """Equal if `other` has the same type and all elements compare equal.""" + if type(self) is not type(other): + return False + return super().__eq__(other) + + def __ne__(self, other): + return not self == other + + def __hash__(self): + return hash((type(self), super().__hash__())) + + +class PathItem(abc.ABC): + """ + Abstract data type representing an optionally variable prefixed path. + Has only two type members: `AbsPath` and `VarPath` + """ + def exists(self, env: Mapping[str, str]) -> bool: + """Does path exists when evaluated in `env`.""" + return self.resolve(env) is not None + + @abc.abstractmethod + def resolve(self, env: Mapping[str, str]) -> Optional[str]: + """Resolve (evaluate) path to an absolute path. Return None if path + does not resolve or does not exist. + """ + raise NotImplementedError + + @abc.abstractmethod + def as_dict(self) -> Dict[str, str]: + """Encode item as dict""" + raise NotImplementedError + + @staticmethod + def from_dict(data: Mapping[str, str]) -> 'PathItem': + """Inverse of `as_dict`""" + try: + type_ = data["type"] + if type_ == "AbsPath": + return AbsPath(data["path"]) + elif type_ == "VarPath": + return VarPath(data["name"], data["relpath"]) + else: + raise ValueError(f"{type_}: unknown type") + except KeyError as err: + raise ValueError() from err + + # Forward declarations for type members + AbsPath: Type['AbsPath'] + VarPath: Type['VarPath'] + + +class AbsPath(_DataType, NamedTuple("AbsPath", [("path", str)]), PathItem): + """ + An absolute path (no var env substitution). + """ + def __new__(cls, path): + path = os.path.abspath(os.path.normpath(path)) + if os.name == "nt": + # Always store paths using a cross platform compatible sep + path = path.replace(os.path.sep, "/") + return super().__new__(cls, path) + + def resolve(self, env: Mapping[str, str]) -> Optional[str]: + return self.path if os.path.exists(self.path) else None + + def as_dict(self) -> Dict[str, str]: + return {"type": "AbsPath", "path": self.path} + + +class VarPath(_DataType, NamedTuple("VarPath", [("name", str), ("relpath", str)]), + PathItem): + """ + A variable prefix path. `name` is the prefix name and `relpath` the path + relative to prefix. + """ + def __new__(cls, name, relpath): + relpath = os.path.normpath(relpath) + if relpath.startswith(os.path.pardir): + raise ValueError("invalid relpath '{}'".format(relpath)) + if os.name == "nt": + relpath = relpath.replace(os.path.sep, "/") + return super().__new__(cls, name, relpath) + + def resolve(self, env: Mapping[str, str]) -> Optional[str]: + prefix = env.get(self.name, None) + if prefix is not None: + path = os.path.join(prefix, self.relpath) + return path if os.path.exists(path) else None + return None + + def as_dict(self) -> Dict[str, str]: + return {"type": "VarPath", "name": self.name, "relpath": self.relpath} + + +PathItem.AbsPath = AbsPath +PathItem.VarPath = VarPath + + +def infer_prefix(path, env) -> Optional[VarPath]: + """ + Create a PrefixRelative item inferring a suitable prefix name and relpath. + + Parameters + ---------- + path : str + File system path. + env : List[Tuple[str, str]] + A sequence of (NAME, basepath) pairs. The sequence is searched + for a item such that basepath/relpath == path and the + VarPath(NAME, relpath) is returned. + (note: the first matching prefixed path is chosen). + + Returns + ------- + varpath : VarPath + """ + abspath = os.path.abspath(path) + for sname, basepath in env: + if isprefixed(basepath, abspath): + relpath = os.path.relpath(abspath, basepath) + return VarPath(sname, relpath) + return None + + +def isprefixed(prefix, path): + """ + Is `path` contained within the directory `prefix`. + + >>> isprefixed("/usr/local/", "/usr/local/shared") + True + """ + normalize = lambda path: os.path.normcase(os.path.normpath(path)) + prefix, path = normalize(prefix), normalize(path) + if not prefix.endswith(os.path.sep): + prefix = prefix + os.path.sep + return os.path.commonprefix([prefix, path]) == prefix + + +def samepath(p1, p2): + # type: (str, str) -> bool + """ + Return True if the paths `p1` and `p2` match after case and path + normalization. + """ + return pathnormalize(p1) == pathnormalize(p2) + + +def pathnormalize(p): + """ + Normalize a path (apply both path and case normalization. + """ + return os.path.normcase(os.path.normpath(p)) + + +def prettyfypath(path): + """ + Return the path with the $HOME prefix shortened to '~/' if applicable. + + Example + ------- + >>> prettyfypath("/home/user/file.dat") + '~/file.dat' + """ + home = os.path.expanduser("~/") + home_n = pathnormalize(home) + path_n = pathnormalize(path) + if path_n.startswith(home_n): + path = os.path.join("~", os.path.relpath(path, home)) + return path diff --git a/Orange/widgets/utils/settings.py b/Orange/widgets/utils/settings.py index 897c4c04cb2..0eccefa1aa7 100644 --- a/Orange/widgets/utils/settings.py +++ b/Orange/widgets/utils/settings.py @@ -1,10 +1,10 @@ import typing -from typing import Any, Union, Tuple, Dict, List +from typing import Any, Union, Tuple, Dict, List, Mapping, Sequence from PyQt5.QtCore import QSettings if typing.TYPE_CHECKING: # pragma: no cover - _T = typing.TypeVar("T") + _T = typing.TypeVar("_T") #: Specification for an value in the return value of readArray #: Can be single type or a tuple of (type, defaultValue) where default #: value is used where a stored entry is missing. @@ -14,7 +14,7 @@ def QSettings_readArray(settings, key, scheme): - # type: (QSettings, str, Dict[str, ValueSpec]) -> List[Dict[str, Any]] + # type: (QSettings, str, Mapping[str, ValueSpec]) -> List[Dict[str, Any]] """ Read the whole array from a QSettings instance. @@ -61,7 +61,7 @@ def normalize_spec(spec): def QSettings_writeArray(settings, key, values): - # type: (QSettings, str, List[Dict[str, Any]]) -> None + # type: (QSettings, str, Sequence[Mapping[str, Any]]) -> None """ Write an array of values to a QSettings instance. @@ -87,7 +87,7 @@ def QSettings_writeArray(settings, key, values): def QSettings_writeArrayItem(settings, key, index, item, arraysize): - # type: (QSettings, str, int, Dict[str, Any], int) -> None + # type: (QSettings, str, int, Mapping[str, Any], int) -> None """ Write/update an array item at index. diff --git a/Orange/widgets/utils/tests/test_combobox.py b/Orange/widgets/utils/tests/test_combobox.py new file mode 100644 index 00000000000..46d92210931 --- /dev/null +++ b/Orange/widgets/utils/tests/test_combobox.py @@ -0,0 +1,21 @@ +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QFont, QColor + +from orangewidget.tests.base import GuiTest +from Orange.widgets.utils.combobox import ItemStyledComboBox + + +class TestItemStyledComboBox(GuiTest): + def test_combobox(self): + cb = ItemStyledComboBox() + cb.setPlaceholderText("...") + self.assertEqual(cb.placeholderText(), "...") + cb.grab() + cb.addItems(["1"]) + cb.setCurrentIndex(0) + model = cb.model() + model.setItemData(model.index(0, 0), { + Qt.ForegroundRole: QColor(Qt.blue), + Qt.FontRole: QFont("Windings") + }) + cb.grab() diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index 9d8daf79d9b..336db2b11d6 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -2,7 +2,7 @@ from collections import defaultdict from itertools import islice from typing import ( - Iterable, Mapping, Any, TypeVar, Type, NamedTuple, Sequence, Optional, + Iterable, Mapping, Any, TypeVar, NamedTuple, Sequence, Optional, Union, Tuple, List, Callable ) @@ -23,7 +23,7 @@ import Orange.distance from Orange.clustering import hierarchical, kmeans -from Orange.widgets.utils import colorpalettes, apply_all, itemmodels +from Orange.widgets.utils import colorpalettes, apply_all, enum_get, itemmodels from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView from Orange.widgets.utils.graphicsview import GraphicsWidgetView @@ -132,20 +132,6 @@ def create_list_model( return model -E = TypeVar("E", bound=enum.Enum) # pylint: disable=invalid-name - - -def enum_get(etype: Type[E], name: str, default: E) -> E: - """ - Return an Enum member by `name`. If no such member exists in `etype` - return `default`. - """ - try: - return etype[name] - except LookupError: - return default - - class OWHeatMap(widget.OWWidget): name = "Heat Map" description = "Plot a data matrix heatmap."