Bumped to version 1.0.1
[be.git] / libbe / storage / util / settings_object.py
1 # Bugs Everywhere - a distributed bugtracker
2 # Copyright (C) 2008-2011 Chris Ball <cjb@laptop.org>
3 #                         Gianluca Montecchi <gian@grys.it>
4 #                         W. Trevor King <wking@drexel.edu>
5 #
6 # This file is part of Bugs Everywhere.
7 #
8 # Bugs Everywhere is free software; you can redistribute it and/or modify it
9 # under the terms of the GNU General Public License as published by the
10 # Free Software Foundation, either version 2 of the License, or (at your
11 # option) any later version.
12 #
13 # Bugs Everywhere is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16 # General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
20
21 """Provides :class:`SavedSettingsObject` implementing settings-dict
22 based property storage.
23
24 See Also
25 --------
26 :mod:`libbe.storage.util.properties` : underlying property definitions
27 """
28
29 import libbe
30 from properties import Property, doc_property, local_property, \
31     defaulting_property, checked_property, fn_checked_property, \
32     cached_property, primed_property, change_hook_property, \
33     settings_property
34 if libbe.TESTING == True:
35     import doctest
36     import unittest
37
38 class _Token (object):
39     """`Control' value class for properties.
40
41     We want values that only mean something to the `settings_object`
42     module.
43     """
44     pass
45
46 class UNPRIMED (_Token):
47     "Property has not been primed (loaded)."
48     pass
49
50 class EMPTY (_Token):
51     """Property has been primed but has no user-set value, so use
52     default/generator value.
53     """
54     pass
55
56
57 def prop_save_settings(self, old, new):
58     """The default action undertaken when a property changes.
59     """
60     if self.storage != None and self.storage.is_writeable():
61         self.save_settings()
62
63 def prop_load_settings(self):
64     """The default action undertaken when an UNPRIMED property is
65     accessed.
66
67     Attempt to run `.load_settings()`, which calls
68     `._setup_saved_settings()` internally.  If `.storage` is
69     inaccessible, don't do anything.
70     """
71     if self.storage != None and self.storage.is_readable():
72         self.load_settings()
73
74 # Some name-mangling routines for pretty printing setting names
75 def setting_name_to_attr_name(self, name):
76     """Convert keys to the `.settings` dict into their associated
77     SavedSettingsObject attribute names.
78
79     Examples
80     --------
81
82     >>> print setting_name_to_attr_name(None,"User-id")
83     user_id
84
85     See Also
86     --------
87     attr_name_to_setting_name : inverse
88     """
89     return name.lower().replace('-', '_')
90
91 def attr_name_to_setting_name(self, name):
92     """Convert SavedSettingsObject attribute names to `.settings` dict
93     keys.
94
95     Examples:
96
97     >>> print attr_name_to_setting_name(None, "user_id")
98     User-id
99
100     See Also
101     --------
102     setting_name_to_attr_name : inverse
103     """
104     return name.capitalize().replace('_', '-')
105
106
107 def versioned_property(name, doc,
108                        default=None, generator=None,
109                        change_hook=prop_save_settings,
110                        mutable=False,
111                        primer=prop_load_settings,
112                        allowed=None, check_fn=None,
113                        settings_properties=[],
114                        required_saved_properties=[],
115                        require_save=False):
116     """Combine the common decorators in a single function.
117
118     Use zero or one (but not both) of default or generator, since a
119     working default will keep the generator from functioning.  Use the
120     default if you know what you want the default value to be at
121     'coding time'.  Use the generator if you can write a function to
122     determine a valid default at run time.  If both default and
123     generator are None, then the property will be a defaulting
124     property which defaults to None.
125
126     allowed and check_fn have a similar relationship, although you can
127     use both of these if you want.  allowed compares the proposed
128     value against a list determined at 'coding time' and check_fn
129     allows more flexible comparisons to take place at run time.
130
131     Set require_save to True if you want to save the default/generated
132     value for a property, to protect against future changes.  E.g., we
133     currently expect all comments to be 'text/plain' but in the future
134     we may want to default to 'text/html'.  If we don't want the old
135     comments to be interpreted as 'text/html', we would require that
136     the content type be saved.
137
138     change_hook, primer, settings_properties, and
139     required_saved_properties are only options to get their defaults
140     into our local scope.  Don't mess with them.
141
142     Set mutable=True if:
143
144     * default is a mutable
145     * your generator function may return mutables
146     * you set change_hook and might have mutable property values
147
148     See the docstrings in `libbe.properties` for details on how each of
149     these cases are handled.
150
151     The value stored in `.settings[name]` will be
152
153     * no value (or UNPRIMED) if the property has been neither set,
154       nor loaded as blank.
155     * EMPTY if the value has been loaded as blank.
156     * some value if the property has been either loaded or set.
157     """
158     settings_properties.append(name)
159     if require_save == True:
160         required_saved_properties.append(name)
161     def decorator(funcs):
162         fulldoc = doc
163         if default != None or generator == None:
164             defaulting  = defaulting_property(default=default, null=EMPTY,
165                                               mutable_default=mutable)
166             fulldoc += "\n\nThis property defaults to %s." % default
167         if generator != None:
168             cached = cached_property(generator=generator, initVal=EMPTY,
169                                      mutable=mutable)
170             fulldoc += "\n\nThis property is generated with %s." % generator
171         if check_fn != None:
172             fn_checked = fn_checked_property(value_allowed_fn=check_fn)
173             fulldoc += "\n\nThis property is checked with %s." % check_fn
174         if allowed != None:
175             checked = checked_property(allowed=allowed)
176             fulldoc += "\n\nThe allowed values for this property are: %s." \
177                        % (', '.join(allowed))
178         hooked      = change_hook_property(hook=change_hook, mutable=mutable,
179                                            default=EMPTY)
180         primed      = primed_property(primer=primer, initVal=UNPRIMED,
181                                       unprimeableVal=EMPTY)
182         settings    = settings_property(name=name, null=UNPRIMED)
183         docp        = doc_property(doc=fulldoc)
184         deco = hooked(primed(settings(docp(funcs))))
185         if default != None or generator == None:
186             deco = defaulting(deco)
187         if generator != None:
188             deco = cached(deco)
189         if check_fn != None:
190             deco = fn_checked(deco)
191         if allowed != None:
192             deco = checked(deco)
193         return Property(deco)
194     return decorator
195
196 class SavedSettingsObject(object):
197     """Setup a framework for lazy saving and loading of `.settings`
198     properties.
199
200     This is useful for BE objects with saved properties
201     (e.g. :class:`~libbe.bugdir.BugDir`, :class:`~libbe.bug.Bug`,
202     :class:`~libbe.comment.Comment`).  For example usage, consider the
203     unittests at the end of the module.
204
205     See Also
206     --------
207     versioned_property, prop_save_settings, prop_load_settings
208     setting_name_to_attr_name, attr_name_to_setting_name
209     """
210     # Keep a list of properties that may be stored in the .settings dict.
211     #settings_properties = []
212
213     # A list of properties that we save to disk, even if they were
214     # never set (in which case we save the default value).  This
215     # protects against future changes in default values.
216     #required_saved_properties = []
217
218     _setting_name_to_attr_name = setting_name_to_attr_name
219     _attr_name_to_setting_name = attr_name_to_setting_name
220
221     def __init__(self):
222         self.storage = None
223         self.settings = {}
224
225     def load_settings(self):
226         """Load the settings from disk."""
227         # Override.  Must call ._setup_saved_settings({}) with
228         # from-storage settings.
229         self._setup_saved_settings({})
230
231     def _setup_saved_settings(self, settings=None):
232         """
233         Sets up a settings dict loaded from storage.  Fills in
234         all missing settings entries with EMPTY.
235         """
236         if settings == None:
237             settings = {}
238         for property in self.settings_properties:
239             if property not in self.settings \
240                     or self.settings[property] == UNPRIMED:
241                 if property in settings:
242                     self.settings[property] = settings[property]
243                 else:
244                     self.settings[property] = EMPTY
245
246     def save_settings(self):
247         """Save the settings to disk."""
248         # Override.  Should save the dict output of ._get_saved_settings()
249         settings = self._get_saved_settings()
250         pass # write settings to disk....
251
252     def _get_saved_settings(self):
253         """
254         In order to avoid overwriting unread on-disk data, make sure
255         we've loaded anything sitting on the disk.  In the current
256         implementation, all the settings are stored in a single file,
257         so we need to load _all_ the saved settings.  Another approach
258         would be per-setting saves, in which case you could skip this
259         step, since any setting changes would have forced that setting
260         load already.
261         """
262         settings = {}
263         for k in self.settings_properties: # force full load
264             if not k in self.settings or self.settings[k] == UNPRIMED:
265                 value = getattr(
266                     self, self._setting_name_to_attr_name(k))
267         for k in self.settings_properties:
268             if k in self.settings and self.settings[k] != EMPTY:
269                 settings[k] = self.settings[k]
270             elif k in self.required_saved_properties:
271                 settings[k] = getattr(
272                     self, self._setting_name_to_attr_name(k))
273         return settings
274
275     def clear_cached_setting(self, setting=None):
276         "If setting=None, clear *all* cached settings"
277         if setting != None:
278             if hasattr(self, "_%s_cached_value" % setting):
279                 delattr(self, "_%s_cached_value" % setting)
280         else:
281             for setting in settings_properties:
282                 self.clear_cached_setting(setting)
283
284
285 if libbe.TESTING == True:
286     import copy
287
288     class TestStorage (list):
289         def __init__(self):
290             list.__init__(self)
291             self.readable = True
292             self.writeable = True
293         def is_readable(self):
294             return self.readable
295         def is_writeable(self):
296             return self.writeable
297         
298     class TestObject (SavedSettingsObject):
299         def load_settings(self):
300             self.load_count += 1
301             if len(self.storage) == 0:
302                 settings = {}
303             else:
304                 settings = copy.deepcopy(self.storage[-1])
305             self._setup_saved_settings(settings)
306         def save_settings(self):
307             settings = self._get_saved_settings()
308             self.storage.append(copy.deepcopy(settings))
309         def __init__(self):
310             SavedSettingsObject.__init__(self)
311             self.load_count = 0
312             self.storage = TestStorage()
313
314     class SavedSettingsObjectTests(unittest.TestCase):
315         def testSimplePropertyDoc(self):
316             """Testing a minimal versioned property docstring"""
317             class Test (TestObject):
318                 settings_properties = []
319                 required_saved_properties = []
320                 @versioned_property(
321                     name="Content-type",
322                     doc="A test property",
323                     settings_properties=settings_properties,
324                     required_saved_properties=required_saved_properties)
325                 def content_type(): return {}
326             expected = "A test property\n\nThis property defaults to None."
327             self.failUnless(Test.content_type.__doc__ == expected,
328                             Test.content_type.__doc__)
329         def testSimplePropertyFromMemory(self):
330             """Testing a minimal versioned property from memory"""
331             class Test (TestObject):
332                 settings_properties = []
333                 required_saved_properties = []
334                 @versioned_property(
335                     name="Content-type",
336                     doc="A test property",
337                     settings_properties=settings_properties,
338                     required_saved_properties=required_saved_properties)
339                 def content_type(): return {}
340             t = Test()
341             self.failUnless(len(t.settings) == 0, len(t.settings))
342             # accessing t.content_type triggers the priming, but
343             # t.storage.is_readable() == False, so nothing happens.
344             t.storage.readable = False
345             self.failUnless(t.content_type == None, t.content_type)
346             self.failUnless(t.settings == {}, t.settings)
347             self.failUnless(len(t.settings) == 0, len(t.settings))
348             self.failUnless(t.content_type == None, t.content_type)
349             # accessing t.content_type triggers the priming again, and
350             # now that t.storage.is_readable() == True, this fills out
351             # t.settings with EMPTY data.  At this point there should
352             # be one load and no saves.
353             t.storage.readable = True
354             self.failUnless(t.content_type == None, t.content_type)
355             self.failUnless(len(t.settings) == 1, len(t.settings))
356             self.failUnless(t.settings["Content-type"] == EMPTY,
357                             t.settings["Content-type"])
358             self.failUnless(t.content_type == None, t.content_type)
359             self.failUnless(t.load_count == 1, t.load_count)
360             self.failUnless(len(t.storage) == 0, len(t.storage))
361             # an explicit call to load settings forces a reload,
362             # but nothing else changes.
363             t.load_settings()
364             self.failUnless(len(t.settings) == 1, len(t.settings))
365             self.failUnless(t.settings["Content-type"] == EMPTY,
366                             t.settings["Content-type"])
367             self.failUnless(t.content_type == None, t.content_type)
368             self.failUnless(t.load_count == 2, t.load_count)
369             self.failUnless(len(t.storage) == 0, len(t.storage))
370             # now we set a value
371             t.content_type = 5
372             self.failUnless(t.settings["Content-type"] == 5,
373                             t.settings["Content-type"])
374             self.failUnless(t.load_count == 2, t.load_count)
375             self.failUnless(len(t.storage) == 1, len(t.storage))
376             self.failUnless(t.storage == [{'Content-type':5}], t.storage)
377             # getting its value changes nothing
378             self.failUnless(t.content_type == 5, t.content_type)
379             self.failUnless(t.settings["Content-type"] == 5,
380                             t.settings["Content-type"])
381             self.failUnless(t.load_count == 2, t.load_count)
382             self.failUnless(len(t.storage) == 1, len(t.storage))
383             self.failUnless(t.storage == [{'Content-type':5}], t.storage)
384             # now we set another value
385             t.content_type = "text/plain"
386             self.failUnless(t.content_type == "text/plain", t.content_type)
387             self.failUnless(t.settings["Content-type"] == "text/plain",
388                             t.settings["Content-type"])
389             self.failUnless(t.load_count == 2, t.load_count)
390             self.failUnless(len(t.storage) == 2, len(t.storage))
391             self.failUnless(t.storage == [{'Content-type':5},
392                                           {'Content-type':'text/plain'}],
393                             t.storage)
394             # t._get_saved_settings() returns a dict of required or
395             # non-default values.
396             self.failUnless(t._get_saved_settings() == \
397                                 {"Content-type":"text/plain"},
398                             t._get_saved_settings())
399             # now we clear to the post-primed value
400             t.content_type = EMPTY
401             self.failUnless(t.settings["Content-type"] == EMPTY,
402                             t.settings["Content-type"])
403             self.failUnless(t.content_type == None, t.content_type)
404             self.failUnless(len(t.settings) == 1, len(t.settings))
405             self.failUnless(t.settings["Content-type"] == EMPTY,
406                             t.settings["Content-type"])
407             self.failUnless(t._get_saved_settings() == {},
408                             t._get_saved_settings())
409             self.failUnless(t.storage == [{'Content-type':5},
410                                           {'Content-type':'text/plain'},
411                                           {}],
412                             t.storage)
413         def testSimplePropertyFromStorage(self):
414             """Testing a minimal versioned property from storage"""
415             class Test (TestObject):
416                 settings_properties = []
417                 required_saved_properties = []
418                 @versioned_property(
419                     name="prop-a",
420                     doc="A test property",
421                     settings_properties=settings_properties,
422                     required_saved_properties=required_saved_properties)
423                 def prop_a(): return {}
424                 @versioned_property(
425                     name="prop-b",
426                     doc="Another test property",
427                     settings_properties=settings_properties,
428                     required_saved_properties=required_saved_properties)
429                 def prop_b(): return {}
430             t = Test()
431             t.storage.append({'prop-a':'saved'})
432             # setting prop-b forces a load (to check for changes),
433             # which also pulls in prop-a.
434             t.prop_b = 'new-b'
435             settings = {'prop-b':'new-b', 'prop-a':'saved'}
436             self.failUnless(t.settings == settings, t.settings)
437             self.failUnless(t._get_saved_settings() == settings,
438                             t._get_saved_settings())
439             # test that _get_saved_settings() works even when settings
440             # were _not_ loaded beforehand
441             t = Test()
442             t.storage.append({'prop-a':'saved'})
443             settings ={'prop-a':'saved'}
444             self.failUnless(t.settings == {}, t.settings)
445             self.failUnless(t._get_saved_settings() == settings,
446                             t._get_saved_settings())
447         def testSimplePropertySetStorageSave(self):
448             """Set a property, then attach storage and save"""
449             class Test (TestObject):
450                 settings_properties = []
451                 required_saved_properties = []
452                 @versioned_property(
453                     name="prop-a",
454                     doc="A test property",
455                     settings_properties=settings_properties,
456                     required_saved_properties=required_saved_properties)
457                 def prop_a(): return {}
458                 @versioned_property(
459                     name="prop-b",
460                     doc="Another test property",
461                     settings_properties=settings_properties,
462                     required_saved_properties=required_saved_properties)
463                 def prop_b(): return {}
464             t = Test()
465             storage = t.storage
466             t.storage = None
467             t.prop_a = 'text/html'
468             t.storage = storage
469             t.save_settings()
470             self.failUnless(t.prop_a == 'text/html', t.prop_a)
471             self.failUnless(t.settings == {'prop-a':'text/html',
472                                            'prop-b':EMPTY},
473                             t.settings)
474             self.failUnless(t.load_count == 1, t.load_count)
475             self.failUnless(len(t.storage) == 1, len(t.storage))
476             self.failUnless(t.storage == [{'prop-a':'text/html'}],
477                             t.storage)
478         def testDefaultingProperty(self):
479             """Testing a defaulting versioned property"""
480             class Test (TestObject):
481                 settings_properties = []
482                 required_saved_properties = []
483                 @versioned_property(
484                     name="Content-type",
485                     doc="A test property",
486                     default="text/plain",
487                     settings_properties=settings_properties,
488                     required_saved_properties=required_saved_properties)
489                 def content_type(): return {}
490             t = Test()
491             self.failUnless(t.settings == {}, t.settings)
492             self.failUnless(t.content_type == "text/plain", t.content_type)
493             self.failUnless(t.settings == {"Content-type":EMPTY},
494                             t.settings)
495             self.failUnless(t.load_count == 1, t.load_count)
496             self.failUnless(len(t.storage) == 0, len(t.storage))
497             self.failUnless(t._get_saved_settings() == {},
498                             t._get_saved_settings())
499             t.content_type = "text/html"
500             self.failUnless(t.content_type == "text/html",
501                             t.content_type)
502             self.failUnless(t.settings == {"Content-type":"text/html"},
503                             t.settings)
504             self.failUnless(t.load_count == 1, t.load_count)
505             self.failUnless(len(t.storage) == 1, len(t.storage))
506             self.failUnless(t.storage == [{'Content-type':'text/html'}],
507                             t.storage)
508             self.failUnless(t._get_saved_settings() == \
509                                 {"Content-type":"text/html"},
510                             t._get_saved_settings())
511         def testRequiredDefaultingProperty(self):
512             """Testing a required defaulting versioned property"""
513             class Test (TestObject):
514                 settings_properties = []
515                 required_saved_properties = []
516                 @versioned_property(
517                     name="Content-type",
518                     doc="A test property",
519                     default="text/plain",
520                     settings_properties=settings_properties,
521                     required_saved_properties=required_saved_properties,
522                     require_save=True)
523                 def content_type(): return {}
524             t = Test()
525             self.failUnless(t.settings == {}, t.settings)
526             self.failUnless(t.content_type == "text/plain", t.content_type)
527             self.failUnless(t.settings == {"Content-type":EMPTY},
528                             t.settings)
529             self.failUnless(t.load_count == 1, t.load_count)
530             self.failUnless(len(t.storage) == 0, len(t.storage))
531             self.failUnless(t._get_saved_settings() == \
532                                 {"Content-type":"text/plain"},
533                             t._get_saved_settings())
534             t.content_type = "text/html"
535             self.failUnless(t.content_type == "text/html",
536                             t.content_type)
537             self.failUnless(t.settings == {"Content-type":"text/html"},
538                             t.settings)
539             self.failUnless(t.load_count == 1, t.load_count)
540             self.failUnless(len(t.storage) == 1, len(t.storage))
541             self.failUnless(t.storage == [{'Content-type':'text/html'}],
542                             t.storage)
543             self.failUnless(t._get_saved_settings() == \
544                                 {"Content-type":"text/html"},
545                             t._get_saved_settings())
546         def testClassVersionedPropertyDefinition(self):
547             """Testing a class-specific _versioned property decorator"""
548             class Test (TestObject):
549                 settings_properties = []
550                 required_saved_properties = []
551                 def _versioned_property(
552                         settings_properties=settings_properties,
553                         required_saved_properties=required_saved_properties,
554                         **kwargs):
555                     if "settings_properties" not in kwargs:
556                         kwargs["settings_properties"] = settings_properties
557                     if "required_saved_properties" not in kwargs:
558                         kwargs["required_saved_properties"] = \
559                             required_saved_properties
560                     return versioned_property(**kwargs)
561                 @_versioned_property(name="Content-type",
562                                      doc="A test property",
563                                      default="text/plain",
564                                      require_save=True)
565                 def content_type(): return {}
566             t = Test()
567             self.failUnless(t._get_saved_settings() == \
568                                 {"Content-type":"text/plain"},
569                             t._get_saved_settings())
570             self.failUnless(t.load_count == 1, t.load_count)
571             self.failUnless(len(t.storage) == 0, len(t.storage))
572             t.content_type = "text/html"
573             self.failUnless(t._get_saved_settings() == \
574                                 {"Content-type":"text/html"},
575                             t._get_saved_settings())
576             self.failUnless(t.load_count == 1, t.load_count)
577             self.failUnless(len(t.storage) == 1, len(t.storage))
578             self.failUnless(t.storage == [{'Content-type':'text/html'}],
579                             t.storage)
580         def testMutableChangeHookedProperty(self):
581             """Testing a mutable change-hooked property"""
582             class Test (TestObject):
583                 settings_properties = []
584                 required_saved_properties = []
585                 @versioned_property(
586                     name="List-type",
587                     doc="A test property",
588                     mutable=True,
589                     change_hook=prop_save_settings,
590                     settings_properties=settings_properties,
591                     required_saved_properties=required_saved_properties)
592                 def list_type(): return {}
593             t = Test()
594             self.failUnless(len(t.storage) == 0, len(t.storage))
595             self.failUnless(t.list_type == None, t.list_type)
596             self.failUnless(len(t.storage) == 0, len(t.storage))
597             self.failUnless(t.settings["List-type"]==EMPTY,
598                             t.settings["List-type"])
599             t.list_type = []
600             self.failUnless(t.settings["List-type"] == [],
601                             t.settings["List-type"])
602             self.failUnless(len(t.storage) == 1, len(t.storage))
603             self.failUnless(t.storage == [{'List-type':[]}],
604                             t.storage)
605             t.list_type.append(5) # external modification not detected yet
606             self.failUnless(len(t.storage) == 1, len(t.storage))
607             self.failUnless(t.storage == [{'List-type':[]}],
608                             t.storage)
609             self.failUnless(t.settings["List-type"] == [5],
610                             t.settings["List-type"])
611             self.failUnless(t.list_type == [5], t.list_type)# get triggers save
612             self.failUnless(len(t.storage) == 2, len(t.storage))
613             self.failUnless(t.storage == [{'List-type':[]},
614                                           {'List-type':[5]}],
615                             t.storage)
616
617     unitsuite = unittest.TestLoader().loadTestsFromTestCase( \
618         SavedSettingsObjectTests)
619     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])