Skip to content

Commit cfea969

Browse files
committed
OWSaveBase: Keep relative path
1 parent cdd53c8 commit cfea969

File tree

2 files changed

+254
-20
lines changed

2 files changed

+254
-20
lines changed

Orange/widgets/utils/save/owsavebase.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,16 @@ class Error(widget.OWWidget.Error):
4848
want_main_area = False
4949
resizing_enabled = False
5050

51-
last_dir = Setting("")
52-
filter = Setting("") # default will be provided in __init__
53-
filename = Setting("", schema_only=True)
51+
filter = Setting("") # Default is provided in __init__
52+
53+
# If the path is in the same directory as the workflow file or its
54+
# subdirectory, it is stored as a relative path, otherwise as absolute.
55+
# For the sake of security, we do not store relative paths from other
56+
# directories, like home or cwd. After loading the widget from a schema,
57+
# auto_save is set to off, unless the stored_path is relative (to the
58+
# workflow).
59+
stored_path = Setting("")
60+
stored_name = Setting("", schema_only=True) # File name, without path
5461
auto_save = Setting(False)
5562

5663
filters = []
@@ -69,6 +76,8 @@ def __init__(self, start_row=0):
6976
"""
7077
super().__init__()
7178
self.data = None
79+
self._absolute_path = self._abs_path_from_setting()
80+
7281
# This cannot be done outside because `filters` is defined by subclass
7382
if not self.filter:
7483
self.filter = next(iter(self.get_filters()))
@@ -90,6 +99,60 @@ def __init__(self, start_row=0):
9099
self.adjustSize()
91100
self.update_messages()
92101

102+
@property
103+
def last_dir(self):
104+
# Not the best name, but kept for compatibility
105+
return self._absolute_path
106+
107+
@last_dir.setter
108+
def last_dir(self, absolute_path):
109+
"""Store _absolute_path and update relative path (stored_path)"""
110+
self._absolute_path = absolute_path
111+
112+
workflow_dir = self.workflowEnv().get("basedir", None)
113+
if workflow_dir and absolute_path.startswith(workflow_dir.rstrip("/")):
114+
self.stored_path = os.path.relpath(absolute_path, workflow_dir)
115+
else:
116+
self.stored_path = absolute_path
117+
118+
def _abs_path_from_setting(self):
119+
"""
120+
Compute absolute path from `stored_path` from settings.
121+
122+
Auto save is disabled unless stored_path is relative and exists.
123+
"""
124+
workflow_dir = self.workflowEnv().get("basedir")
125+
if os.path.isabs(self.stored_path):
126+
path = self.stored_path
127+
self.auto_save = False
128+
elif workflow_dir:
129+
path = os.path.join(workflow_dir, self.stored_path)
130+
else:
131+
path = None
132+
133+
if path and os.path.exists(path):
134+
return path
135+
else:
136+
self.stored_path = workflow_dir or _userhome
137+
self.auto_save = False
138+
return self.stored_path
139+
140+
@property
141+
def filename(self):
142+
if self.stored_name:
143+
return os.path.join(self._absolute_path, self.stored_name)
144+
else:
145+
return ""
146+
147+
@filename.setter
148+
def filename(self, value):
149+
self.last_dir, self.stored_name = os.path.split(value)
150+
151+
def workflowEnvChanged(self, key, value, oldvalue):
152+
# Trigger refresh of relative path, e.g. when saving the scheme
153+
if key == "basedir":
154+
self.last_dir = self._absolute_path
155+
93156
@classmethod
94157
def get_filters(cls):
95158
return cls.filters
@@ -133,8 +196,7 @@ def save_file_as(self):
133196
return
134197
self.filename = filename
135198
self.filter = selected_filter
136-
self.last_dir = os.path.split(self.filename)[0]
137-
self.bt_save.setText(f"Save as {os.path.split(filename)[1]}")
199+
self.bt_save.setText(f"Save as {self.stored_name}")
138200
self.update_messages()
139201
self._try_save()
140202

@@ -243,6 +305,14 @@ def valid_filters(self):
243305
def default_valid_filter(self):
244306
return self.filter
245307

308+
@classmethod
309+
def migrate_settings(cls, settings, version):
310+
# We cannot use versions because they are overriden in derived classes
311+
if "last_dir" in settings:
312+
settings["stored_path"] = settings.pop("last_dir")
313+
if "filename" in settings:
314+
settings["stored_name"] = os.path.split(settings.pop("filename"))[1]
315+
246316
# As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double
247317
# suffixes, not even in non-native dialogs. We handle each OS separately.
248318
if sys.platform in ("darwin", "win32"):

Orange/widgets/utils/save/tests/test_owsavebase.py

Lines changed: 179 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55

66
# pylint: disable=missing-docstring, protected-access, unsubscriptable-object
77
import unittest
8-
from unittest.mock import Mock
8+
from unittest.mock import Mock, patch
9+
import sys
910
import os
1011
import collections
1112

13+
from orangewidget.widget import Input
1214
from Orange.widgets.tests.base import WidgetTest
1315
from Orange.widgets.utils import getmembers
14-
from Orange.widgets.utils.save.owsavebase import OWSaveBase
15-
from orangewidget.widget import Input
16+
from Orange.widgets.utils.save.owsavebase import OWSaveBase, _userhome
1617

1718

1819
class SaveWidgetsTestBaseMixin:
@@ -46,18 +47,18 @@ def test_filters(self):
4647
class TestOWSaveBaseWithWriters(WidgetTest):
4748
# Tests for OWSaveBase methods that require filters to be dictionaries
4849
# with with writers as keys in `filters`.
49-
def setUp(self):
50-
class OWSaveMockWriter(OWSaveBase):
51-
name = "Mock save"
52-
writer = Mock()
53-
writer.EXTENSIONS = [".csv"]
54-
writer.SUPPORT_COMPRESSED = True
55-
writer.SUPPORT_SPARSE_DATA = False
56-
writer.OPTIONAL_TYPE_ANNOTATIONS = False
57-
writers = [writer]
58-
filters = {"csv (*.csv)": writer}
50+
class OWSaveMockWriter(OWSaveBase):
51+
name = "Mock save"
52+
writer = Mock()
53+
writer.EXTENSIONS = [".csv"]
54+
writer.SUPPORT_COMPRESSED = True
55+
writer.SUPPORT_SPARSE_DATA = False
56+
writer.OPTIONAL_TYPE_ANNOTATIONS = False
57+
writers = [writer]
58+
filters = {"csv (*.csv)": writer}
5959

60-
self.widget = self.create_widget(OWSaveMockWriter)
60+
def setUp(self):
61+
self.widget = self.create_widget(self.OWSaveMockWriter)
6162

6263
def test_no_data_no_save(self):
6364
widget = self.widget
@@ -96,6 +97,169 @@ def test_base_methods(self):
9697
self.assertIs(widget.valid_filters(), widget.get_filters())
9798
self.assertIs(widget.default_valid_filter(), widget.filter)
9899

100+
def assertPathEqual(self, a, b):
101+
if sys.platform == "win32":
102+
a = a.replace("\\", "/")
103+
b = b.replace("\\", "/")
104+
self.assertEqual(a.rstrip("/"), b.rstrip("/"))
105+
106+
@patch("os.path.exists", lambda name: name == "/home/u/orange/a/b")
107+
def test_open_moved_workflow(self):
108+
"""Stored relative paths are properly changed on load"""
109+
home = _userhome
110+
home_c_foo = os.path.join(_userhome, "c.foo")
111+
with patch("Orange.widgets.widget.OWWidget.workflowEnv",
112+
Mock(return_value={})):
113+
w = self.create_widget(
114+
self.OWSaveMockWriter,
115+
stored_settings=dict(stored_path="a/b",
116+
stored_name="c.foo",
117+
auto_save=True))
118+
self.assertPathEqual(w.last_dir, home)
119+
self.assertPathEqual(w.filename, home_c_foo)
120+
self.assertFalse(w.auto_save)
121+
122+
w = self.create_widget(
123+
self.OWSaveMockWriter,
124+
stored_settings=dict(stored_path="/a/d",
125+
stored_name="c.foo",
126+
auto_save=True))
127+
self.assertPathEqual(w.last_dir, home)
128+
self.assertPathEqual(w.filename, home_c_foo)
129+
self.assertFalse(w.auto_save)
130+
131+
w = self.create_widget(
132+
self.OWSaveMockWriter,
133+
stored_settings=dict(stored_path=".",
134+
stored_name="c.foo",
135+
auto_save=True))
136+
self.assertPathEqual(w.last_dir, home)
137+
self.assertPathEqual(w.filename, home_c_foo)
138+
self.assertFalse(w.auto_save)
139+
140+
with patch("Orange.widgets.widget.OWWidget.workflowEnv",
141+
Mock(return_value={"basedir": "/home/u/orange/"})):
142+
w = self.create_widget(
143+
self.OWSaveMockWriter,
144+
stored_settings=dict(stored_path="a/b",
145+
stored_name="c.foo",
146+
auto_save=True))
147+
self.assertPathEqual(w.last_dir, "/home/u/orange/a/b")
148+
self.assertPathEqual(w.filename, "/home/u/orange/a/b/c.foo")
149+
self.assertTrue(w.auto_save)
150+
151+
w = self.create_widget(
152+
self.OWSaveMockWriter,
153+
stored_settings=dict(stored_path="a/b",
154+
stored_name="c.foo",
155+
auto_save=False))
156+
self.assertPathEqual(w.last_dir, "/home/u/orange/a/b")
157+
self.assertPathEqual(w.filename, "/home/u/orange/a/b/c.foo")
158+
self.assertFalse(w.auto_save)
159+
160+
w = self.create_widget(
161+
self.OWSaveMockWriter,
162+
stored_settings=dict(stored_path="a/d",
163+
stored_name="c.foo",
164+
auto_save=True))
165+
self.assertPathEqual(w.last_dir, "/home/u/orange/")
166+
self.assertPathEqual(w.filename, "/home/u/orange/c.foo")
167+
self.assertFalse(w.auto_save)
168+
169+
w = self.create_widget(
170+
self.OWSaveMockWriter,
171+
stored_settings=dict(stored_path="/a/d",
172+
stored_name="c.foo",
173+
auto_save=True))
174+
self.assertPathEqual(w.last_dir, "/home/u/orange/")
175+
self.assertPathEqual(w.filename, "/home/u/orange/c.foo")
176+
self.assertFalse(w.auto_save)
177+
178+
w = self.create_widget(
179+
self.OWSaveMockWriter,
180+
stored_settings=dict(stored_path=".",
181+
stored_name="c.foo",
182+
auto_save=True))
183+
self.assertPathEqual(w.last_dir, "/home/u/orange/")
184+
self.assertPathEqual(w.filename, "/home/u/orange/c.foo")
185+
self.assertFalse(w.auto_save)
186+
187+
w = self.create_widget(
188+
self.OWSaveMockWriter,
189+
stored_settings=dict(stored_path="",
190+
stored_name="c.foo",
191+
auto_save=True))
192+
self.assertPathEqual(w.last_dir, "/home/u/orange/")
193+
self.assertPathEqual(w.filename, "/home/u/orange/c.foo")
194+
self.assertFalse(w.auto_save)
195+
196+
def test_move_workflow(self):
197+
"""Widget correctly stores relative paths"""
198+
w = self.widget
199+
w._try_save = Mock()
200+
w.update_messages = Mock()
201+
env = {}
202+
203+
with patch("Orange.widgets.widget.OWWidget.workflowEnv",
204+
Mock(return_value=env)):
205+
# File is save to subdirectory of workflow path
206+
env["basedir"] = "/home/u/orange/"
207+
208+
w.get_save_filename = \
209+
Mock(return_value=("/home/u/orange/a/b/c.foo", ""))
210+
w.save_file_as()
211+
self.assertPathEqual(w.last_dir, "/home/u/orange/a/b")
212+
self.assertPathEqual(w.stored_path, "a/b/")
213+
self.assertEqual(w.stored_name, "c.foo")
214+
215+
# Workflow path changes: relative path is changed to absolute
216+
env["basedir"] = "/tmp/u/work/"
217+
w.workflowEnvChanged("basedir", "/tmp/u/work", "/home/u/orange")
218+
self.assertPathEqual(w.last_dir, "/home/u/orange/a/b/")
219+
self.assertPathEqual(w.stored_path, "/home/u/orange/a/b/")
220+
self.assertEqual(w.stored_name, "c.foo")
221+
222+
# Workflow path changes back: absolute path is again relative
223+
env["basedir"] = "/home/u/orange/"
224+
w.workflowEnvChanged("basedir", "/home/u/orange", "/tmp/u/work")
225+
self.assertPathEqual(w.last_dir, "/home/u/orange/a/b")
226+
self.assertPathEqual(w.stored_path, "a/b/")
227+
self.assertEqual(w.stored_name, "c.foo")
228+
229+
# File is saved to an unrelated directory: path is absolute
230+
w.get_save_filename = \
231+
Mock(return_value=("/tmp/u/work/a/b/c.foo", ""))
232+
w.save_file_as()
233+
self.assertPathEqual(w.last_dir, "/tmp/u/work/a/b/")
234+
self.assertPathEqual(w.stored_path, "/tmp/u/work/a/b/")
235+
self.assertEqual(w.stored_name, "c.foo")
236+
237+
# File is saved to the workflow's directory: path is relative
238+
w.get_save_filename = \
239+
Mock(return_value=("/home/u/orange/c.foo", ""))
240+
w.save_file_as()
241+
self.assertPathEqual(w.last_dir, "/home/u/orange/")
242+
self.assertPathEqual(w.stored_path, ".")
243+
self.assertEqual(w.stored_name, "c.foo")
244+
245+
def test_migrate_pre_relative_settings(self):
246+
with patch("os.path.exists", lambda name: name == "/a/b"):
247+
w = self.create_widget(
248+
self.OWSaveMockWriter,
249+
stored_settings=dict(last_dir="/a/b", filename="/a/b/c.foo"))
250+
self.assertPathEqual(w.last_dir, "/a/b")
251+
self.assertPathEqual(w.filename, "/a/b/c.foo")
252+
self.assertPathEqual(w.stored_path, "/a/b")
253+
self.assertPathEqual(w.stored_name, "c.foo")
254+
255+
w = self.create_widget(
256+
self.OWSaveMockWriter,
257+
stored_settings=dict(last_dir="/a/b", filename="/a/b/c.foo"))
258+
self.assertPathEqual(w.last_dir, _userhome)
259+
self.assertPathEqual(w.filename, os.path.join(_userhome, "c.foo"))
260+
self.assertPathEqual(w.stored_path, _userhome)
261+
self.assertPathEqual(w.stored_name, "c.foo")
262+
99263

100264
class TestOWSaveBase(WidgetTest):
101265
# Tests for OWSaveBase methods with filters as list
@@ -142,7 +306,7 @@ class TestOWSaveUtils(unittest.TestCase):
142306
def test_replace_extension(self):
143307
class OWMockSaveBase(OWSaveBase):
144308
filters = ["Tab delimited (*.tab)",
145-
"Compressed tab delimitd (*.gz.tab)",
309+
"Compressed tab delimited (*.gz.tab)",
146310
"Comma separated (*.csv)",
147311
"Compressed comma separated (*.csv.gz)",
148312
"Excel File (*.xlsx)"]

0 commit comments

Comments
 (0)