Skip to content

Commit 6fc0741

Browse files
authored
Merge pull request #4532 from janezd/relative-save-paths
[ENH] Save widgets: Store paths relative to workflow directory
2 parents 0dc97d2 + c35adf0 commit 6fc0741

File tree

6 files changed

+271
-22
lines changed

6 files changed

+271
-22
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: 80 additions & 6 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()))
@@ -81,7 +90,10 @@ def __init__(self, start_row=0):
8190
callback=self.update_messages),
8291
start_row, 0, 1, 2)
8392
grid.setRowMinimumHeight(start_row + 1, 8)
84-
self.bt_save = gui.button(None, self, "Save", callback=self.save_file)
93+
self.bt_save = gui.button(
94+
None, self,
95+
label=f"Save as {self.stored_name}" if self.stored_name else "Save",
96+
callback=self.save_file)
8597
grid.addWidget(self.bt_save, start_row + 2, 0)
8698
grid.addWidget(
8799
gui.button(None, self, "Save as ...", callback=self.save_file_as),
@@ -90,6 +102,61 @@ def __init__(self, start_row=0):
90102
self.adjustSize()
91103
self.update_messages()
92104

105+
@property
106+
def last_dir(self):
107+
# Not the best name, but kept for compatibility
108+
return self._absolute_path
109+
110+
@last_dir.setter
111+
def last_dir(self, absolute_path):
112+
"""Store _absolute_path and update relative path (stored_path)"""
113+
self._absolute_path = absolute_path
114+
115+
workflow_dir = self.workflowEnv().get("basedir", None)
116+
if workflow_dir and absolute_path.startswith(workflow_dir.rstrip("/")):
117+
self.stored_path = os.path.relpath(absolute_path, workflow_dir)
118+
else:
119+
self.stored_path = absolute_path
120+
121+
def _abs_path_from_setting(self):
122+
"""
123+
Compute absolute path from `stored_path` from settings.
124+
125+
Auto save is disabled unless stored_path is relative and exists.
126+
"""
127+
workflow_dir = self.workflowEnv().get("basedir")
128+
if os.path.isabs(self.stored_path):
129+
path = self.stored_path
130+
self.auto_save = False
131+
elif workflow_dir:
132+
path = os.path.join(workflow_dir, self.stored_path)
133+
else:
134+
path = None
135+
136+
if path and os.path.exists(path):
137+
return path
138+
else:
139+
self.stored_path = workflow_dir or _userhome
140+
self.auto_save = False
141+
return self.stored_path
142+
143+
@property
144+
def filename(self):
145+
if self.stored_name:
146+
return os.path.join(self._absolute_path, self.stored_name)
147+
else:
148+
return ""
149+
150+
@filename.setter
151+
def filename(self, value):
152+
self.last_dir, self.stored_name = os.path.split(value)
153+
154+
# pylint: disable=unused-argument
155+
def workflowEnvChanged(self, key, value, oldvalue):
156+
# Trigger refresh of relative path, e.g. when saving the scheme
157+
if key == "basedir":
158+
self.last_dir = self._absolute_path
159+
93160
@classmethod
94161
def get_filters(cls):
95162
return cls.filters
@@ -133,8 +200,7 @@ def save_file_as(self):
133200
return
134201
self.filename = filename
135202
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]}")
203+
self.bt_save.setText(f"Save as {self.stored_name}")
138204
self.update_messages()
139205
self._try_save()
140206

@@ -243,6 +309,14 @@ def valid_filters(self):
243309
def default_valid_filter(self):
244310
return self.filter
245311

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

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

Lines changed: 185 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,175 @@ 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+
263+
def test_save_button_label(self):
264+
w = self.create_widget(
265+
self.OWSaveMockWriter,
266+
stored_settings=dict(stored_path="", stored_name="c.foo"))
267+
self.assertTrue(w.bt_save.text().endswith(" c.foo"))
268+
99269

100270
class TestOWSaveBase(WidgetTest):
101271
# Tests for OWSaveBase methods with filters as list
@@ -142,7 +312,7 @@ class TestOWSaveUtils(unittest.TestCase):
142312
def test_replace_extension(self):
143313
class OWMockSaveBase(OWSaveBase):
144314
filters = ["Tab delimited (*.tab)",
145-
"Compressed tab delimitd (*.gz.tab)",
315+
"Compressed tab delimited (*.gz.tab)",
146316
"Comma separated (*.csv)",
147317
"Compressed comma separated (*.csv.gz)",
148318
"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)