Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] CSV File Import: Add support for explicit workflow relative paths #4872

Merged
merged 22 commits into from
Sep 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1df11e3
utils: Move and reuse enum_get
ales-erjavec May 18, 2020
ce83e81
owcsvimport: Implement relative path import
ales-erjavec May 19, 2020
44ecc4f
owcsvimport: Browse for missing
ales-erjavec May 21, 2020
2136d92
combobox: Add an ItemStyledComboBox utility
ales-erjavec Jun 3, 2020
e004f02
combobox: Add placeholderText
ales-erjavec Jun 23, 2020
c6f4040
combobox: Add basic test for ItemStyledComboBox
ales-erjavec Jun 23, 2020
83474c0
owcsvimport: Use ItemStyledComboBox
ales-erjavec Jun 3, 2020
63d727d
owcsvimport: Fix settings namespace for import options dialog
ales-erjavec Jun 22, 2020
79a6608
utils: Move qname to utils
ales-erjavec Jun 22, 2020
0d47f47
owcsvimport: Explicit relative dir import
ales-erjavec Jun 23, 2020
74cd3e7
owcsvimport: Non-eager start
ales-erjavec Jun 23, 2020
beebaa5
utils/settings: Generalize type hints
ales-erjavec Jul 1, 2020
d45d341
test_owcsvimport: Fix path eq test
ales-erjavec Jul 1, 2020
93e8b5e
owcsvimport: Improve sniff_csv
ales-erjavec Jul 10, 2020
a11a42a
owcsvimport: FileDialog subclass
ales-erjavec Jul 10, 2020
5c47bb1
owcsvimport: Add browse_for_missing test
ales-erjavec Jul 15, 2020
cf01981
owcsvimport: Add test for browse
ales-erjavec Jul 15, 2020
9a9a8fe
owcsvimport: Add test for prefixed import
ales-erjavec Jul 15, 2020
b70ed6d
owcsvimport: Add test_browse_prefix_parent test
ales-erjavec Jul 15, 2020
5636879
owcsvimport: Add test_browse_for_missing_prefixed test
ales-erjavec Jul 15, 2020
fb152ec
owcsvimport: Add test_browse_for_missing_prefixed_parent test
ales-erjavec Jul 17, 2020
475dd3e
owcsvimport: Add test for model
ales-erjavec Jul 20, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
629 changes: 439 additions & 190 deletions Orange/widgets/data/owcsvimport.py

Large diffs are not rendered by default.

232 changes: 220 additions & 12 deletions Orange/widgets/data/tests/test_owcsvimport.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,60 @@
# 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
from Orange.tests import named_file
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(""))
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions Orange/widgets/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Loading