Reported bug with utf-8 strings
[be.git] / libbe / settings_object.py
1 # Bugs Everywhere - a distributed bugtracker
2 # Copyright (C) 2008-2009 W. Trevor King <wking@drexel.edu>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along
15 # with this program; if not, write to the Free Software Foundation, Inc.,
16 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18 """
19 This module provides a base class implementing settings-dict based
20 property storage useful for BE objects with saved properties
21 (e.g. BugDir, Bug, Comment).  For example usage, consider the
22 unittests at the end of the module.
23 """
24
25 import doctest
26 import unittest
27
28 from properties import Property, doc_property, local_property, \
29     defaulting_property, checked_property, fn_checked_property, \
30     cached_property, primed_property, change_hook_property, \
31     settings_property
32
33
34 class _Token (object):
35     """
36     `Control' value class for properties.  We want values that only
37     mean something to the settings_object module.
38     """
39     pass
40
41 class UNPRIMED (_Token):
42     "Property has not been primed."
43     pass
44
45 class EMPTY (_Token):
46     """
47     Property has been primed but has no user-set value, so use
48     default/generator value.
49     """
50     pass
51
52
53 def prop_save_settings(self, old, new):
54     """
55     The default action undertaken when a property changes.
56     """
57     if self.sync_with_disk==True:
58         self.save_settings()
59
60 def prop_load_settings(self):
61     """
62     The default action undertaken when an UNPRIMED property is accessed.
63     """
64     if self.sync_with_disk==True and self._settings_loaded==False:
65         self.load_settings()
66     else:
67         self._setup_saved_settings(flag_as_loaded=False)
68
69 # Some name-mangling routines for pretty printing setting names
70 def setting_name_to_attr_name(self, name):
71     """
72     Convert keys to the .settings dict into their associated
73     SavedSettingsObject attribute names.
74     >>> print setting_name_to_attr_name(None,"User-id")
75     user_id
76     """
77     return name.lower().replace('-', '_')
78
79 def attr_name_to_setting_name(self, name):
80     """
81     The inverse of setting_name_to_attr_name.
82     >>> print attr_name_to_setting_name(None, "user_id")
83     User-id
84     """
85     return name.capitalize().replace('_', '-')
86
87
88 def versioned_property(name, doc,
89                        default=None, generator=None,
90                        change_hook=prop_save_settings,
91                        mutable=False,
92                        primer=prop_load_settings,
93                        allowed=None, check_fn=None,
94                        settings_properties=[],
95                        required_saved_properties=[],
96                        require_save=False):
97     """
98     Combine the common decorators in a single function.
99
100     Use zero or one (but not both) of default or generator, since a
101     working default will keep the generator from functioning.  Use the
102     default if you know what you want the default value to be at
103     'coding time'.  Use the generator if you can write a function to
104     determine a valid default at run time.  If both default and
105     generator are None, then the property will be a defaulting
106     property which defaults to None.
107
108     allowed and check_fn have a similar relationship, although you can
109     use both of these if you want.  allowed compares the proposed
110     value against a list determined at 'coding time' and check_fn
111     allows more flexible comparisons to take place at run time.
112
113     Set require_save to True if you want to save the default/generated
114     value for a property, to protect against future changes.  E.g., we
115     currently expect all comments to be 'text/plain' but in the future
116     we may want to default to 'text/html'.  If we don't want the old
117     comments to be interpreted as 'text/html', we would require that
118     the content type be saved.
119
120     change_hook, primer, settings_properties, and
121     required_saved_properties are only options to get their defaults
122     into our local scope.  Don't mess with them.
123
124     Set mutable=True if:
125       * default is a mutable
126       * your generator function may return mutables
127       * you set change_hook and might have mutable property values
128     See the docstrings in libbe.properties for details on how each of
129     these cases are handled.
130     """
131     settings_properties.append(name)
132     if require_save == True:
133         required_saved_properties.append(name)
134     def decorator(funcs):
135         fulldoc = doc
136         if default != None or generator == None:
137             defaulting  = defaulting_property(default=default, null=EMPTY,
138                                               mutable_default=mutable)
139             fulldoc += "\n\nThis property defaults to %s." % default
140         if generator != None:
141             cached = cached_property(generator=generator, initVal=EMPTY,
142                                      mutable=mutable)
143             fulldoc += "\n\nThis property is generated with %s." % generator
144         if check_fn != None:
145             fn_checked = fn_checked_property(value_allowed_fn=check_fn)
146             fulldoc += "\n\nThis property is checked with %s." % check_fn
147         if allowed != None:
148             checked = checked_property(allowed=allowed)
149             fulldoc += "\n\nThe allowed values for this property are: %s." \
150                        % (', '.join(allowed))
151         hooked      = change_hook_property(hook=change_hook, mutable=mutable,
152                                            default=EMPTY)
153         primed      = primed_property(primer=primer, initVal=UNPRIMED)
154         settings    = settings_property(name=name, null=UNPRIMED)
155         docp        = doc_property(doc=fulldoc)
156         deco = hooked(primed(settings(docp(funcs))))
157         if default != None or generator == None:
158             deco = defaulting(deco)
159         if generator != None:
160             deco = cached(deco)
161         if check_fn != None:
162             deco = fn_checked(deco)
163         if allowed != None:
164             deco = checked(deco)
165         return Property(deco)
166     return decorator
167
168 class SavedSettingsObject(object):
169
170     # Keep a list of properties that may be stored in the .settings dict.
171     #settings_properties = []
172
173     # A list of properties that we save to disk, even if they were
174     # never set (in which case we save the default value).  This
175     # protects against future changes in default values.
176     #required_saved_properties = []
177
178     _setting_name_to_attr_name = setting_name_to_attr_name
179     _attr_name_to_setting_name = attr_name_to_setting_name
180
181     def __init__(self):
182         self._settings_loaded = False
183         self.sync_with_disk = False
184         self.settings = {}
185
186     def load_settings(self):
187         """Load the settings from disk."""
188         # Override.  Must call ._setup_saved_settings() after loading.
189         self.settings = {}
190         self._setup_saved_settings()
191
192     def _setup_saved_settings(self, flag_as_loaded=True):
193         """
194         To be run after setting self.settings up from disk.  Marks all
195         settings as primed.
196         """
197         for property in self.settings_properties:
198             if property not in self.settings:
199                 self.settings[property] = EMPTY
200             elif self.settings[property] == UNPRIMED:
201                 self.settings[property] = EMPTY
202         if flag_as_loaded == True:
203             self._settings_loaded = True
204
205     def save_settings(self):
206         """Load the settings from disk."""
207         # Override.  Should save the dict output of ._get_saved_settings()
208         settings = self._get_saved_settings()
209         pass # write settings to disk....
210
211     def _get_saved_settings(self):
212         settings = {}
213         for k,v in self.settings.items():
214             if v != None and v != EMPTY:
215                 settings[k] = v
216         for k in self.required_saved_properties:
217             settings[k] = getattr(self, self._setting_name_to_attr_name(k))
218         return settings
219
220     def clear_cached_setting(self, setting=None):
221         "If setting=None, clear *all* cached settings"
222         if setting != None:
223             if hasattr(self, "_%s_cached_value" % setting):
224                 delattr(self, "_%s_cached_value" % setting)
225         else:
226             for setting in settings_properties:
227                 self.clear_cached_setting(setting)
228
229
230 class SavedSettingsObjectTests(unittest.TestCase):
231     def testSimpleProperty(self):
232         """Testing a minimal versioned property"""
233         class Test(SavedSettingsObject):
234             settings_properties = []
235             required_saved_properties = []
236             @versioned_property(name="Content-type",
237                                 doc="A test property",
238                                 settings_properties=settings_properties,
239                                 required_saved_properties=required_saved_properties)
240             def content_type(): return {}
241             def __init__(self):
242                 SavedSettingsObject.__init__(self)
243         t = Test()
244         # access missing setting
245         self.failUnless(t._settings_loaded == False, t._settings_loaded)
246         self.failUnless(len(t.settings) == 0, len(t.settings))
247         self.failUnless(t.content_type == None, t.content_type)
248         # accessing t.content_type triggers the priming, which runs
249         # t._setup_saved_settings, which fills out t.settings with
250         # EMPTY data.  t._settings_loaded is still false though, since
251         # the default priming does not do any of the `official' loading
252         # that occurs in t.load_settings.
253         self.failUnless(len(t.settings) == 1, len(t.settings))
254         self.failUnless(t.settings["Content-type"] == EMPTY,
255                         t.settings["Content-type"])
256         self.failUnless(t._settings_loaded == False, t._settings_loaded)
257         # load settings creates an EMPTY value in the settings array
258         t.load_settings()
259         self.failUnless(t._settings_loaded == True, t._settings_loaded)
260         self.failUnless(t.settings["Content-type"] == EMPTY,
261                         t.settings["Content-type"])
262         self.failUnless(t.content_type == None, t.content_type)
263         self.failUnless(len(t.settings) == 1, len(t.settings))
264         self.failUnless(t.settings["Content-type"] == EMPTY,
265                         t.settings["Content-type"])
266         # now we set a value
267         t.content_type = 5
268         self.failUnless(t.settings["Content-type"] == 5,
269                         t.settings["Content-type"])
270         self.failUnless(t.content_type == 5, t.content_type)
271         self.failUnless(t.settings["Content-type"] == 5,
272                         t.settings["Content-type"])
273         # now we set another value
274         t.content_type = "text/plain"
275         self.failUnless(t.content_type == "text/plain", t.content_type)
276         self.failUnless(t.settings["Content-type"] == "text/plain",
277                         t.settings["Content-type"])
278         self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
279                         t._get_saved_settings())
280         # now we clear to the post-primed value
281         t.content_type = EMPTY
282         self.failUnless(t._settings_loaded == True, t._settings_loaded)
283         self.failUnless(t.settings["Content-type"] == EMPTY,
284                         t.settings["Content-type"])
285         self.failUnless(t.content_type == None, t.content_type)
286         self.failUnless(len(t.settings) == 1, len(t.settings))
287         self.failUnless(t.settings["Content-type"] == EMPTY,
288                         t.settings["Content-type"])
289     def testDefaultingProperty(self):
290         """Testing a defaulting versioned property"""
291         class Test(SavedSettingsObject):
292             settings_properties = []
293             required_saved_properties = []
294             @versioned_property(name="Content-type",
295                                 doc="A test property",
296                                 default="text/plain",
297                                 settings_properties=settings_properties,
298                                 required_saved_properties=required_saved_properties)
299             def content_type(): return {}
300             def __init__(self):
301                 SavedSettingsObject.__init__(self)
302         t = Test()
303         self.failUnless(t._settings_loaded == False, t._settings_loaded)
304         self.failUnless(t.content_type == "text/plain", t.content_type)
305         self.failUnless(t._settings_loaded == False, t._settings_loaded)
306         t.load_settings()
307         self.failUnless(t._settings_loaded == True, t._settings_loaded)
308         self.failUnless(t.content_type == "text/plain", t.content_type)
309         self.failUnless(t.settings["Content-type"] == EMPTY,
310                         t.settings["Content-type"])
311         self.failUnless(t._get_saved_settings() == {}, t._get_saved_settings())
312         t.content_type = "text/html"
313         self.failUnless(t.content_type == "text/html",
314                         t.content_type)
315         self.failUnless(t.settings["Content-type"] == "text/html",
316                         t.settings["Content-type"])
317         self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
318                         t._get_saved_settings())
319     def testRequiredDefaultingProperty(self):
320         """Testing a required defaulting versioned property"""
321         class Test(SavedSettingsObject):
322             settings_properties = []
323             required_saved_properties = []
324             @versioned_property(name="Content-type",
325                                 doc="A test property",
326                                 default="text/plain",
327                                 settings_properties=settings_properties,
328                                 required_saved_properties=required_saved_properties,
329                                 require_save=True)
330             def content_type(): return {}
331             def __init__(self):
332                 SavedSettingsObject.__init__(self)
333         t = Test()
334         self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
335                         t._get_saved_settings())
336         t.content_type = "text/html"
337         self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
338                         t._get_saved_settings())
339     def testClassVersionedPropertyDefinition(self):
340         """Testing a class-specific _versioned property decorator"""
341         class Test(SavedSettingsObject):
342             settings_properties = []
343             required_saved_properties = []
344             def _versioned_property(settings_properties=settings_properties,
345                                     required_saved_properties=required_saved_properties,
346                                     **kwargs):
347                 if "settings_properties" not in kwargs:
348                     kwargs["settings_properties"] = settings_properties
349                 if "required_saved_properties" not in kwargs:
350                     kwargs["required_saved_properties"]=required_saved_properties
351                 return versioned_property(**kwargs)
352             @_versioned_property(name="Content-type",
353                                 doc="A test property",
354                                 default="text/plain",
355                                 require_save=True)
356             def content_type(): return {}
357             def __init__(self):
358                 SavedSettingsObject.__init__(self)
359         t = Test()
360         self.failUnless(t._get_saved_settings()=={"Content-type":"text/plain"},
361                         t._get_saved_settings())
362         t.content_type = "text/html"
363         self.failUnless(t._get_saved_settings()=={"Content-type":"text/html"},
364                         t._get_saved_settings())
365     def testMutableChangeHookedProperty(self):
366         """Testing a mutable change-hooked property"""
367         SAVES = []
368         def prop_log_save_settings(self, old, new, saves=SAVES):
369             saves.append("'%s' -> '%s'" % (str(old), str(new)))
370             prop_save_settings(self, old, new)
371         class Test(SavedSettingsObject):
372             settings_properties = []
373             required_saved_properties = []
374             @versioned_property(name="List-type",
375                                 doc="A test property",
376                                 mutable=True,
377                                 change_hook=prop_log_save_settings,
378                                 settings_properties=settings_properties,
379                                 required_saved_properties=required_saved_properties)
380             def list_type(): return {}
381             def __init__(self):
382                 SavedSettingsObject.__init__(self)
383         t = Test()
384         self.failUnless(t._settings_loaded == False, t._settings_loaded)
385         t.load_settings()
386         self.failUnless(SAVES == [], SAVES)
387         self.failUnless(t._settings_loaded == True, t._settings_loaded)
388         self.failUnless(t.list_type == None, t.list_type)
389         self.failUnless(SAVES == [], SAVES)
390         self.failUnless(t.settings["List-type"]==EMPTY,t.settings["List-type"])
391         t.list_type = []
392         self.failUnless(t.settings["List-type"] == [], t.settings["List-type"])
393         self.failUnless(SAVES == [
394                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'"
395                 ], SAVES)
396         t.list_type.append(5)
397         self.failUnless(SAVES == [
398                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
399                 ], SAVES)
400         self.failUnless(t.settings["List-type"] == [5],t.settings["List-type"])
401         self.failUnless(SAVES == [ # the append(5) has not yet been saved
402                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
403                 ], SAVES)
404         self.failUnless(t.list_type == [5], t.list_type) # <-get triggers saved
405
406         self.failUnless(SAVES == [ # now the append(5) has been saved.
407                 "'<class 'libbe.settings_object.EMPTY'>' -> '[]'",
408                 "'[]' -> '[5]'"
409                 ], SAVES)
410
411 unitsuite=unittest.TestLoader().loadTestsFromTestCase(SavedSettingsObjectTests)
412 suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])