Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix container items change event being saved in preferences #196

Merged
merged 10 commits into from
Nov 19, 2020
25 changes: 23 additions & 2 deletions apptools/preferences/preferences_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@


class PreferencesHelper(HasTraits):
""" An object that can be initialized from a preferences node. """
""" A base class for objects that can be initialized from a preferences
node.

Additional traits defined on subclasses will be listened to. Changes
are then synchronized with the preferences. Note that mutations on nested
containers e.g. List(List(Str)) cannot be synchronized and should be
avoided.
"""

#### 'PreferencesHelper' interface ########################################

Expand Down Expand Up @@ -65,9 +72,23 @@ def _preferences_default(self):
def _anytrait_changed(self, trait_name, old, new):
""" Static trait change handler. """

if self.preferences is None:
return

# If the trait was a list or dict '_items' trait then just treat it as
# if the entire list or dict was changed.
if trait_name.endswith('_items'):
trait_name = trait_name[:-6]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when we get to python 3.9, we would be able to use removesuffix method - which would make the code more readable. ref https://www.python.org/dev/peps/pep-0616/

maybe we should add two backwards compatible removeprefix and removesuffix functions in a traits utility - which other libraries that depend on traits can use.

if self._is_preference_trait(trait_name):
self.preferences.set(
'%s.%s' % (self._get_path(), trait_name),
getattr(self, trait_name)
)
return
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ugly, and is similar to what the UI counterpart is doing:

# If the trait was a list or dict '_items' trait then just treat it as
# if the entire list or dict was changed.
if trait_name.endswith("_items"):
trait_name = trait_name[:-6]
if self._is_preference_trait(trait_name):
self._changed[trait_name] = getattr(self, trait_name)

The alternative might have been a redesign of the helper <-> preference relationship, but that might not be feasible without changing how it is used.


# If we were the one that set the trait (because the underlying
# preferences node changed) then do nothing.
if self.preferences and self._is_preference_trait(trait_name):
if self._is_preference_trait(trait_name):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely an unrealistic edge case (it would probably be considered a programmer error), but what if there is a preference trait that ends with _items, e.g. something_items? In that case we enter the above if statement, and set trait_name = = trait_name[:-6] removing the trailing _items leaving us with something. Then calling getattr(self, trait_name) will fail.

In general you should never name traits with a trailing _items, so this code is good to stay as is. But just wanted to ask about this.
e.g. this test fails:

def test_mutate_list_of_values(self):
        """ Mutated list should be saved and _items events not to be
        saved in the preferences.
        """
        # Regression test for enthought/apptools#129

        class MyPreferencesHelper(PreferencesHelper):
            preferences_path = Str('my_section')

            list_of_str_items = List(Str)

        helper = MyPreferencesHelper(list_of_str_items=["1"])

        # Now modify the list to fire _items event
        helper.list_of_str_items.append("2")
        self.preferences.save(self.tmpfile)

        new_preferences = Preferences()
        new_preferences.load(self.tmpfile)

        self.assertEqual(
            new_preferences.get("my_section.list_of_str_items"), str(["1", "2"])
        )
        self.assertEqual(new_preferences.keys("my_section"), ["list_of_str_items"])

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it is a very good observation. I agree this is why magic naming is bad, and one of the reasons to introduce observe is to do away with _items magic naming.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the test you provided can be fixed though, but it may have to be done along with apptools.preferences.ui.preferences_page. I will do that in a separate PR.

To be honest, I think a better design would be one that does not require synchronization - we probably don't need that. Most of the time we care about storing the preferences to a file after it has been edited. As long as we can obtain and serialize all the values at the point when the file is saved, we are good. If someone says they want to share the same preferences with multiple helpers, I'd say, think again. A lot can go wrong when editing shared mutable states, best approach is don't.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the test you provided can be fixed though, but it may have to be done along with apptools.preferences.ui.preferences_page. I will do that in a separate PR.

Actually thinking about it... I am not sure it can be fixed to satisify both situations. The suffix _items is simply overloaded. It might be one that just needs to be documented and a warning be emitted perhaps.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened #225

self.preferences.set('%s.%s' % (self._get_path(), trait_name), new)

return
Expand Down
71 changes: 67 additions & 4 deletions apptools/preferences/tests/test_preferences_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
from apptools.preferences.api import Preferences, PreferencesHelper
from apptools.preferences.api import ScopedPreferences
from apptools.preferences.api import set_default_preferences
from traits.api import Any, Bool, HasTraits, Int, Float, List, Str
from traits.api import (
Any, Bool, HasTraits, Int, Float, List, Str,
push_exception_handler, pop_exception_handler,
)


def width_listener(obj, trait_name, old, new):
Expand Down Expand Up @@ -59,6 +62,12 @@ def setUp(self):
# A temporary directory that can safely be written to.
self.tmpdir = tempfile.mkdtemp()

# Path to a temporary file
self.tmpfile = os.path.join(self.tmpdir, "tmp.ini")

push_exception_handler(reraise_exceptions=True)
self.addCleanup(pop_exception_handler)

def tearDown(self):
""" Called immediately after each test method has been called. """

Expand Down Expand Up @@ -245,16 +254,70 @@ class AcmeUIPreferencesHelper(PreferencesHelper):
self.assertEqual(second_unicode_str, p.get('acme.ui.description'))

# Save it to another file.
tmp = os.path.join(self.tmpdir, 'tmp.ini')
p.save(tmp)
p.save(self.tmpfile)

# Load it into a new node.
p = Preferences()
p.load(tmp)
p.load(self.tmpfile)
self.assertEqual(second_unicode_str, p.get('acme.ui.description'))
self.assertEqual(u'True', p.get('acme.ui.visible'))
self.assertTrue(helper.visible)

def test_mutate_list_of_values(self):
""" Mutated list should be saved and _items events not to be
saved in the preferences.
"""
# Regression test for enthought/apptools#129

class MyPreferencesHelper(PreferencesHelper):
preferences_path = Str('my_section')

list_of_str = List(Str)

helper = MyPreferencesHelper(list_of_str=["1"])

# Now modify the list to fire _items event
helper.list_of_str.append("2")
self.preferences.save(self.tmpfile)

new_preferences = Preferences()
new_preferences.load(self.tmpfile)

self.assertEqual(
new_preferences.get("my_section.list_of_str"), str(["1", "2"])
)
self.assertEqual(new_preferences.keys("my_section"), ["list_of_str"])

def test_nested_container_mutation_not_supported(self):
""" Known limitation: mutation on nested containers are not
synchronized
See enthought/apptools#194
"""

class MyPreferencesHelper(PreferencesHelper):
preferences_path = Str('my_section')
list_of_list_of_str = List(List(Str))

helper = MyPreferencesHelper(list_of_list_of_str=[["1"]])
helper.list_of_list_of_str[0].append("9")

self.preferences.save(self.tmpfile)

new_preferences = Preferences()
new_preferences.load(self.tmpfile)

# The event trait is not saved.
self.assertEqual(
new_preferences.keys("my_section"), ["list_of_list_of_str"]
)

# The values are not synchronized
with self.assertRaises(AssertionError):
self.assertEqual(
new_preferences.get("my_section.list_of_list_of_str"),
str(helper.list_of_list_of_str)
)

def test_no_preferences_path(self):
""" no preferences path """

Expand Down
Empty file.
77 changes: 77 additions & 0 deletions apptools/preferences/ui/tests/test_preferences_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
""" Tests for the preferences page. """

import unittest

from traits.api import Enum, List, Str
from traitsui.api import Group, Item, View

from apptools.preferences.api import Preferences
from apptools.preferences.ui.api import PreferencesPage


class TestPreferencesPage(unittest.TestCase):
""" Non-GUI Tests for PreferencesPage."""

def test_preferences_page_apply(self):
""" Test applying the preferences """

# this sets up imitate Mayavi usage.

class MyPreferencesPage(PreferencesPage):

# the following set default values for class traits
category = "Application"

help_id = ""

name = "Note"

preferences_path = "my_ref.pref"

# custom preferences

backend = Enum("auto", "simple", "test")

traits_view = View(Group(Item("backend")))

preferences = Preferences()
pref_page = MyPreferencesPage(
preferences=preferences,
category="Another Application",
help_id="this_wont_be_saved",
name="Different Note",
# custom preferences
backend="simple",
)
pref_page.apply()

self.assertEqual(preferences.get("my_ref.pref.backend"), "simple")
self.assertEqual(preferences.keys("my_ref.pref"), ["backend"])

# this is not saved by virtue of it being static and never assigned to
self.assertIsNone(preferences.get("my_ref.pref.traits_view"))

# These are skipped because this trait is defined on the
# PreferencesPage.
self.assertIsNone(preferences.get("my_ref.pref.help_id"))
self.assertIsNone(preferences.get("my_ref.pref.category"))
self.assertIsNone(preferences.get("my_ref.pref.name"))

def test_preferences_page_apply_skip_items_traits(self):
""" Test _items traits from List mutation are skipped. """
# Regression test for enthought/apptools#129

class MyPreferencesPage(PreferencesPage):
preferences_path = "my_ref.pref"
names = List(Str())

preferences = Preferences()
pref_page = MyPreferencesPage(
preferences=preferences,
names=["1"],
)
pref_page.names.append("2")
pref_page.apply()

self.assertEqual(preferences.get("my_ref.pref.names"), str(["1", "2"]))
self.assertEqual(preferences.keys("my_ref.pref"), ["names"])
1 change: 1 addition & 0 deletions docs/releases/upcoming/196.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix container items change event being saved in preferences (#196)