Skip to content

Commit 944d112

Browse files
committed
Add settings versions and migrations
1 parent 8886d32 commit 944d112

File tree

4 files changed

+204
-6
lines changed

4 files changed

+204
-6
lines changed

Orange/widgets/settings.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747

4848
_IMMUTABLES = (str, int, bytes, bool, float, tuple)
4949

50+
VERSION_KEY = "__version__"
51+
5052

5153
class Setting:
5254
"""Description of a setting.
@@ -386,6 +388,7 @@ def read_defaults_file(self, settings_file):
386388
for key, value in defaults.items()
387389
if not isinstance(value, Setting)
388390
}
391+
self._migrate_settings(self.defaults)
389392

390393
def write_defaults(self):
391394
"""Write (global) defaults for this widget class to a file.
@@ -434,12 +437,18 @@ def initialize(self, instance, data=None):
434437

435438
if isinstance(data, bytes):
436439
data = pickle.loads(data)
440+
self._migrate_settings(data)
437441

438442
if provider is self.provider:
439443
data = self._add_defaults(data)
440444

441445
provider.initialize(instance, data)
442446

447+
def _migrate_settings(self, settings):
448+
"""Ask widget to migrate settings to the latest version."""
449+
if settings:
450+
self.widget_class.migrate_settings(settings, settings.pop(VERSION_KEY, None))
451+
443452
def _select_provider(self, instance):
444453
provider = self.provider.get_provider(instance.__class__)
445454
if provider is None:
@@ -473,7 +482,9 @@ def pack_data(self, widget):
473482
----------
474483
widget : OWWidget
475484
"""
476-
return self.provider.pack(widget)
485+
packed_settings = self.provider.pack(widget)
486+
packed_settings[VERSION_KEY] = self.widget_class.settings_version
487+
return packed_settings
477488

478489
def update_defaults(self, widget):
479490
"""
@@ -588,6 +599,7 @@ def initialize(self, instance, data=None):
588599
super().initialize(instance, data)
589600
if data and "context_settings" in data:
590601
instance.context_settings = data["context_settings"]
602+
self._migrate_contexts(instance.context_settings)
591603
else:
592604
instance.context_settings = []
593605

@@ -596,16 +608,23 @@ def read_defaults_file(self, settings_file):
596608
pickle."""
597609
super().read_defaults_file(settings_file)
598610
self.global_contexts = pickle.load(settings_file)
611+
self._migrate_contexts(self.global_contexts)
612+
613+
def _migrate_contexts(self, contexts):
614+
for context in contexts:
615+
self.widget_class.migrate_context(context, context.values.pop(VERSION_KEY, None))
599616

600617
def write_defaults_file(self, settings_file):
601618
"""Call the inherited method, then add global context to the pickle."""
602619
super().write_defaults_file(settings_file)
603620
pickle.dump(self.global_contexts, settings_file, -1)
604621

605622
def pack_data(self, widget):
606-
"""Call the inherited method, then add local contexts to the pickle."""
623+
"""Call the inherited method, then add local contexts to the dict."""
607624
data = super().pack_data(widget)
608625
self.settings_from_widget(widget)
626+
for context in widget.context_settings:
627+
context.values[VERSION_KEY] = self.widget_class.settings_version
609628
data["context_settings"] = widget.context_settings
610629
return data
611630

Orange/widgets/tests/test_context_handler.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,78 @@
1-
from copy import copy
1+
import pickle
2+
from copy import copy, deepcopy
3+
from io import BytesIO
24
from unittest import TestCase
3-
from unittest.mock import Mock
4-
from Orange.widgets.settings import ContextHandler, Setting, ContextSetting, Context
5+
from unittest.mock import Mock, patch, call
6+
from Orange.widgets.settings import ContextHandler, Setting, ContextSetting, Context, VERSION_KEY
57

68
__author__ = 'anze'
79

810

911
class SimpleWidget:
12+
settings_version = 1
13+
1014
setting = Setting(42)
1115

1216
context_setting = ContextSetting(42)
1317

18+
migrate_settings = Mock()
19+
migrate_context = Mock()
20+
21+
22+
class DummyContext(Context):
23+
id = 0
24+
25+
def __init__(self, version=None):
26+
super().__init__()
27+
DummyContext.id += 1
28+
self.id = DummyContext.id
29+
if version:
30+
self.values[VERSION_KEY] = version
31+
32+
def __repr__(self):
33+
return "Context(id={})".format(self.id)
34+
__str__ = __repr__
35+
36+
def __eq__(self, other):
37+
if not isinstance(other, DummyContext):
38+
return False
39+
return self.id == other.id
40+
41+
42+
def create_defaults_file(contexts):
43+
b = BytesIO()
44+
pickle.dump({"x": 5}, b)
45+
pickle.dump(contexts, b)
46+
b.seek(0)
47+
return b
48+
1449

1550
class TestContextHandler(TestCase):
51+
def test_read_defaults(self):
52+
contexts = [DummyContext() for _ in range(3)]
53+
54+
handler = ContextHandler()
55+
handler.widget_class = SimpleWidget
56+
57+
# Old settings without version
58+
migrate_context = Mock()
59+
with patch.object(SimpleWidget, "migrate_context", migrate_context):
60+
handler.read_defaults_file(create_defaults_file(contexts))
61+
self.assertSequenceEqual(handler.global_contexts, contexts)
62+
migrate_context.assert_has_calls([call(c, None) for c in contexts])
63+
64+
# Settings with version
65+
contexts = [DummyContext(version=i) for i in range(1, 4)]
66+
migrate_context.reset_mock()
67+
with patch.object(SimpleWidget, "migrate_context", migrate_context):
68+
handler.read_defaults_file(create_defaults_file(contexts))
69+
self.assertSequenceEqual(handler.global_contexts, contexts)
70+
migrate_context.assert_has_calls([call(c, c.values[VERSION_KEY]) for c in contexts])
71+
1672
def test_initialize(self):
1773
handler = ContextHandler()
1874
handler.provider = Mock()
75+
handler.widget_class = SimpleWidget
1976

2077
# Context settings from data
2178
widget = SimpleWidget()
@@ -29,6 +86,26 @@ def test_initialize(self):
2986
self.assertTrue(hasattr(widget, 'context_settings'))
3087
self.assertEqual(widget.context_settings, handler.global_contexts)
3188

89+
def test_initialize_migrates_contexts(self):
90+
handler = ContextHandler()
91+
handler.bind(SimpleWidget)
92+
93+
widget = SimpleWidget()
94+
95+
# Old settings without version
96+
contexts = [DummyContext() for _ in range(3)]
97+
migrate_context = Mock()
98+
with patch.object(SimpleWidget, "migrate_context", migrate_context):
99+
handler.initialize(widget, dict(context_settings=contexts))
100+
migrate_context.assert_has_calls([call(c, None) for c in contexts])
101+
102+
# Settings with version
103+
contexts = [DummyContext(version=i) for i in range(1, 4)]
104+
migrate_context = Mock()
105+
with patch.object(SimpleWidget, "migrate_context", migrate_context):
106+
handler.initialize(widget, dict(context_settings=deepcopy(contexts)))
107+
migrate_context.assert_has_calls([call(c, c.values[VERSION_KEY]) for c in contexts])
108+
32109
def test_fast_save(self):
33110
handler = ContextHandler()
34111
handler.bind(SimpleWidget)
@@ -68,3 +145,16 @@ def test_find_or_create_context(self):
68145
self.assertEqual(context.i, 5)
69146
self.assertEqual([c.i for c in widget.context_settings], [5, 2])
70147
self.assertEqual([c.i for c in handler.global_contexts], [3, 7])
148+
149+
def test_pack_settings_stores_version(self):
150+
handler = ContextHandler()
151+
handler.bind(SimpleWidget)
152+
153+
widget = SimpleWidget()
154+
handler.initialize(widget)
155+
widget.context_setting = [DummyContext() for _ in range(3)]
156+
157+
settings = handler.pack_data(widget)
158+
self.assertIn("context_settings", settings)
159+
for c in settings["context_settings"]:
160+
self.assertIn(VERSION_KEY, c.values)

Orange/widgets/tests/test_settings_handler.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import unittest
66
from unittest.mock import patch, Mock, mock_open
77
import warnings
8-
from Orange.widgets.settings import SettingsHandler, Setting, SettingProvider
8+
from Orange.widgets.settings import SettingsHandler, Setting, SettingProvider, VERSION_KEY
99

1010

1111
class SettingHandlerTestCase(unittest.TestCase):
@@ -211,6 +211,57 @@ def test_schema_only_settings(self):
211211
data = handler.pack_data(widget)
212212
self.assertEqual(data['schema_only_setting'], 5)
213213

214+
def test_read_defaults_migrates_settings(self):
215+
handler = SettingsHandler()
216+
handler.widget_class = SimpleWidget
217+
218+
migrate_settings = Mock()
219+
with patch.object(SimpleWidget, "migrate_settings", migrate_settings):
220+
# Old settings without version
221+
settings = {"value": 5}
222+
with self.override_defaults(settings):
223+
handler.read_defaults()
224+
migrate_settings.assert_called_with(settings, None)
225+
226+
migrate_settings.reset()
227+
# Settings with version
228+
settings_with_version = dict(settings)
229+
settings_with_version[VERSION_KEY] = 1
230+
with self.override_defaults(settings_with_version):
231+
handler.read_defaults()
232+
migrate_settings.assert_called_with(settings, 1)
233+
234+
def test_initialize_migrates_settings(self):
235+
handler = SettingsHandler()
236+
with self.override_defaults():
237+
handler.bind(SimpleWidget)
238+
239+
widget = SimpleWidget()
240+
241+
migrate_settings = Mock()
242+
with patch.object(SimpleWidget, "migrate_settings", migrate_settings):
243+
# Old settings without version
244+
settings = {"value": 5}
245+
246+
handler.initialize(widget, settings)
247+
migrate_settings.assert_called_with(settings, None)
248+
249+
migrate_settings.reset_mock()
250+
# Settings with version
251+
252+
settings_with_version = dict(settings)
253+
settings_with_version[VERSION_KEY] = 1
254+
handler.initialize(widget, settings_with_version)
255+
migrate_settings.assert_called_with(settings, 1)
256+
257+
def test_pack_settings_stores_version(self):
258+
handler = SettingsHandler()
259+
handler.bind(SimpleWidget)
260+
261+
widget = SimpleWidget()
262+
263+
settings = handler.pack_data(widget)
264+
self.assertIn(VERSION_KEY, settings)
214265

215266
@contextmanager
216267
def override_defaults(self, defaults=None):
@@ -227,6 +278,8 @@ class Component:
227278

228279

229280
class SimpleWidget:
281+
settings_version = 1
282+
230283
setting = Setting(42)
231284
schema_only_setting = Setting(None, schema_only=True)
232285
non_setting = 5
@@ -236,6 +289,9 @@ class SimpleWidget:
236289
def __init__(self):
237290
self.component = Component()
238291

292+
migrate_settings = Mock()
293+
migrate_context = Mock()
294+
239295

240296
class SimpleWidgetMk1(SimpleWidget):
241297
pass

Orange/widgets/widget.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ class OWWidget(QDialog, Report, ProgressBarMixin, WidgetMessagesMixin,
154154
settingsHandler = None
155155
""":type: SettingsHandler"""
156156

157+
#: Version of the settings representation
158+
#: Subclasses should increase this number when they make breaking
159+
#: changes to settings representation (a settings that used to store
160+
#: int now stores string) and handle migrations in migrate and
161+
#: migrate_context settings.
162+
settings_version = 1
163+
157164
savedWidgetGeometry = settings.Setting(None)
158165

159166
#: A list of advice messages (:class:`Message`) to display to the user.
@@ -738,6 +745,32 @@ def _userconfirmed():
738745

739746
self.__msgwidget.accepted.connect(_userconfirmed)
740747

748+
@classmethod
749+
def migrate_settings(cls, settings, version):
750+
"""Fix settings to work with the current version of widgets
751+
752+
Parameters
753+
----------
754+
settings : dict
755+
dict of name - value mappings
756+
version : Optional[int]
757+
version of the saved settings
758+
or None if settings were created before migrations
759+
"""
760+
761+
@classmethod
762+
def migrate_context(cls, context, version):
763+
"""Fix contexts to work with the current version of widgets
764+
765+
Parameters
766+
----------
767+
context : Context
768+
Context object
769+
version : Optional[int]
770+
version of the saved context
771+
or None if context was created before migrations
772+
"""
773+
741774

742775
class Message(object):
743776
"""

0 commit comments

Comments
 (0)