Skip to content

Commit 39c7ce8

Browse files
authored
Merge pull request #4872 from ales-erjavec/owcsvimport-rel-path
[ENH] CSV File Import: Add support for explicit workflow relative paths
2 parents 690a402 + 475dd3e commit 39c7ce8

File tree

8 files changed

+955
-226
lines changed

8 files changed

+955
-226
lines changed

Orange/widgets/data/owcsvimport.py

Lines changed: 439 additions & 190 deletions
Large diffs are not rendered by default.

Orange/widgets/data/tests/test_owcsvimport.py

Lines changed: 220 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,60 @@
1-
# pylint: disable=no-self-use,protected-access
1+
# pylint: disable=no-self-use,protected-access,invalid-name,arguments-differ
22
import unittest
33
from unittest import mock
4-
from contextlib import ExitStack
4+
from contextlib import ExitStack, contextmanager
55

66
import os
77
import io
88
import csv
99
import json
10+
from typing import Type, TypeVar, Optional
1011

1112
import numpy as np
1213
from numpy.testing import assert_array_equal
1314

14-
from AnyQt.QtCore import QSettings
15+
from AnyQt.QtCore import QSettings, Qt
16+
from AnyQt.QtGui import QIcon
17+
from AnyQt.QtWidgets import QFileDialog
18+
from AnyQt.QtTest import QSignalSpy
19+
20+
from orangewidget.tests.utils import simulate
21+
from orangewidget.widget import OWBaseWidget
1522

1623
from Orange.data import DiscreteVariable, TimeVariable, ContinuousVariable, \
1724
StringVariable
1825
from Orange.tests import named_file
1926
from Orange.widgets.tests.base import WidgetTest, GuiTest
2027
from Orange.widgets.data import owcsvimport
2128
from Orange.widgets.data.owcsvimport import (
22-
pandas_to_table, ColumnType, RowSpec
29+
OWCSVFileImport, pandas_to_table, ColumnType, RowSpec,
2330
)
31+
from Orange.widgets.utils.pathutils import PathItem, samepath
2432
from Orange.widgets.utils.settings import QSettings_writeArray
2533
from Orange.widgets.utils.state_summary import format_summary_details
2634

35+
W = TypeVar("W", bound=OWBaseWidget)
36+
2737

2838
class TestOWCSVFileImport(WidgetTest):
39+
def create_widget(
40+
self, cls: Type[W], stored_settings: Optional[dict] = None,
41+
reset_default_settings=True, **kwargs) -> W:
42+
if reset_default_settings:
43+
self.reset_default_settings(cls)
44+
widget = cls.__new__(cls, signal_manager=self.signal_manager,
45+
stored_settings=stored_settings, **kwargs)
46+
widget.__init__()
47+
48+
def delete():
49+
widget.onDeleteWidget()
50+
widget.close()
51+
widget.deleteLater()
52+
53+
self._stack.callback(delete)
54+
return widget
55+
2956
def setUp(self):
57+
super().setUp()
3058
self._stack = ExitStack().__enter__()
3159
# patch `_local_settings` to avoid side effects, across tests
3260
fname = self._stack.enter_context(named_file(""))
@@ -37,10 +65,9 @@ def setUp(self):
3765
self.widget = self.create_widget(owcsvimport.OWCSVFileImport)
3866

3967
def tearDown(self):
40-
self.widgets.remove(self.widget)
41-
self.widget.onDeleteWidget()
42-
self.widget = None
68+
del self.widget
4369
self._stack.close()
70+
super().tearDown()
4471

4572
def test_basic(self):
4673
w = self.widget
@@ -58,6 +85,8 @@ def test_basic(self):
5885
(range(1, 3), RowSpec.Skipped),
5986
],
6087
)
88+
data_regions_path = os.path.join(
89+
os.path.dirname(__file__), "data-regions.tab")
6190

6291
def _check_data_regions(self, table):
6392
self.assertEqual(len(table), 3)
@@ -82,7 +111,7 @@ def test_restore(self):
82111
}
83112
)
84113
item = w.current_item()
85-
self.assertEqual(item.path(), path)
114+
self.assertTrue(samepath(item.path(), path))
86115
self.assertEqual(item.options(), self.data_regions_options)
87116
out = self.get_output("Data", w)
88117
self._check_data_regions(out)
@@ -102,12 +131,19 @@ def test_restore_from_local(self):
102131
owcsvimport.OWCSVFileImport,
103132
)
104133
item = w.current_item()
105-
self.assertEqual(item.path(), path)
134+
self.assertIsNone(item)
135+
simulate.combobox_activate_index(w.recent_combo, 0)
136+
item = w.current_item()
137+
self.assertTrue(samepath(item.path(), path))
106138
self.assertEqual(item.options(), self.data_regions_options)
139+
data = w.settingsHandler.pack_data(w)
107140
self.assertEqual(
108-
w._session_items, [(path, self.data_regions_options.as_dict())],
109-
"local settings item must be recorded in _session_items when "
110-
"activated in __init__",
141+
data['_session_items_v2'], [
142+
(PathItem.AbsPath(path).as_dict(),
143+
self.data_regions_options.as_dict())
144+
],
145+
"local settings item must be recorded in _session_items_v2 when "
146+
"activated",
111147
)
112148
self._check_data_regions(self.get_output("Data", w))
113149

@@ -189,6 +225,134 @@ def test_backward_compatibility(self):
189225
self.assertIsInstance(domain["numeric2"], ContinuousVariable)
190226
self.assertIsInstance(domain["string"], StringVariable)
191227

228+
@staticmethod
229+
@contextmanager
230+
def _browse_setup(widget: OWCSVFileImport, path: str):
231+
browse_dialog = widget._browse_dialog
232+
with mock.patch.object(widget, "_browse_dialog") as r:
233+
dlg = browse_dialog()
234+
dlg.setOption(QFileDialog.DontUseNativeDialog)
235+
dlg.selectFile(path)
236+
dlg.exec_ = dlg.exec = lambda: QFileDialog.Accepted
237+
r.return_value = dlg
238+
with mock.patch.object(owcsvimport.CSVImportDialog, "exec_",
239+
lambda _: QFileDialog.Accepted):
240+
yield
241+
242+
def test_browse(self):
243+
widget = self.widget
244+
path = self.data_regions_path
245+
with self._browse_setup(widget, path):
246+
widget.browse()
247+
cur = widget.current_item()
248+
self.assertIsNotNone(cur)
249+
self.assertTrue(samepath(cur.path(), path))
250+
251+
def test_browse_prefix(self):
252+
widget = self.widget
253+
path = self.data_regions_path
254+
with self._browse_setup(widget, path):
255+
basedir = os.path.dirname(__file__)
256+
widget.workflowEnv = lambda: {"basedir": basedir}
257+
widget.workflowEnvChanged("basedir", basedir, "")
258+
widget.browse_relative(prefixname="basedir")
259+
260+
cur = widget.current_item()
261+
self.assertIsNotNone(cur)
262+
self.assertTrue(samepath(cur.path(), path))
263+
self.assertIsInstance(cur.varPath(), PathItem.VarPath)
264+
265+
def test_browse_prefix_parent(self):
266+
widget = self.widget
267+
path = self.data_regions_path
268+
269+
with self._browse_setup(widget, path):
270+
basedir = os.path.join(os.path.dirname(__file__), "bs")
271+
widget.workflowEnv = lambda: {"basedir": basedir}
272+
widget.workflowEnvChanged("basedir", basedir, "")
273+
mb = widget._path_must_be_relative_mb = mock.Mock()
274+
widget.browse_relative(prefixname="basedir")
275+
mb.assert_called()
276+
self.assertIsNone(widget.current_item())
277+
278+
def test_browse_for_missing(self):
279+
missing = os.path.dirname(__file__) + "/this file does not exist.csv"
280+
widget = self.create_widget(
281+
owcsvimport.OWCSVFileImport, stored_settings={
282+
"_session_items": [
283+
(missing, self.data_regions_options.as_dict())
284+
]
285+
}
286+
)
287+
widget.activate_recent(0)
288+
dlg = widget.findChild(QFileDialog)
289+
assert dlg is not None
290+
# calling selectFile when using native (macOS) dialog does not have
291+
# an effect - at least not immediately;
292+
dlg.setOption(QFileDialog.DontUseNativeDialog)
293+
dlg.selectFile(self.data_regions_path)
294+
dlg.accept()
295+
cur = widget.current_item()
296+
self.assertTrue(samepath(self.data_regions_path, cur.path()))
297+
self.assertEqual(
298+
self.data_regions_options.as_dict(), cur.options().as_dict()
299+
)
300+
301+
def test_browse_for_missing_prefixed(self):
302+
path = self.data_regions_path
303+
basedir = os.path.dirname(path)
304+
widget = self.create_widget(
305+
owcsvimport.OWCSVFileImport, stored_settings={
306+
"__version__": 3,
307+
"_session_items_v2": [
308+
(PathItem.VarPath("basedir", "this file does not exist.csv").as_dict(),
309+
self.data_regions_options.as_dict())]
310+
},
311+
env={"basedir": basedir}
312+
)
313+
widget.activate_recent(0)
314+
dlg = widget.findChild(QFileDialog)
315+
assert dlg is not None
316+
# calling selectFile when using native (macOS) dialog does not have
317+
# an effect - at least not immediately;
318+
dlg.setOption(QFileDialog.DontUseNativeDialog)
319+
dlg.selectFile(path)
320+
dlg.accept()
321+
cur = widget.current_item()
322+
self.assertTrue(samepath(path, cur.path()))
323+
self.assertEqual(
324+
cur.varPath(), PathItem.VarPath("basedir", "data-regions.tab"))
325+
self.assertEqual(
326+
self.data_regions_options.as_dict(), cur.options().as_dict()
327+
)
328+
329+
def test_browse_for_missing_prefixed_parent(self):
330+
path = self.data_regions_path
331+
basedir = os.path.join(os.path.dirname(path), "origin1")
332+
item = (PathItem.VarPath("basedir",
333+
"this file does not exist.csv"),
334+
self.data_regions_options)
335+
widget = self.create_widget(
336+
owcsvimport.OWCSVFileImport, stored_settings={
337+
"__version__": 3,
338+
"_session_items_v2": [(item[0].as_dict(), item[1].as_dict())]
339+
},
340+
env={"basedir": basedir}
341+
)
342+
mb = widget._path_must_be_relative_mb = mock.Mock()
343+
widget.activate_recent(0)
344+
dlg = widget.findChild(QFileDialog)
345+
assert dlg is not None
346+
# calling selectFile when using native (macOS) dialog does not have
347+
# an effect - at least not immediately;
348+
dlg.setOption(QFileDialog.DontUseNativeDialog)
349+
dlg.selectFile(path)
350+
dlg.accept()
351+
mb.assert_called()
352+
cur = widget.current_item()
353+
self.assertEqual(item[0], cur.varPath())
354+
self.assertEqual(item[1].as_dict(), cur.options().as_dict())
355+
192356

193357
class TestImportDialog(GuiTest):
194358
@staticmethod
@@ -219,6 +383,42 @@ def test_dialog():
219383
opts1 = d.options()
220384

221385

386+
class TestModel(GuiTest):
387+
def test_model(self):
388+
path = TestOWCSVFileImport.data_regions_path
389+
model = owcsvimport.VarPathItemModel()
390+
model.setItemPrototype(owcsvimport.ImportItem())
391+
it1 = owcsvimport.ImportItem()
392+
it1.setVarPath(PathItem.VarPath("prefix", "data-regions.tab"))
393+
it2 = owcsvimport.ImportItem()
394+
it2.setVarPath(PathItem.AbsPath(path))
395+
model.appendRow([it1])
396+
model.appendRow([it2])
397+
398+
def data(row, role):
399+
return model.data(model.index(row, 0), role)
400+
401+
self.assertIsInstance(data(0, Qt.DecorationRole), QIcon)
402+
self.assertIsInstance(data(1, Qt.DecorationRole), QIcon)
403+
404+
self.assertEqual(data(0, Qt.DisplayRole), "data-regions.tab")
405+
self.assertEqual(data(1, Qt.DisplayRole), "data-regions.tab")
406+
407+
self.assertEqual(data(0, Qt.ToolTipRole), "${prefix}/data-regions.tab (missing)")
408+
self.assertTrue(samepath(data(1, Qt.ToolTipRole), path))
409+
410+
self.assertIsNotNone(data(0, Qt.ForegroundRole))
411+
self.assertIsNone(data(1, Qt.ForegroundRole))
412+
spy = QSignalSpy(model.dataChanged)
413+
model.setReplacementEnv({"prefix": os.path.dirname(path)})
414+
self.assertSequenceEqual(
415+
[[model.index(0, 0), model.index(1, 0), []]],
416+
list(spy)
417+
)
418+
self.assertEqual(data(0, Qt.ToolTipRole), "${prefix}/data-regions.tab")
419+
self.assertIsNone(data(0, Qt.ForegroundRole))
420+
421+
222422
class TestUtils(unittest.TestCase):
223423
def test_load_csv(self):
224424
contents = (
@@ -347,6 +547,14 @@ def test_open_compressed(self):
347547
with owcsvimport._open(fname, "rt", encoding="ascii") as f:
348548
self.assertEqual(content, f.read())
349549

550+
def test_sniff_csv(self):
551+
f = io.StringIO("A|B|C\n1|2|3\n1|2|3")
552+
dialect, header = owcsvimport.sniff_csv(f)
553+
self.assertEqual(dialect.delimiter, "|")
554+
self.assertTrue(header)
555+
with self.assertRaises(csv.Error):
556+
owcsvimport.sniff_csv(f, delimiters=["."])
557+
350558

351559
def _open_write(path, mode, encoding=None):
352560
# pylint: disable=import-outside-toplevel

Orange/widgets/utils/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import enum
12
import inspect
23
import sys
34
from collections import deque
4-
from typing import TypeVar, Callable, Any, Iterable, Optional, Hashable
5+
from typing import (
6+
TypeVar, Callable, Any, Iterable, Optional, Hashable, Type, Union
7+
)
58

69
from AnyQt.QtCore import QObject
710

@@ -81,7 +84,13 @@ def mypredicate(x):
8184
return inspect.getmembers(obj, mypredicate)
8285

8386

84-
_T1 = TypeVar("_T1")
87+
def qname(type_: type) -> str:
88+
"""Return the fully qualified name for a `type_`."""
89+
return "{0.__module__}.{0.__qualname__}".format(type_)
90+
91+
92+
_T1 = TypeVar("_T1") # pylint: disable=invalid-name
93+
_E = TypeVar("_E", bound=enum.Enum) # pylint: disable=invalid-name
8594

8695

8796
def apply_all(seq, op):
@@ -116,3 +125,14 @@ def unique_everseen(iterable, key=None):
116125
if el_k not in seen:
117126
seen.add(el_k)
118127
yield el
128+
129+
130+
def enum_get(etype: Type[_E], name: str, default: _T1) -> Union[_E, _T1]:
131+
"""
132+
Return an Enum member by `name`. If no such member exists in `etype`
133+
return `default`.
134+
"""
135+
try:
136+
return etype[name]
137+
except LookupError:
138+
return default

0 commit comments

Comments
 (0)