Skip to content

Commit b26a327

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

File tree

6 files changed

+261
-21
lines changed

6 files changed

+261
-21
lines changed

Orange/widgets/data/tests/test_owsave.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ def test_dataset(self):
7979

8080
def test_initial_start_dir(self):
8181
widget = self.widget
82-
widget.filename = _w("/usr/foo/bar.csv")
8382
self.assertEqual(widget.initial_start_dir(),
8483
_w(os.path.expanduser("~/")))
8584

Orange/widgets/utils/save/owsavebase.py

Lines changed: 76 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,61 @@ 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+
# pylint: disable=unused-argument
152+
def workflowEnvChanged(self, key, value, oldvalue):
153+
# Trigger refresh of relative path, e.g. when saving the scheme
154+
if key == "basedir":
155+
self.last_dir = self._absolute_path
156+
93157
@classmethod
94158
def get_filters(cls):
95159
return cls.filters
@@ -133,8 +197,7 @@ def save_file_as(self):
133197
return
134198
self.filename = filename
135199
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]}")
200+
self.bt_save.setText(f"Save as {self.stored_name}")
138201
self.update_messages()
139202
self._try_save()
140203

@@ -243,6 +306,14 @@ def valid_filters(self):
243306
def default_valid_filter(self):
244307
return self.filter
245308

309+
@classmethod
310+
def migrate_settings(cls, settings, version):
311+
# We cannot use versions because they are overriden in derived classes
312+
if "last_dir" in settings:
313+
settings["stored_path"] = settings.pop("last_dir")
314+
if "filename" in settings:
315+
settings["stored_name"] = os.path.split(settings.pop("filename"))[1]
316+
246317
# As of Qt 5.9, QFileDialog.setDefaultSuffix does not support double
247318
# suffixes, not even in non-native dialogs. We handle each OS separately.
248319
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)"]

doc/visual-programming/source/widgets/data/save.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The **Save Data** widget considers a dataset provided in the input channel and s
1111

1212
The widget does not save the data every time it receives a new signal in the input as this would constantly (and, mostly, inadvertently) overwrite the file. Instead, the data is saved only after a new file name is set or the user pushes the *Save* button.
1313

14+
If the file is saved to the same directory as the workflow or in the subtree of that directory, the widget remembers the relative path. Otherwise it will store an absolute path, but disable auto save for security reasons.
15+
1416
![](images/Save-stamped.png)
1517

1618
1. Save by overwriting the existing file.

doc/visual-programming/source/widgets/model/savemodel.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Save Model
33

44
Save a trained model to an output file.
55

6+
If the file is saved to the same directory as the workflow or in the subtree of that directory, the widget remembers the relative path. Otherwise it will store an absolute path, but disable auto save for security reasons.
7+
68
**Inputs**
79

810
- Model: trained model

doc/visual-programming/source/widgets/unsupervised/savedistancematrix.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Save Distance Matrix
33

44
Saves a distance matrix.
55

6+
If the file is saved to the same directory as the workflow or in the subtree of that directory, the widget remembers the relative path. Otherwise it will store an absolute path, but disable auto save for security reasons.
7+
68
**Inputs**
79

810
- Distances: distance matrix

0 commit comments

Comments
 (0)