From 1df11e3b2c6bfa86a92a6e4c410d785bdfd6f44f Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 18 May 2020 10:57:53 +0200 Subject: [PATCH 01/22] utils: Move and reuse enum_get --- Orange/widgets/data/owcsvimport.py | 15 ++------------- Orange/widgets/utils/__init__.py | 19 +++++++++++++++++-- Orange/widgets/visualize/owheatmap.py | 18 ++---------------- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 81c4e0533ce..dcf651984be 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -52,7 +52,7 @@ 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 ) from Orange.widgets.utils.overlay import OverlayWidget from Orange.widgets.utils.settings import ( @@ -74,17 +74,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.""" @@ -219,7 +208,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 diff --git a/Orange/widgets/utils/__init__.py b/Orange/widgets/utils/__init__.py index 21029bf8c59..57629704988 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,8 @@ def mypredicate(x): return inspect.getmembers(obj, mypredicate) -_T1 = TypeVar("_T1") +_T1 = TypeVar("_T1") # pylint: disable=invalid-name +_E = TypeVar("_E", bound=enum.Enum) # pylint: disable=invalid-name def apply_all(seq, op): @@ -116,3 +120,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/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." From ce83e8142a82bc3936b6c2312f61ffed2509af56 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 19 May 2020 12:42:58 +0200 Subject: [PATCH 02/22] owcsvimport: Implement relative path import --- Orange/widgets/data/owcsvimport.py | 299 +++++++++++++++++++---------- Orange/widgets/utils/pathutils.py | 177 +++++++++++++++++ 2 files changed, 379 insertions(+), 97 deletions(-) create mode 100644 Orange/widgets/utils/pathutils.py diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index dcf651984be..d0509b35b9a 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,13 +28,15 @@ import typing from typing import ( List, Tuple, Dict, Optional, Any, Callable, Iterable, - Union, AnyStr, BinaryIO, Set + Union, AnyStr, BinaryIO, Set, Type, Mapping ) 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, @@ -54,17 +57,20 @@ from Orange.widgets.utils import ( textimport, concurrent as qconcurrent, unique_everseen, enum_get ) +from Orange.widgets.utils.pathutils import ( + PathItem, VarPath, samepath, pathnormalize, prettyfypath, infer_prefix, +) from Orange.widgets.utils.overlay import OverlayWidget from Orange.widgets.utils.settings import ( QSettings_readArray, QSettings_writeArray ) 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"] @@ -110,19 +116,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. """ @@ -194,13 +199,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: @@ -380,61 +385,154 @@ 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 qname(type_: type) -> str: + """Return the fully qualified name for a `type_`.""" return "{0.__module__}.{0.__qualname__}".format(type_) @@ -472,6 +570,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": "", @@ -481,7 +581,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 @@ -492,6 +592,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) @@ -503,7 +604,8 @@ def __init__(self, *args, **kwargs): grid = QGridLayout() grid.addWidget(QLabel("File:", self), 0, 0, 1, 1) - self.import_items_model = QStandardItemModel(self) + self.import_items_model = VarPathItemModel(self) + self.import_items_model.setReplacementEnv(dict(self._replacements())) self.recent_combo = QComboBox( self, objectName="recent-combo", toolTip="Recent files.", sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, @@ -578,6 +680,11 @@ def update_buttons(cbindex): if item is not None: self.set_selected_file(item.path(), item.options()) + def workflowEnvChanged(self, key, value, oldvalue): + super().workflowEnvChanged(key, value, oldvalue) + if key == "basedir": + self.import_items_model.setReplacementEnv(dict(self._replacements())) + @Slot(int) def activate_recent(self, index): """ @@ -777,6 +884,8 @@ 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: @@ -794,14 +903,6 @@ def _add_recent(self, filename, options=None): 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) @@ -1006,6 +1107,25 @@ def itemsFromSettings(self): items.append((path, opts)) return items[::-1] + def _replacements(self) -> List[Tuple[str, str]]: + replacements = [] + basedir = self.workflowEnv().get("basedir", None) + if basedir is not None: + replacements += [('basedir', basedir)] + return replacements + + def _saveState(self): + replacements = self._replacements() + session_items = [] + model = self.import_items_model + for item in map(model.item, range(model.rowCount())): + if item.data(ImportItem.IsSessionItemRole): + vp = infer_prefix(item.path(), replacements) + if vp is None: + vp = VarPath.AbsPath(item.path()) + 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. @@ -1014,17 +1134,31 @@ def _restoreState(self): 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 = (VarPath.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_) + sitems.append((p, m)) items = sitems + items - items = unique_everseen(items, key=lambda t: pathnormalize(t[0])) + replacements = self._replacements() + + def key(t: Union[VarPath, str]) -> str: + if isinstance(t, PathItem): + p = t.resolve(dict(replacements)) + if p is not None: + return pathnormalize(p) + else: + return t + # p = t.abspath + else: + p = t + return pathnormalize(p) + + items = unique_everseen(items, key=lambda t: key(t[0])) curr = self.recent_combo.currentIndex() if curr != -1: @@ -1046,6 +1180,11 @@ 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): @@ -1169,7 +1308,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: @@ -1482,40 +1621,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/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 From 44ecc4fb4acfb9edce8b50ffb3abbc3f465cdda4 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Thu, 21 May 2020 16:01:24 +0200 Subject: [PATCH 03/22] owcsvimport: Browse for missing --- Orange/widgets/data/owcsvimport.py | 95 +++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 21 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index d0509b35b9a..8b8d97db8b5 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -697,21 +697,53 @@ def activate_recent(self, index): opts = item.data(ImportItem.OptionsRole) if not isinstance(opts, Options): opts = None - self.set_selected_file(path, opts) + if not os.path.exists(path): + vpath = item.data(ImportItem.VarPathRole) + if isinstance(vpath, VarPath): + startdir = dict(self._replacements()).get(vpath.name, None) + else: + startdir = None + self._browse_for_missing(item, startdir=startdir) + else: + self.set_selected_file(path, opts) else: self.recent_combo.setCurrentIndex(-1) - @Slot() - def browse(self): - """ - Open a file dialog and select a user specified file. - """ + def _browse_for_missing(self, item: ImportItem, startdir: Optional[str] = None): + dlg = self._browse_dialog() + if startdir is not None: + dlg.setDirectory(startdir) + dlg.setAttribute(Qt.WA_DeleteOnClose) + model = self.import_items_model + + def accepted(): + path = dlg.selectedFiles()[0] + # 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: + return + + item.setPath(path) + item.setData(infer_prefix(path, self._replacements()), + ImportItem.VarPathRole) + assert item.model() is model + [item_, ] = model.takeRow(item.row()) + assert item_ is item + model.insertRow(0, [item]) + item.setData(True, ImportItem.IsSessionItemRole) + self.set_selected_file(path, item.options()) + + dlg.accepted.connect(accepted) + dlg.open() + + def _browse_dialog(self): formats = [ "Text - comma separated (*.csv, *)", "Text - tab separated (*.tsv, *)", "Text - all files (*)" ] - dlg = QFileDialog( self, windowTitle="Open Data File", acceptMode=QFileDialog.AcceptOpen, @@ -721,33 +753,50 @@ def browse(self): 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 + + @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 = self._browse_dialog() 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() path = dlg.selectedFiles()[0] # 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 @@ -892,6 +941,10 @@ def _add_recent(self, filename, options=None): item.setOptions(options) self.recent_combo.setCurrentIndex(0) + + if not os.path.exists(filename): + return + # store items to local persistent settings s = self._local_settings() arr = QSettings_readArray(s, "recent", OWCSVFileImport.SCHEMA) From 2136d92e5e339e0606b1babe7afc30f8efd7966f Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 3 Jun 2020 13:30:16 +0200 Subject: [PATCH 04/22] combobox: Add an ItemStyledComboBox utility --- Orange/widgets/utils/combobox.py | 36 +++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index c41fa5d7f06..46439cadc10 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -1,5 +1,39 @@ +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 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) From e004f024eeee5ed28e0229fc6983f4f98f762352 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 23 Jun 2020 13:00:07 +0200 Subject: [PATCH 05/22] combobox: Add placeholderText --- Orange/widgets/data/owcsvimport.py | 2 +- Orange/widgets/utils/combobox.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 8b8d97db8b5..095de68da01 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -609,7 +609,7 @@ def __init__(self, *args, **kwargs): self.recent_combo = QComboBox( self, objectName="recent-combo", toolTip="Recent files.", sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, - minimumContentsLength=16, + minimumContentsLength=16, placeholderText="..." ) self.recent_combo.setModel(self.import_items_model) self.recent_combo.activated.connect(self.activate_recent) diff --git a/Orange/widgets/utils/combobox.py b/Orange/widgets/utils/combobox.py index 46439cadc10..616f5e6b6c6 100644 --- a/Orange/widgets/utils/combobox.py +++ b/Orange/widgets/utils/combobox.py @@ -18,6 +18,10 @@ class ItemStyledComboBox(ComboBox): ---- 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() @@ -37,3 +41,33 @@ def paintEvent(self, _event) -> None: 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) From c6f4040a00a96ccc78e1123583757029d21915a3 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 23 Jun 2020 13:00:18 +0200 Subject: [PATCH 06/22] combobox: Add basic test for ItemStyledComboBox --- Orange/widgets/utils/tests/test_combobox.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Orange/widgets/utils/tests/test_combobox.py 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() From 83474c0fed0246ca365e5e5addd3da7d7e488a42 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 3 Jun 2020 13:35:56 +0200 Subject: [PATCH 07/22] owcsvimport: Use ItemStyledComboBox --- Orange/widgets/data/owcsvimport.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 095de68da01..8841b7f8ddc 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -57,6 +57,7 @@ from Orange.widgets.utils import ( textimport, concurrent as qconcurrent, unique_everseen, enum_get ) +from Orange.widgets.utils.combobox import ItemStyledComboBox from Orange.widgets.utils.pathutils import ( PathItem, VarPath, samepath, pathnormalize, prettyfypath, infer_prefix, ) @@ -606,10 +607,10 @@ def __init__(self, *args, **kwargs): self.import_items_model = VarPathItemModel(self) self.import_items_model.setReplacementEnv(dict(self._replacements())) - self.recent_combo = QComboBox( + self.recent_combo = ItemStyledComboBox( self, objectName="recent-combo", toolTip="Recent files.", sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, - minimumContentsLength=16, placeholderText="..." + minimumContentsLength=16, placeholderText="Recent files…" ) self.recent_combo.setModel(self.import_items_model) self.recent_combo.activated.connect(self.activate_recent) From 63d727de7f5c05ffc6f200daad2a4148af6b1180 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 22 Jun 2020 14:26:52 +0200 Subject: [PATCH 08/22] owcsvimport: Fix settings namespace for import options dialog --- Orange/widgets/data/owcsvimport.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 8841b7f8ddc..fd016ea0c34 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -870,9 +870,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) From 79a66085aa773e9118b5f2803048a3bcbeee5972 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 22 Jun 2020 14:41:56 +0200 Subject: [PATCH 09/22] utils: Move qname to utils --- Orange/widgets/data/owcsvimport.py | 7 +------ Orange/widgets/utils/__init__.py | 5 +++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index fd016ea0c34..de6ded3aa5c 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -55,7 +55,7 @@ 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, enum_get + textimport, concurrent as qconcurrent, unique_everseen, enum_get, qname ) from Orange.widgets.utils.combobox import ItemStyledComboBox from Orange.widgets.utils.pathutils import ( @@ -532,11 +532,6 @@ def resolve(self, vpath: PathItem) -> Optional[str]: return vpath.resolve(self.replacementEnv()) -def qname(type_: type) -> str: - """Return the fully qualified name for a `type_`.""" - return "{0.__module__}.{0.__qualname__}".format(type_) - - class OWCSVFileImport(widget.OWWidget): name = "CSV File Import" description = "Import a data table from a CSV formatted file." diff --git a/Orange/widgets/utils/__init__.py b/Orange/widgets/utils/__init__.py index 57629704988..6ae7adbd0ce 100644 --- a/Orange/widgets/utils/__init__.py +++ b/Orange/widgets/utils/__init__.py @@ -84,6 +84,11 @@ def mypredicate(x): return inspect.getmembers(obj, mypredicate) +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 From 0d47f473c54c9456f4fe4bc30f5ce0735b665221 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 23 Jun 2020 13:50:48 +0200 Subject: [PATCH 10/22] owcsvimport: Explicit relative dir import Import workflow relative files with explicit menu action --- Orange/widgets/data/owcsvimport.py | 193 +++++++++++++++++++---------- 1 file changed, 126 insertions(+), 67 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index de6ded3aa5c..7ced68f0895 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -40,7 +40,7 @@ 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 @@ -59,7 +59,7 @@ ) from Orange.widgets.utils.combobox import ItemStyledComboBox from Orange.widgets.utils.pathutils import ( - PathItem, VarPath, samepath, pathnormalize, prettyfypath, infer_prefix, + PathItem, VarPath, AbsPath, samepath, prettyfypath, isprefixed, ) from Orange.widgets.utils.overlay import OverlayWidget from Orange.widgets.utils.settings import ( @@ -532,6 +532,15 @@ 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 OWCSVFileImport(widget.OWWidget): name = "CSV File Import" description = "Import a data table from a CSV formatted file." @@ -601,7 +610,7 @@ def __init__(self, *args, **kwargs): grid.addWidget(QLabel("File:", self), 0, 0, 1, 1) self.import_items_model = VarPathItemModel(self) - self.import_items_model.setReplacementEnv(dict(self._replacements())) + self.import_items_model.setReplacementEnv(self._replacements()) self.recent_combo = ItemStyledComboBox( self, objectName="recent-combo", toolTip="Recent files.", sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, @@ -615,6 +624,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) @@ -679,57 +702,76 @@ def update_buttons(cbindex): def workflowEnvChanged(self, key, value, oldvalue): super().workflowEnvChanged(key, value, oldvalue) if key == "basedir": - self.import_items_model.setReplacementEnv(dict(self._replacements())) + 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 + 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): - vpath = item.data(ImportItem.VarPathRole) - if isinstance(vpath, VarPath): - startdir = dict(self._replacements()).get(vpath.name, None) - else: - startdir = None - self._browse_for_missing(item, startdir=startdir) + self._browse_for_missing( + item, onfinished=lambda status: self._invalidate() + ) else: - self.set_selected_file(path, opts) + cb.setCurrentIndex(0) + self._invalidate() else: self.recent_combo.setCurrentIndex(-1) - def _browse_for_missing(self, item: ImportItem, startdir: Optional[str] = None): + def _browse_for_missing( + self, item: ImportItem, *, onfinished: Optional[Callable[[int], Any]] = None): dlg = self._browse_dialog() - if startdir is not None: - dlg.setDirectory(startdir) - dlg.setAttribute(Qt.WA_DeleteOnClose) 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 - item.setPath(path) - item.setData(infer_prefix(path, self._replacements()), - ImportItem.VarPathRole) - assert item.model() is model - [item_, ] = model.takeRow(item.row()) - assert item_ is item - model.insertRow(0, [item]) + 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() @@ -773,22 +815,51 @@ def _might_be_binary_mb(self, path) -> QMessageBox: 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): + def browse(self, prefixname=None, directory=None): """ Open a file dialog and select a user specified file. """ formats = [ "Text - comma separated (*.csv, *)", - "Text - tab separated (*.tsv, *)", + "Text - tab separated (*.tsv, *.tab, *)", "Text - all files (*)" ] dlg = self._browse_dialog() + if directory is not None: + dlg.setDirectory(directory) + status = dlg.exec_() dlg.deleteLater() if status == QFileDialog.Accepted: selected_filter = dlg.selectedNameFilter() 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"): @@ -839,6 +910,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] @@ -880,8 +952,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) @@ -939,15 +1011,16 @@ def _add_recent(self, filename, options=None): if not os.path.exists(filename): return + self._note_recent(filename, options) - # store items to local persistent settings + 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) @@ -975,8 +1048,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 @@ -1155,22 +1228,19 @@ def itemsFromSettings(self): items.append((path, opts)) return items[::-1] - def _replacements(self) -> List[Tuple[str, str]]: + def _replacements(self) -> Mapping[str, str]: replacements = [] basedir = self.workflowEnv().get("basedir", None) if basedir is not None: replacements += [('basedir', basedir)] - return replacements + return dict(replacements) def _saveState(self): - replacements = self._replacements() session_items = [] model = self.import_items_model for item in map(model.item, range(model.rowCount())): - if item.data(ImportItem.IsSessionItemRole): - vp = infer_prefix(item.path(), replacements) - if vp is None: - vp = VarPath.AbsPath(item.path()) + 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 @@ -1178,47 +1248,36 @@ 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 = [] - replacements = self._replacements() + # replacements = self._replacements() for p, m in self._session_items_v2: try: - p, m = (VarPath.from_dict(p), Options.from_dict(m)) + 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((p, m)) - - items = sitems + items - replacements = self._replacements() - - def key(t: Union[VarPath, str]) -> str: - if isinstance(t, PathItem): - p = t.resolve(dict(replacements)) - if p is not None: - return pathnormalize(p) - else: - return t - # p = t.abspath - else: - p = t - return pathnormalize(p) - - items = unique_everseen(items, key=lambda t: key(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) From 74cd3e7d4d5aaa0f76a1c44f1660f6a623fbd75d Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 23 Jun 2020 13:51:37 +0200 Subject: [PATCH 11/22] owcsvimport: Non-eager start --- Orange/widgets/data/owcsvimport.py | 10 +++++++--- Orange/widgets/data/tests/test_owcsvimport.py | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 7ced68f0895..7da584cbd00 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -697,7 +697,7 @@ 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) @@ -1279,8 +1279,12 @@ def _restoreState(self): 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): diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 088d3a660dc..2b5aa2f6ab6 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -13,6 +13,8 @@ from AnyQt.QtCore import QSettings +from orangewidget.tests.utils import simulate + from Orange.data import DiscreteVariable, TimeVariable, ContinuousVariable, \ StringVariable from Orange.tests import named_file @@ -21,6 +23,7 @@ from Orange.widgets.data.owcsvimport import ( pandas_to_table, ColumnType, RowSpec ) +from Orange.widgets.utils.pathutils import PathItem from Orange.widgets.utils.settings import QSettings_writeArray from Orange.widgets.utils.state_summary import format_summary_details @@ -102,12 +105,19 @@ def test_restore_from_local(self): owcsvimport.OWCSVFileImport, ) item = w.current_item() + self.assertIsNone(item) + simulate.combobox_activate_index(w.recent_combo, 0) + item = w.current_item() self.assertEqual(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)) From beebaa5e5ed034d6767ef1290f249acf180573ff Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 1 Jul 2020 13:37:56 +0200 Subject: [PATCH 12/22] utils/settings: Generalize type hints --- Orange/widgets/utils/settings.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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. From d45d341d8726fd269e0f0c5f26fbaf28bcadecf4 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 1 Jul 2020 13:48:24 +0200 Subject: [PATCH 13/22] test_owcsvimport: Fix path eq test --- Orange/widgets/data/tests/test_owcsvimport.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 2b5aa2f6ab6..b710d293ad8 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -23,7 +23,7 @@ from Orange.widgets.data.owcsvimport import ( pandas_to_table, ColumnType, RowSpec ) -from Orange.widgets.utils.pathutils import PathItem +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 @@ -85,7 +85,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) @@ -108,7 +108,7 @@ def test_restore_from_local(self): self.assertIsNone(item) simulate.combobox_activate_index(w.recent_combo, 0) item = w.current_item() - self.assertEqual(item.path(), path) + self.assertTrue(samepath(item.path(), path)) self.assertEqual(item.options(), self.data_regions_options) data = w.settingsHandler.pack_data(w) self.assertEqual( From 93e8b5e814c50627c4e7ff6b23c7d08a110421c6 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 10 Jul 2020 14:49:29 +0200 Subject: [PATCH 14/22] owcsvimport: Improve sniff_csv Use already determined dialect to infer header --- Orange/widgets/data/owcsvimport.py | 21 ++++++++++++++----- Orange/widgets/data/tests/test_owcsvimport.py | 8 +++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 7da584cbd00..1660d4312cb 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -1298,24 +1298,35 @@ def migrate_settings(cls, settings, version): @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): diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index b710d293ad8..7421dda83d1 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -357,6 +357,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 From a11a42ae14b5a366296d190a56daa8ee63cbca6f Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 10 Jul 2020 14:45:28 +0200 Subject: [PATCH 15/22] owcsvimport: FileDialog subclass --- Orange/widgets/data/owcsvimport.py | 97 ++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/Orange/widgets/data/owcsvimport.py b/Orange/widgets/data/owcsvimport.py index 1660d4312cb..8b22c9929ad 100644 --- a/Orange/widgets/data/owcsvimport.py +++ b/Orange/widgets/data/owcsvimport.py @@ -28,7 +28,7 @@ import typing from typing import ( List, Tuple, Dict, Optional, Any, Callable, Iterable, - Union, AnyStr, BinaryIO, Set, Type, Mapping + Union, AnyStr, BinaryIO, Set, Type, Mapping, Sequence, NamedTuple ) from PyQt5.QtCore import ( @@ -541,6 +541,61 @@ def move_item_to_index(model: QStandardItemModel, item: QStandardItem, index: in 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)})" + + 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): name = "CSV File Import" description = "Import a data table from a CSV formatted file." @@ -777,17 +832,13 @@ def accepted(): dlg.open() def _browse_dialog(self): - formats = [ - "Text - comma separated (*.csv, *)", - "Text - tab separated (*.tsv, *)", - "Text - all files (*)" - ] - dlg = QFileDialog( - self, windowTitle="Open Data File", + 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", "") @@ -836,11 +887,6 @@ def browse(self, prefixname=None, directory=None): """ Open a file dialog and select a user specified file. """ - formats = [ - "Text - comma separated (*.csv, *)", - "Text - tab separated (*.tsv, *.tab, *)", - "Text - all files (*)" - ] dlg = self._browse_dialog() if directory is not None: dlg.setDirectory(directory) @@ -848,7 +894,7 @@ def browse(self, prefixname=None, directory=None): status = dlg.exec_() dlg.deleteLater() if status == QFileDialog.Accepted: - selected_filter = dlg.selectedNameFilter() + selected_filter = dlg.selectedFileFormat() path = dlg.selectedFiles()[0] if prefixname: _prefixpath = self._replacements().get(prefixname, "") @@ -866,23 +912,10 @@ def browse(self, prefixname=None, directory=None): 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 From 5c47bb143e27a75f5f70ef9bbf981b4f30d18868 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 15 Jul 2020 12:22:37 +0200 Subject: [PATCH 16/22] owcsvimport: Add browse_for_missing test --- Orange/widgets/data/tests/test_owcsvimport.py | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 7421dda83d1..24f73ffc1a1 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -7,13 +7,16 @@ 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.QtWidgets import QFileDialog from orangewidget.tests.utils import simulate +from orangewidget.widget import OWBaseWidget from Orange.data import DiscreteVariable, TimeVariable, ContinuousVariable, \ StringVariable @@ -27,9 +30,29 @@ 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("")) @@ -40,10 +63,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 @@ -61,6 +83,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) @@ -199,6 +223,32 @@ def test_backward_compatibility(self): self.assertIsInstance(domain["numeric2"], ContinuousVariable) self.assertIsInstance(domain["string"], StringVariable) + 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() + self.assertTrue(samepath( + self.data_regions_path, + widget.recent_combo.currentData(owcsvimport.ImportItem.PathRole) + )) + self.assertEqual( + self.data_regions_options.as_dict(), + widget.recent_combo.currentData(owcsvimport.ImportItem.OptionsRole).as_dict() + ) + class TestImportDialog(GuiTest): @staticmethod From cf01981ddaa49fabacac018b3304d241da20d4e0 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 15 Jul 2020 12:49:20 +0200 Subject: [PATCH 17/22] owcsvimport: Add test for browse --- Orange/widgets/data/tests/test_owcsvimport.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 24f73ffc1a1..84fac31cbfe 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -223,6 +223,23 @@ def test_backward_compatibility(self): self.assertIsInstance(domain["numeric2"], ContinuousVariable) self.assertIsInstance(domain["string"], StringVariable) + def test_browse(self): + widget = self.widget + path = self.data_regions_path + 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): + widget.browse() + cur = widget.current_item() + self.assertIsNotNone(cur) + self.assertTrue(samepath(cur.path(), path)) + def test_browse_for_missing(self): missing = os.path.dirname(__file__) + "/this file does not exist.csv" widget = self.create_widget( From 9a9a8febc3c72785e2057d08ba296578c2ae7aac Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 15 Jul 2020 13:12:09 +0200 Subject: [PATCH 18/22] owcsvimport: Add test for prefixed import --- Orange/widgets/data/tests/test_owcsvimport.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 84fac31cbfe..563a78deb3b 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -240,6 +240,28 @@ def test_browse(self): self.assertIsNotNone(cur) self.assertTrue(samepath(cur.path(), path)) + def test_browse_prefix(self): + widget = self.widget + path = self.data_regions_path + 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): + dir = os.path.dirname(__file__) + widget.workflowEnv = lambda: {"basedir": dir} + widget.workflowEnvChanged("basedir", dir, "") + 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_for_missing(self): missing = os.path.dirname(__file__) + "/this file does not exist.csv" widget = self.create_widget( From b70ed6d9fd91eee60d4dd86aaa30b6276427a784 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 15 Jul 2020 14:10:08 +0200 Subject: [PATCH 19/22] owcsvimport: Add test_browse_prefix_parent test --- Orange/widgets/data/tests/test_owcsvimport.py | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index 563a78deb3b..cf8fb8915e0 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -1,7 +1,7 @@ -# 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 @@ -24,7 +24,7 @@ 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 @@ -223,9 +223,9 @@ def test_backward_compatibility(self): self.assertIsInstance(domain["numeric2"], ContinuousVariable) self.assertIsInstance(domain["string"], StringVariable) - def test_browse(self): - widget = self.widget - path = self.data_regions_path + @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() @@ -235,7 +235,13 @@ def test_browse(self): r.return_value = dlg with mock.patch.object(owcsvimport.CSVImportDialog, "exec_", lambda _: QFileDialog.Accepted): - widget.browse() + 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)) @@ -243,25 +249,30 @@ def test_browse(self): def test_browse_prefix(self): widget = self.widget path = self.data_regions_path - 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): - dir = os.path.dirname(__file__) - widget.workflowEnv = lambda: {"basedir": dir} - widget.workflowEnvChanged("basedir", dir, "") - widget.browse_relative(prefixname="basedir") + 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( @@ -279,13 +290,10 @@ def test_browse_for_missing(self): dlg.setOption(QFileDialog.DontUseNativeDialog) dlg.selectFile(self.data_regions_path) dlg.accept() - self.assertTrue(samepath( - self.data_regions_path, - widget.recent_combo.currentData(owcsvimport.ImportItem.PathRole) - )) + cur = widget.current_item() + self.assertTrue(samepath(self.data_regions_path, cur.path())) self.assertEqual( - self.data_regions_options.as_dict(), - widget.recent_combo.currentData(owcsvimport.ImportItem.OptionsRole).as_dict() + self.data_regions_options.as_dict(), cur.options().as_dict() ) From 563687916fd441a803e61e2fd63ce56c2ab368e9 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 15 Jul 2020 14:10:59 +0200 Subject: [PATCH 20/22] owcsvimport: Add test_browse_for_missing_prefixed test --- Orange/widgets/data/tests/test_owcsvimport.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index cf8fb8915e0..e2fb971f306 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -296,6 +296,34 @@ def test_browse_for_missing(self): 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() + ) + class TestImportDialog(GuiTest): @staticmethod From fb152ec62785f91b3c7d451fd96a378032c62f7c Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 17 Jul 2020 10:39:34 +0200 Subject: [PATCH 21/22] owcsvimport: Add test_browse_for_missing_prefixed_parent test --- Orange/widgets/data/tests/test_owcsvimport.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index e2fb971f306..aaddc894a86 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -324,6 +324,33 @@ def test_browse_for_missing_prefixed(self): 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 From 475dd3e1a5e9d10f520c6066da6dc46784aeeaab Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 20 Jul 2020 11:49:02 +0200 Subject: [PATCH 22/22] owcsvimport: Add test for model --- Orange/widgets/data/tests/test_owcsvimport.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/Orange/widgets/data/tests/test_owcsvimport.py b/Orange/widgets/data/tests/test_owcsvimport.py index aaddc894a86..c935a73cd1d 100644 --- a/Orange/widgets/data/tests/test_owcsvimport.py +++ b/Orange/widgets/data/tests/test_owcsvimport.py @@ -12,8 +12,10 @@ 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 @@ -381,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 = (