def test_save_load_skip_starting_with_pound(self): self.p.set('hello/#there', 'world') self.assertIn('hello/#there', self.p) self.p.save() p = Preferences(app=self.app) self.assertTrue(p.load()) self.assertNotIn('hello/#there', p)
class CommandProcessor(QtCore.QObject): """Implement the command pattern and shared application state. The "command" pattern (also called "action" or "transaction" pattern) allows for all UI actions to funnel through a central location. This class consistent state management and support for undo/redo. The Joulescope UI intentionally does not use the Qt Undo framework, because we can provide much greater flexibility in Python. This class also integrates with Preferences, which are shared application state that can be changed. For example, the Joulescope's current range setting may be controlled by multiple widgets within the UI. This class implements a consistent publish/subscribe model to these Preferences. Unlike with Qt's signals and slots, a subscriber can automatically register itself without any knowledge of the producer. See `The Many Faces of Publish/Subscribe <http://members.unine.ch/pascal.felber/publications/CS-03.pdf>`_. This class uses :meth:`register` for command and :meth:`subscribe` for preferences. This distinction may seem arbitrary, but commands must only have a single subscriber that processes the command and returns the undo. The command processor for preferences is handled internally to this class, and subscribers simply observe the result. The return value for subscriber calls is ignored. You can subscribe to commands to know that they occurred. """ invokeSignal = QtCore.Signal(str, object, object) def __init__(self, parent=None, synchronous=None, app=None): QtCore.QObject.__init__(self, parent) self.preferences = Preferences(parent=self, app=app) self.restore_success = self.preferences.load() starting_profile = self.preferences.get('General/starting_profile', profile='default', default=None) if starting_profile == 'app defaults': # New instance, do not load! self.preferences = Preferences(parent=self, app=app) self.preferences.set('General/starting_profile', starting_profile) self._topic = {} self._subscribers = {} self._undos = [ ] # tuples of (do, undo), do is tuple (command, data), undo is list of tuples (command, data) self._redos = [ ] # tuples of (do, undo), do is tuple (command, data), undo is list of tuples (command, data) self._thread_id = None self._topic_stack = [] self._stack_undo = None self.register('!undo', self._undo) self.register('!redo', self._redo) self.register('!command_group/start', self._command_group_start) self.register('!command_group/end', self._command_group_end) self.register('!preferences/profile/add', self._preferences_profile_add) self.register('!preferences/profile/remove', self._preferences_profile_remove) self.register('!preferences/profile/set', self._preferences_profile_set) self.register('!preferences/save', self._preferences_save) self.register('!preferences/load', self._preferences_load) self.register('!preferences/restore', self._preferences_restore) self.register('!preferences/preference/purge', self._preferences_preference_purge) self.register('!preferences/preference/set', self._preferences_preference_set) # name, value, profile self.register('!preferences/preference/clear', self._preferences_preference_clear) # name, profile # Push all commands through the Qt event queue by default connect_type = QtCore.Qt.QueuedConnection if bool(synchronous): connect_type = QtCore.Qt.AutoConnection self.invokeSignal.connect(self._on_invoke, type=connect_type) def __str__(self): return "CommandProcessor: %d commands, %d undos, %d redos" % (len( self._topic), len(self._undos), len(self._redos)) def __getitem__(self, key): return self.preferences.get(key) def __setitem__(self, key, value): self.publish(key, value) def __delitem__(self, key): self.invoke('!preferences/preference/purge', key) def __contains__(self, key): return self.preferences.__contains__(key) def items(self, prefix=None): return self.preferences.items(prefix=prefix) @property def undos(self): """The list of currently available undos.""" return [do_cmd[0] for do_cmd, _ in self._undos] @property def redos(self): """The list of currently available redos.""" return [do_cmd[0] for do_cmd, _ in self._redos] def _undo(self, topic, data): if len(self._undos): do_cmd, undo_cmds = self._undos.pop() for undo_topic, undo_data in undo_cmds[-1::-1]: if _is_command(undo_topic): log.debug('undo_exec %s | %s', undo_topic, undo_data) fn = self._topic[undo_topic]['execute_fn']() if fn is not None: fn(undo_topic, undo_data) else: log.debug('undo_pref %s | %s', undo_topic, undo_data) self.preferences[undo_topic] = undo_data self._redos.append((do_cmd, undo_cmds)) self._subscriber_update(undo_topic, undo_data) if undo_topic == '!command_group/end': while len(self._undos): (redo_topic, _), _ = self._undos[-1] self._undo(None, None) if redo_topic == '!command_group/start': break return None def _redo(self, topic, data): if len(self._redos): do_cmd, undo_cmds = self._redos.pop() do_topic, do_data = do_cmd if _is_command(do_topic): log.debug('redo_exec %s | %s', do_topic, do_data) fn = self._topic[do_topic]['execute_fn']() if fn is not None: fn(do_topic, do_data) else: self.preferences[do_topic] = do_data self._undos.append((do_cmd, undo_cmds)) self._subscriber_update(do_topic, do_data) if do_topic == '!command_group/start': while len(self._redos): (redo_topic, _), _ = self._redos[-1] self._redo(None, None) if redo_topic == '!command_group/end': break return None def _command_group_start(self, topic, data): return topic, data def _command_group_end(self, topic, data): return topic, data @QtCore.Slot(str, object) def _on_invoke(self, topic, data, no_undo=None): if self._thread_id is None: self._thread_id = threading.get_ident() self._topic_stack.append(topic) # re-entrant! try: redo_undos = None if _is_command(topic): log.debug('cmd %s | %s', topic, data) if bool(self._topic[topic].get('record_undo', False)) and len( self._topic_stack) == 1: self._stack_undo = [] execute_fn = self._topic[topic]['execute_fn']() if execute_fn is not None: rv = execute_fn(topic, data) if rv is not None and rv[0] is not None: if isinstance(rv[0], str): redo_undos = (topic, data), [rv] else: redo, undos = rv if not isinstance(undos[0][0], str): raise ValueError( 'invalid return value for topic %s', topic) redo_undos = rv else: if TOPIC_TEMPORARY_CHAR not in topic: try: data_orig = self.preferences.get(topic) if data == data_orig: return # ignore, no change necessary if len(self._undos) and self._undos[0][0][0] == topic: # coalesce repeated actions to the same topic _, undos = self._undos.pop() if 1 == len(undos): _, data_orig = undos[0] undos = [(topic, data_orig)] except KeyError: undos = [('!preferences/preference/purge', topic)] log.debug('set %s <= %s', topic, data) redo_undos = (topic, data), undos self.preferences[topic] = data finally: self._topic_stack.pop() is_dependent_command = len(self._topic_stack) if redo_undos is not None and not no_undo: if is_dependent_command: if self._stack_undo is not None: self._stack_undo.extend(redo_undos[1]) else: if self._stack_undo is not None and len(self._stack_undo): redo, undos = redo_undos redo_undos = (redo, self._stack_undo + undos) self._undos.append(redo_undos) self._redos.clear() if not is_dependent_command: self._stack_undo = None self._subscriber_update(topic, data) def invoke(self, topic, data=None): """Invoke a new command. :param topic: The command's topic name. :param data: The optional associated data. The commands "redo" and "undo" are registered automatically, and neither take data. """ if not _is_command(topic): raise ValueError( 'invoke commands only, use publish for preferences') self.publish(topic, data) def publish(self, topic, data, no_undo=None): """Publish new data to a topic. :param topic: The topic name. :param data: The new data for the topic. :param no_undo: Publish the parameter without posting an undo. """ if _is_command(topic): if topic not in self._topic: raise KeyError(f'unknown command {topic}') fn = self._topic[topic]['validate_fn'] if fn is not None: fn = fn() # dereference weakref if fn is not None and callable(fn): data = fn(data) else: data = self.preferences.validate(topic, data) if self._thread_id == threading.get_ident( ) and self._stack_undo is not None: self._on_invoke(topic, data, no_undo) else: self.invokeSignal.emit(topic, data, bool(no_undo)) def subscribe(self, topic, update_fn, update_now=False): """Subscribe to a topic. :param topic: The topic name. Topic names that end with "/" are wildcards that match all subtopics. :param update_fn: The callable(topic, data) that will be called whenever topic is published. The return value is ignored. Note that this instance stores a weakref to update_fn so that subscribing does not keep the subscriber alive. Since this instance creates and stores weakrefs, the update_fn must remain referenced externally. To prevent unintentionally having update_fn go out of scope, creating and passing a lambda or local function will raise a ValueError. Write the caller to provide a method or module function. :param update_now: When True, call update_fn with the current value for all matching topics. Any commands (topics that start with "!") will not match since they do not have persistent state. """ if _is_lambda_or_local(update_fn): raise ValueError( f'Provided update_fn {update_fn.__qualname__} that may have limited lifetime' ) subscribers = self._subscribers.get(topic, []) update_fn = _weakref_factory(update_fn) subscribers.append(update_fn) self._subscribers[topic] = subscribers if bool(update_now): if _is_command(topic): log.warning('commands do not support update_now') return elif topic[-1] == '/': for t, v in self.preferences.items(prefix=topic): self._subscriber_call(update_fn, t, v) else: try: value = self.preferences[topic] self._subscriber_call(update_fn, topic, value) except KeyError: log.info('subscribed to missing topic %s', topic) def unsubscribe(self, topic, update_fn): """Unsubscribe from a topic. :param topic: The topic name provided to :meth:`subscribe`. :param update_fn: The callable provided to :meth:`subscribe`. """ subscribers = self._subscribers.get(topic, []) if isinstance(update_fn, _weakref_type): update_fn = update_fn() if update_fn is not None: for subscriber in subscribers: if subscriber() == update_fn: subscribers.remove(subscriber) return True log.info('unsubscribe not found for %s', topic) return False def _subscriber_call(self, subscriber, topic, value): try: fn = subscriber() if fn is None: return False fn(topic, value) except: log.exception('subscriber error for topic=%s, value=%s', topic, value) return True def _subscribers_call(self, subscribers, topic, value): remove_indices = [] for idx, subscriber in enumerate(subscribers): if not self._subscriber_call(subscriber, topic, value): remove_indices.append(idx) for idx in remove_indices[-1::-1]: log.debug('removing expired subscriber from %s', topic) subscribers.pop(idx) def _subscriber_update(self, topic, value): subscribers = self._subscribers.get(topic, []) self._subscribers_call(subscribers, topic, value) subscriber_parts = topic.split('/') while len(subscriber_parts): subscriber_parts[-1] = '' n = '/'.join(subscriber_parts) subscribers = self._subscribers.get(n, []) self._subscribers_call(subscribers, topic, value) subscriber_parts.pop() def _preferences_bulk_update(self, profile_name=None, flat_old=None): if flat_old is None: flat_old = self.preferences.flatten() if profile_name is not None: self.preferences.profile = profile_name flat_new = self.preferences.flatten() for key, value in flat_new.items(): if TOPIC_TEMPORARY_CHAR in key: continue if key not in flat_old or flat_new[key] != flat_old[key]: self._subscriber_update(key, value) return self def _preferences_profile_add(self, topic, data): # data is either name, or (name, flatten) if isinstance(data, str): name = data self.preferences.profile_add(data) else: name = data[0] self.preferences.profile_add(data[0]) for t, v in data[1].items(): self.preferences[t] = v return '!preferences/profile/remove', name def _preferences_profile_remove(self, topic, data): flat_old = self.preferences.flatten() self.preferences.profile_remove(data) return '!preferences/profile/add', (data, flat_old) def _preferences_profile_set(self, topic, data): profile = data profile_prev = self.preferences.profile if profile_prev == data: return self._preferences_bulk_update(profile_name=profile) return '!preferences/profile/set', profile_prev def _preferences_save(self, topic, data): self.preferences.save() return None def _preferences_load(self, topic, data): state_old = self.preferences.state_export() flat_old = self.preferences.flatten() self.preferences.load() self._preferences_bulk_update(flat_old=flat_old) return '!preferences/restore', state_old def _preferences_restore(self, topic, data): state_old = self.preferences.state_export() flat_old = self.preferences.flatten() self.preferences.state_restore(data) self._preferences_bulk_update(flat_old=flat_old) return '!preferences/restore', state_old def _preferences_preference_purge(self, topic, data): try: value = self.preferences[data] except KeyError: return None self.preferences.purge(data) return data, value def _preferences_preference_clear(self, topic, data): topic, profile = data try: value_orig = self.preferences.get(topic) value_profile = self.preferences.get(topic, profile=profile) self.preferences.clear(topic, profile=profile) value_new = self.preferences.get(topic) if value_orig != value_new: self._subscriber_update(topic, value_new) return '!preferences/preference/set', (topic, value_orig, profile) except KeyError: return None def _preferences_preference_set(self, topic, data): topic, value, profile = data value_orig = self.preferences.get(topic, default=None) try: previous_value = self.preferences.get(topic, profile=profile) undo = '!preferences/preference/set', (topic, previous_value, profile) except KeyError: undo = '!preferences/preference/clear', (topic, profile) self.preferences.set(topic, value, profile) value_new = self.preferences.get(topic, default=None) if value_orig != value_new: self._subscriber_update(topic, value_new) return undo def define(self, topic, brief=None, detail=None, dtype=None, options=None, default=None, default_profile_only=None): """Define a new preference. :param topic: The name for the preference which must be unique. Preferences must not contain a "!", but should use "/" to create hierarchical names, such as "widget/marker/color". :param brief: The brief user-meaningful description for this preference. :param detail: The detailed user-meaningful HTML formatted description for this preference. :param dtype: The data for this preference, which must be one of :data:`joulescope_ui.preferences.DTYPES_DEF`. :param options: The options when dtype='str', which can be one of: * list of allowed value strings * dict containing allowed value strings with entry dicts as containing any of [brief, detail, aliases], where aliases is a list of alternative allowed value string. :param default: The default value for this preference. Providing a default value is high recommended. :param default_profile_only: True to force this preference to exist in the default profile only. False or None (default), allow this preference to exist in all profiles and override the default profile. If '#' is in the topic string, then default_profile_only defaults to True. """ if _is_command(topic): raise ValueError(f'Invalid topic name "{topic}" for a preference.') return self.preferences.define( topic, brief=brief, detail=detail, dtype=dtype, options=options, default=default, default_profile_only=default_profile_only) def register(self, topic, execute_fn, validate_fn=None, brief=None, detail=None, record_undo=None): """Register a new command topic. :param topic: The name for the command topic which must be unique. Command topics not associated with a preference must start with "!" and use "/" to create a hierarchical names, such as "!widget/marker/add". Topics that do not start with "!" are presumed to be associated with a preference topic. :param execute_fn: The callable(topic, data) -> (undo_topic, undo_data) that executes the command and returns the undo command and undo data. If the callable returns None or (None, object), then no undo operation will be registered. The callable can optionally override the original command for future redos or have multiple undo operations by returning ((redo_topic, redo_data), [(undo1_topic, undo1_data), ...]). Note that the list of undos will be performed in reverse order! :param validate_fn: The optional callable(data) that validates the data. Returns the validate data on success, which may be different from the input data. Throw an exception on failure. :param brief: The brief user-meaningful description for this command. :param detail: The detailed user-meaningful HTML formatted description for this command. :param record_undo: True to record all publish and invokes that occur during the command for future undo as a single group. False to simply use the execute_fn return value for undo. None (default) is equivalent to False. :raises ValueError: If command is not a string of execute_fn is not callable. :raises KeyError: If command is already registered. """ if not isinstance(topic, str): raise ValueError('commands must be strings') if not _is_command(topic): raise ValueError(f'topic "{topic}" is not a valid command name') if topic in self._topic: raise KeyError(f'command already exists: {topic}') if not callable(execute_fn): raise ValueError('execute_fn is not callable') if _is_lambda_or_local(execute_fn): raise ValueError( f'Provided execute_fn {execute_fn.__qualname__} that may have limited lifetime' ) if _is_lambda_or_local(validate_fn): raise ValueError( f'Provided validate_fn {validate_fn.__qualname__} that may have limited lifetime' ) log.info('register command %s', topic) self._topic[topic] = { 'execute_fn': _weakref_factory(execute_fn), 'validate_fn': _weakref_factory(validate_fn), 'brief': brief, 'detail': detail, 'record_undo': bool(record_undo) } def unregister(self, topic): """Unregister a command. :param topic: The command to unregister. """ if topic not in self._topic: log.warning('unregister command %s, but not registered', topic) return del self._topic[topic] def convert_units(self, field, value, units=None): """Convert a field value into user-configurable preferred units. :param field: The field name, such as 'current'. :param value: The float value to convert or the dict of {'value': value, 'units': units}. :param units: The units for when value is a float. Ignored otherwise. :return: dict of {'value': value, 'units': units}. """ if value is None: value = 0.0 if units is None: units = FIELD_UNITS_SI.get(field) if units is None: units = value['units'] value = value['value'] output_units = self.preferences.get('Units/' + field, default=units) return convert_units(value, units, output_units) def elapsed_time_formatter(self, seconds): """Format time in seconds to a string. :param seconds: The elapsed time in seconds. :return: The elapsed time string. """ return elapsed_time_formatter(seconds, cmdp=self)
class TestPreferences(unittest.TestCase): def setUp(self): self.listener_calls = [] self.app = f'joulescope_preferences_{os.getpid()}' self.paths = paths.paths_current(app=self.app) os.makedirs(self.paths['dirs']['config']) self.p = Preferences(app=self.app) def tearDown(self): paths.clear(app=self.app, delete_data=True) def test_get_without_set_or_define(self): with self.assertRaises(KeyError): self.p.get('hello') with self.assertRaises(KeyError): self.p['hello'] def test_set_get_without_define(self): self.p.set('hello', 'world') self.assertEqual('world', self.p.get('hello')) def test_get_set_get(self): with self.assertRaises(KeyError): self.p.get('hello') self.assertEqual('default', self.p.get('hello', default='default')) self.p.set('hello', 'world') self.assertEqual('world', self.p.get('hello')) self.assertEqual('world', self.p.get('hello', default='default')) def test_contains(self): self.assertFalse('hello' in self.p) self.p['hello'] = 'world' self.assertTrue('hello' in self.p) def test_set_profile_missing(self): with self.assertRaises(KeyError): self.p.set('hello', 'world', profile='p1') def test_get_profile_missing(self): with self.assertRaises(KeyError): self.p.get('hello', profile='p1') def test_set_clear(self): self.p.set('hello', 'world') self.p.clear('hello') with self.assertRaises(KeyError): self.p.get('hello') def test_define_set_clear(self): self.p.define(name='hello', default='default') self.p.set('hello', 'world') self.p.clear('hello') self.assertEqual('default', self.p.get('hello')) def test_profile_add_remove(self): self.assertEqual(BASE_PROFILE, self.p.profile) self.assertEqual([BASE_PROFILE], self.p.profiles) self.p.profile_add('p1') self.assertEqual([BASE_PROFILE, 'p1'], self.p.profiles) self.assertEqual(BASE_PROFILE, self.p.profile) self.p.profile = 'p1' self.assertEqual('p1', self.p.profile) self.p.profile_remove('p1') self.assertEqual(BASE_PROFILE, self.p.profile) self.assertEqual([BASE_PROFILE], self.p.profiles) def test_profile_override(self): self.p.set('hello', 'all_value') self.assertEqual('all_value', self.p.get('hello')) self.p.profile_add('p1', activate=True) self.assertEqual('all_value', self.p.get('hello')) self.assertFalse(self.p.is_in_profile('hello')) self.p.set('hello', 'p1_value') self.assertTrue(self.p.is_in_profile('hello')) self.assertEqual('p1_value', self.p.get('hello')) self.p.profile = BASE_PROFILE self.assertEqual('all_value', self.p.get('hello')) def test_load_not_found(self): self.p.define(name='hello', default='world') self.assertEqual('world', self.p['hello']) self.p.load() self.assertEqual('world', self.p['hello']) def test_save_load_simple(self): self.p.set('hello', 'world') self.p.save() p = Preferences(app=self.app).load() self.assertEqual('world', p.get('hello')) def test_save_load_skip_starting_with_pound(self): self.p.set('hello/#there', 'world') self.assertIn('hello/#there', self.p) self.p.save() p = Preferences(app=self.app).load() self.assertNotIn('hello/#there', p) def test_save_load_bytes(self): self.p.set('hello', b'world') self.p.save() p = Preferences(app=self.app).load() self.assertEqual(b'world', p.get('hello')) def test_define_default_when_new(self): self.p.define(name='hello', default='world') self.assertEqual('world', self.p.get('hello')) def test_define_default_when_existing(self): self.p.set('hello', 'there') self.p.define(name='hello', default='world') self.assertEqual('there', self.p.get('hello')) def test_validate_str(self): self.assertEqual('there', validate('there', 'str')) with self.assertRaises(ValueError): validate(1, 'str') with self.assertRaises(ValueError): validate(1.0, 'str') with self.assertRaises(ValueError): validate([], 'str') with self.assertRaises(ValueError): validate({}, 'str') def test_validate_str_options_list(self): options = options_conform(['a', 'b', 'c']) self.assertEqual('a', validate('a', 'str', options=options)) with self.assertRaises(ValueError): validate('A', 'str', options=options) def test_validate_str_options_map(self): options = options_conform({ 'a': { 'brief': 'option a' }, 'b': { 'brief': 'option b' }, 'c': {} }) self.assertEqual('a', validate('a', 'str', options=options)) with self.assertRaises(ValueError): validate('A', 'str', options=options) def test_validate_str_options_map_with_aliases(self): options = options_conform( {'a': { 'brief': 'option a', 'aliases': ['b', 'c'] }}) self.assertEqual('a', validate('a', 'str', options=options)) self.assertEqual('a', validate('b', 'str', options=options)) self.assertEqual('a', validate('c', 'str', options=options)) with self.assertRaises(ValueError): validate('d', 'str', options=options) def test_options_callable_list(self): self.assertEqual('a', validate('a', 'str', options=lambda: ['a'])) with self.assertRaises(ValueError): validate('d', 'str', options=lambda: ['a']) def test_options_callable_dict(self): self.assertEqual('a', validate('a', 'str', options=lambda: {'a': 'a'})) with self.assertRaises(ValueError): validate('d', 'str', options=lambda: {'a': 'a'}) def test_validate_int(self): self.assertEqual(1, validate(1, 'int')) self.assertEqual(1, validate('1', 'int')) with self.assertRaises(ValueError): validate('world', 'int') def test_validate_int_range(self): options = {'min': 1, 'max': 11, 'step': 2} self.assertEqual(1, validate(1, 'int', options=options)) self.assertEqual(3, validate(3, 'int', options=options)) self.assertEqual(11, validate(11, 'int', options=options)) with self.assertRaises(ValueError): validate(-1, 'int', options=options) with self.assertRaises(ValueError): validate(2, 'int', options=options) with self.assertRaises(ValueError): validate(13, 'int', options=options) def test_validate_int_list(self): options = [6, 7, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 64] self.assertEqual(10, validate(10, 'int', options=options)) self.assertEqual(10, validate('10', 'int', options=options)) with self.assertRaises(ValueError): self.assertEqual(10, validate(11, 'int', options=options)) def test_validate_float(self): self.assertEqual(1, validate(1, 'float')) self.assertEqual(1.1, validate(1.1, 'float')) self.assertEqual(1.1, validate('1.1', 'float')) with self.assertRaises(ValueError): validate('world', 'float') def test_validate_bool(self): self.assertEqual(True, validate(True, 'bool')) self.assertEqual(False, validate(False, 'bool')) self.assertEqual(False, validate(None, 'bool')) self.assertEqual(False, validate('off', 'bool')) self.assertEqual(False, validate('none', 'bool')) self.assertEqual(False, validate('None', 'bool')) self.assertEqual(False, validate('0', 'bool')) self.assertEqual(True, validate(1, 'bool')) self.assertEqual(True, validate('1.1', 'bool')) def test_validate_bytes(self): self.assertEqual(True, validate(b'12345', 'bytes')) def test_validate_dict(self): self.assertEqual({}, validate({}, 'dict')) with self.assertRaises(ValueError): validate('world', 'dict') def test_validate_none(self): self.assertEqual('hi', validate('hi', 'none')) def test_validate_color(self): self.assertEqual((1, 2, 3, 255), validate([1, 2, 3], 'color')) self.assertEqual((1, 2, 3, 4), validate([1, 2, 3, 4], 'color')) self.assertEqual((255, 0, 0, 255), validate('red', 'color')) with self.assertRaises(ValueError): validate('djflkajfdsfklsj', 'color') with self.assertRaises(ValueError): validate([1, 2], 'color') def test_validate_font(self): validate('Monospaced', 'font') def test_set_invalid_type(self): self.p.define(name='hello', dtype='str', default='world') with self.assertRaises(ValueError): self.p.set('hello', 1) def test_set_invalid_option(self): self.p.define(name='hello', dtype='str', options=['there', 'world'], default='world') self.p.set('hello', 'there') with self.assertRaises(ValueError): self.p.set('hello', 'you') def test_set_invalid_default(self): with self.assertRaises(ValueError): self.p.define(name='hello', dtype='str', options=['there', 'world'], default='bad') def test_definition_get(self): self.p.define(name='hello', dtype='str', default='world') d = self.p.definition_get(name='hello') def test_definitions_get(self): self.p.define(name='/', brief='top level', dtype='container') self.p.define(name='hello/', brief='holder', dtype='container') self.p.define(name='hello/world', brief='hello', dtype='str', default='world') self.p.define(name='hello/there/world', brief='hello', dtype='str', default='world') d = self.p.definitions self.assertEqual('/', d['name']) self.assertIn('children', d) self.assertEqual('hello/', d['children']['hello']['name']) self.assertEqual('hello/there/', d['children']['hello']['children']['there']['name']) def test_dict_style_access(self): p = self.p self.assertEqual(0, len(p)) p.define(name='hello/a', dtype='str', default='a_default') p.define(name='hello/b', dtype='str', default='b_default') self.assertEqual(2, len(p)) self.assertIn('hello/a', p) pairs = [(key, value) for key, value in p] self.assertEqual([('hello/a', 'a_default'), ('hello/b', 'b_default')], pairs) p.profile_add('p1', activate=True) p['hello/a'] = 'a_override' self.assertEqual('a_override', p['hello/a']) self.assertEqual('b_default', p['hello/b']) self.assertEqual(2, len(p)) del p['hello/a'] self.assertEqual(2, len(p)) self.assertEqual('a_default', p['hello/a']) def test_items(self): p = self.p p.define(name='a', dtype='str', default='zz') p.define(name='a/0', dtype='str', default='0') p.define(name='a/1', dtype='str', default='1') p.define(name='b/2', dtype='str', default='2') p.profile_add('p1', activate=True) p['a/1'] = 'new' self.assertEqual([('a', 'zz'), ('a/0', '0'), ('a/1', 'new'), ('b/2', '2')], list(p.items())) self.assertEqual([('a', 'zz'), ('a/0', '0'), ('a/1', 'new')], list(p.items(prefix='a'))) self.assertEqual([('a/0', '0'), ('a/1', 'new')], list(p.items(prefix='a/'))) self.assertEqual([('b/2', '2')], list(p.items(prefix='b/'))) def test_purge_single(self): self.p.define(name='a', dtype='str', default='zz') self.p.profile_add('p1', activate=True) self.p['a'] = '1' r = self.p.purge('a') self.assertEqual({ BASE_PROFILE: { 'a': 'zz' }, 'p1': { 'a': '1' } }, r['profiles']) with self.assertRaises(KeyError): self.p['a'] self.p.restore(r) self.assertEqual('1', self.p['a']) self.p.profile = BASE_PROFILE self.assertEqual('zz', self.p['a']) with self.assertRaises(ValueError): self.p['a'] = 1 def test_purge_hierarchy(self): p = self.p p.define(name='a/0', dtype='str', default='0') p.define(name='a/1', dtype='str', default='1') self.p.profile_add('p1', activate=True) self.p['a/0'] = '00' r = self.p.purge('a/') with self.assertRaises(KeyError): self.p['a/0'] self.p.restore(r) self.assertEqual('00', self.p['a/0']) self.p.profile = BASE_PROFILE self.assertEqual('0', self.p['a/0']) with self.assertRaises(ValueError): self.p['a/0'] = 1 def test_match(self): p = self.p p.define(name='a/0', dtype='str', default='0') p.define(name='a/1', dtype='str', default='1') self.assertEqual(['a/0', 'a/1'], p.match('a/')) self.assertEqual(['a/0'], p.match('a/0')) p.profile_add('p1', activate=True) self.assertEqual([], p.match('a/')) p['a/0'] = 'zz' self.assertEqual(['a/0'], p.match('a/')) def test_singleton(self): self.p.define(name='a', dtype='str', default='0', default_profile_only=True) self.p.profile_add('p', activate=True) self.p['a'] = 'override' self.assertEqual('override', self.p.get('a', profile='p')) self.assertEqual('override', self.p.get('a', profile=BASE_PROFILE)) def test_restore_base_default(self): self.p.define(name='a', dtype='str', default='0') self.p['a'] = 'base' self.p['b'] = 'no define' self.p.profile_add('p', activate=True) self.p['a'] = 'override' self.p.restore_base_defaults() self.assertEqual('override', self.p['a']) self.assertEqual('0', self.p.get('a', profile=BASE_PROFILE)) self.assertEqual('no define', self.p.get('b', profile=BASE_PROFILE))
def test_save_load_bytes(self): self.p.set('hello', b'world') self.p.save() p = Preferences(app=self.app) self.assertTrue(p.load()) self.assertEqual(b'world', p.get('hello'))