Skip to content

Commit

Permalink
Merge pull request #6747 from ales-erjavec/owcsvimport-drop-handler
Browse files Browse the repository at this point in the history
[ENH] Open draged files on CSV Import, Load Model and Distance File widgets
  • Loading branch information
janezd committed Mar 1, 2024
2 parents ba951fa + cfb79c4 commit 0487f58
Show file tree
Hide file tree
Showing 11 changed files with 281 additions and 50 deletions.
76 changes: 63 additions & 13 deletions Orange/widgets/data/owcsvimport.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
----------------------
"""
from __future__ import annotations
import sys
import types
import os
Expand Down Expand Up @@ -32,7 +33,8 @@
)

from AnyQt.QtCore import (
Qt, QFileInfo, QTimer, QSettings, QObject, QSize, QMimeDatabase, QMimeType
Qt, QFileInfo, QTimer, QSettings, QObject, QSize, QMimeDatabase, QMimeType,
QUrl
)
from AnyQt.QtGui import (
QStandardItem, QStandardItemModel, QPalette, QColor, QIcon
Expand All @@ -50,12 +52,15 @@
from pandas import CategoricalDtype
from pandas.api import types as pdtypes

from orangecanvas.utils import assocf
from orangecanvas.utils.qobjref import qobjref_weak
from orangewidget.utils import enum_as_int

import Orange.data
from Orange.misc.collections import natural_sorted

from Orange.widgets import widget, gui, settings
from Orange.widgets.utils.filedialogs import OWUrlDropBase
from Orange.widgets.utils.localization import pl
from Orange.widgets.utils.concurrent import PyOwned
from Orange.widgets.utils import (
Expand Down Expand Up @@ -610,7 +615,7 @@ def default_options_for_mime_type(
return Options(dialect=dialect, encoding=encoding, rowspec=rowspec)


class OWCSVFileImport(widget.OWWidget):
class OWCSVFileImport(OWUrlDropBase):
name = "CSV File Import"
description = "Import a data table from a CSV formatted file."
icon = "icons/CSVFile.svg"
Expand Down Expand Up @@ -967,6 +972,24 @@ def _activate_import_dialog(self):
"""Activate the Import Options dialog for the current item."""
item = self.current_item()
assert item is not None
path = item.path()
options = item.options()

def onfinished(dlg: CSVImportDialog):
if dlg.result() != QDialog.Accepted:
return
newoptions = dlg.options()
item.setData(newoptions, ImportItem.OptionsRole)
# update local recent paths list
self._note_recent(path, newoptions)
if newoptions != options:
self._invalidate()

self._activate_import_dialog_for_item(item, finished=onfinished)

def _activate_import_dialog_for_item(
self, item: ImportItem, finished: Callable[[CSVImportDialog], None]
):
dlg = CSVImportDialog(
self, windowTitle="Import Options", sizeGripEnabled=True,
)
Expand All @@ -984,18 +1007,14 @@ def _activate_import_dialog(self):
if isinstance(options, Options):
dlg.setOptions(options)

def update():
newoptions = dlg.options()
item.setData(newoptions, ImportItem.OptionsRole)
# update local recent paths list
self._note_recent(path, newoptions)
if newoptions != options:
self._invalidate()
dlg.accepted.connect(update)
dialog_ref = qobjref_weak(dlg)

def store_size():
def onfinished():
dlg = dialog_ref()
settings.setValue("size", dlg.size())
dlg.finished.connect(store_size)
finished(dlg)

dlg.finished.connect(onfinished)
dlg.show()

def set_selected_file(self, filename, options=None):
Expand Down Expand Up @@ -1035,7 +1054,6 @@ 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)

Expand Down Expand Up @@ -1127,6 +1145,7 @@ def cancel(self):
"""
Cancel current pending or executing task.
"""
self.__committimer.stop()
if self.__watcher is not None:
self.__cancel_task()
self.__clear_running_state()
Expand Down Expand Up @@ -1311,6 +1330,37 @@ def _restoreState(self):
idx = -1
self.recent_combo.setCurrentIndex(idx)

def canDropUrl(self, url: QUrl) -> bool:
if url.isLocalFile():
return _mime_type_for_path(url.toLocalFile()).inherits("text/plain")
else:
return False

def handleDroppedUrl(self, url: QUrl) -> None:
# search recent items for path
path = url.toLocalFile()
hist = self.itemsFromSettings()
res = assocf(hist, lambda p: samepath(p, path))
if res is not None:
_, options = res
else:
mt = _mime_type_for_path(path)
options = default_options_for_mime_type(path, mt.name())
self.activate_import_for_file(path, options)

def activate_import_for_file(self, path: str, options: Options | None = None):
self.cancel() # Cancel current task if any
item = ImportItem() # dummy temp item
item.setPath(path)
item.setOptions(options)

def finished(dlg: CSVImportDialog):
if dlg.result() != QDialog.Accepted:
return
self.set_selected_file(path, dlg.options())

self._activate_import_dialog_for_item(item, finished)

@classmethod
def migrate_settings(cls, settings, version):
if not version or version < 2:
Expand Down
32 changes: 13 additions & 19 deletions Orange/widgets/data/owfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from Orange.widgets.utils.itemmodels import PyListModel
from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin, \
open_filename_dialog, stored_recent_paths_prepend
from Orange.widgets.utils.filedialogs import OWUrlDropBase
from Orange.widgets.utils.widgetpreview import WidgetPreview
from Orange.widgets.widget import Output, Msg
from Orange.widgets.utils.combobox import TextEditCombo
Expand Down Expand Up @@ -82,7 +83,7 @@ def focusInEvent(self, event):
QTimer.singleShot(0, self.selectAll)


class OWFile(widget.OWWidget, RecentPathsWComboMixin):
class OWFile(OWUrlDropBase, RecentPathsWComboMixin):
name = "File"
id = "orange.widgets.data.file"
description = "Read data from an input file or network " \
Expand Down Expand Up @@ -311,8 +312,7 @@ def package(w):

QTimer.singleShot(0, self.load_data)

@staticmethod
def sizeHint():
def sizeHint(self):
return QSize(600, 550)

def select_file(self, n):
Expand Down Expand Up @@ -632,24 +632,18 @@ def get_ext_name(filename):

self.report_data("Data", self.data)

@staticmethod
def dragEnterEvent(event):
"""Accept drops of valid file urls"""
urls = event.mimeData().urls()
if urls:
try:
FileFormat.get_reader(urls[0].toLocalFile())
event.acceptProposedAction()
except MissingReaderException:
pass

def dropEvent(self, event):
"""Handle file drops"""
urls = event.mimeData().urls()
if urls:
self.add_path(urls[0].toLocalFile()) # add first file
def canDropUrl(self, url: QUrl) -> bool:
return OWFileDropHandler().canDropUrl(url)

def handleDroppedUrl(self, url: QUrl) -> None:
if url.isLocalFile():
self.add_path(url.toLocalFile()) # add first file
self.source = self.LOCAL_FILE
self.load_data()
else:
self.url_combo.insertItem(0, url.toString())
self.url_combo.setCurrentIndex(0)
self._url_set()

def workflowEnvChanged(self, key, value, oldvalue):
"""
Expand Down
41 changes: 38 additions & 3 deletions Orange/widgets/data/tests/test_owcsvimport.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# pylint: disable=no-self-use,protected-access,invalid-name,arguments-differ
# pylint: disable=protected-access,invalid-name,arguments-differ
import tempfile
import unittest
from unittest import mock
Expand All @@ -14,7 +14,7 @@
import pandas as pd
from numpy.testing import assert_array_equal

from AnyQt.QtCore import QSettings, Qt
from AnyQt.QtCore import QSettings, Qt, QUrl
from AnyQt.QtGui import QIcon
from AnyQt.QtWidgets import QFileDialog
from AnyQt.QtTest import QSignalSpy
Expand All @@ -28,7 +28,7 @@
from Orange.widgets.tests.base import WidgetTest, GuiTest
from Orange.widgets.data import owcsvimport
from Orange.widgets.data.owcsvimport import (
OWCSVFileImport, pandas_to_table, ColumnType, RowSpec,
OWCSVFileImport, pandas_to_table, ColumnType, RowSpec, ImportItem,
)
from Orange.widgets.utils.pathutils import PathItem, samepath
from Orange.widgets.utils.settings import QSettings_writeArray
Expand Down Expand Up @@ -374,6 +374,41 @@ def test_browse_for_missing_prefixed_parent(self):
self.assertEqual(item[0], cur.varPath())
self.assertEqual(item[1].as_dict(), cur.options().as_dict())

def test_activate_import_dialog(self):
path = self.data_regions_path
item = ImportItem.fromPath(path)
self.widget.import_items_model.appendRow(ImportItem.fromPath(path))
opts = item.options()
self.assertIsNone(opts)
with mock.patch.object(owcsvimport.CSVImportDialog, "show"):
self.widget.import_options_button.click()
dlg = self.widget.findChild(owcsvimport.CSVImportDialog)
dlg.accept()
item_ = self.widget.current_item()
self.assertEqual(item.path(), item_.path())
self.assertIsNotNone(item_.options())

def test_drop_file(self):
self.assertFalse(self.widget.canDropUrl(QUrl("https://aa-bb.com")))
url = QUrl.fromLocalFile(self.data_regions_path)
self.assertTrue(self.widget.canDropUrl(url))

with mock.patch.object(owcsvimport.CSVImportDialog, "show"):
self.widget.handleDroppedUrl(url)
dlg = self.widget.findChild(owcsvimport.CSVImportDialog)
dlg.reject()
item = self.widget.current_item()
self.assertIsNone(item, "Rejecting the dialog should not record the recent file")

with mock.patch.object(owcsvimport.CSVImportDialog, "show"):
self.widget.handleDroppedUrl(url)
dlg = self.widget.findChild(owcsvimport.CSVImportDialog)
dlg.accept()
item = self.widget.current_item()
self.assertEqual(item.path(), url.toLocalFile())
out = self.get_output(self.widget.Outputs.data)
self.assertEqual(len(out.domain), 3)

def test_long_data(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "test.csv")
Expand Down
13 changes: 8 additions & 5 deletions Orange/widgets/data/tests/test_owfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ class TestOWFile(WidgetTest):
event_data = None

def setUp(self):
super().setUp()
self.widget = self.create_widget(OWFile) # type: OWFile
dataset_dirs.append(dirname(__file__))

def tearDown(self):
dataset_dirs.pop()
super().tearDown()

def test_describe_call_get_nans(self):
table = Table("iris")
Expand All @@ -93,11 +95,6 @@ def test_dragEnterEvent_accepts_urls(self):
self.widget.dragEnterEvent(event)
self.assertTrue(event.isAccepted())

def test_dragEnterEvent_skips_osx_file_references(self):
event = self._drag_enter_event(QUrl.fromLocalFile('/.file/id=12345'))
self.widget.dragEnterEvent(event)
self.assertFalse(event.isAccepted())

def test_dragEnterEvent_skips_usupported_files(self):
event = self._drag_enter_event(QUrl.fromLocalFile('file.unsupported'))
self.widget.dragEnterEvent(event)
Expand All @@ -122,6 +119,12 @@ def test_dropEvent_selects_file(self):
self.assertTrue(path.samefile(self.widget.last_path(), TITANIC_PATH))
self.widget.load_data.assert_called_with()

event = self._drop_event(QUrl("https://example.com/aa.csv"))
self.widget.load_data.reset_mock()
self.widget.dropEvent(event)
self.assertEqual(self.widget.source, OWFile.URL)
self.widget.load_data.assert_called_with()

def _drop_event(self, url):
# make sure data does not get garbage collected before it used
self.event_data = data = QMimeData()
Expand Down
17 changes: 14 additions & 3 deletions Orange/widgets/model/owloadmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
from typing import Any, Dict

from AnyQt.QtWidgets import QSizePolicy, QStyle, QFileDialog
from AnyQt.QtCore import QTimer
from AnyQt.QtCore import QTimer, QUrl

from orangewidget.workflow.drophandler import SingleFileDropHandler

from Orange.base import Model
from Orange.widgets import widget, gui
from Orange.widgets.model import owsavemodel
from Orange.widgets.utils.filedialogs import RecentPathsWComboMixin, RecentPath, \
stored_recent_paths_prepend
stored_recent_paths_prepend, OWUrlDropBase
from Orange.widgets.utils import stdpaths
from Orange.widgets.utils.widgetpreview import WidgetPreview
from Orange.widgets.widget import Msg, Output


class OWLoadModel(widget.OWWidget, RecentPathsWComboMixin):
class OWLoadModel(OWUrlDropBase, RecentPathsWComboMixin):
name = "Load Model"
description = "Load a model from an input file."
priority = 3050
Expand Down Expand Up @@ -91,6 +91,17 @@ def open_file(self):
else:
self.Outputs.model.send(model)

def canDropUrl(self, url: QUrl) -> bool:
if url.isLocalFile():
return OWLoadModelDropHandler().canDropFile(url.toLocalFile())
else:
return False

def handleDroppedUrl(self, url: QUrl) -> None:
if url.isLocalFile():
self.add_path(url.toLocalFile())
self.open_file()


class OWLoadModelDropHandler(SingleFileDropHandler):
WIDGET = OWLoadModel
Expand Down

0 comments on commit 0487f58

Please sign in to comment.