From fe5a546367923fea19fe86775c9796ee53ee9468 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Thu, 5 Oct 2023 15:35:31 +0100 Subject: [PATCH] Add Metadata methods to ProxyMetadata when reasonable to avoid AttributeError exceptions. Add tests for the new methods. --- src/calibre/db/lazy.py | 103 ++++++++++++++++++++++++++++---- src/calibre/db/tests/reading.py | 59 ++++++++++++++++++ 2 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/calibre/db/lazy.py b/src/calibre/db/lazy.py index 07a73a81aa..3c49d8c665 100644 --- a/src/calibre/db/lazy.py +++ b/src/calibre/db/lazy.py @@ -372,16 +372,30 @@ class ProxyMetadata(Metadata): if extra is not None: cache[field + '_index'] = val - def get_user_metadata(self, field, make_copy=False): - um = ga(self, '_user_metadata') - try: - ans = um[field] - except KeyError: - pass - else: - if make_copy: - ans = deepcopy(ans) - return ans + # Replacements (overrides) for methods in the Metadata base class. + # ProxyMetadata cannot set attributes. + + def _unimplemented_exception(self, method, add_txt): + raise NotImplementedError(f"{method}() cannot be used in this context. " + "{'ProxyMetadata is read only' if add_txt else ''}") + + # Metadata returns a seemingly arbitrary set of items. Rather than attempt + # compatibility, flag __iter__ as unimplemented. This won't break anything + # because the Metadata version raises AttributeError + def __iter__(self): + raise NotImplementedError(f"__iter__() cannot be used in this context. " + "Use the explicit methods such as all_field_keys()") + + def has_key(self, key): + return key in self.all_field_keys() + + def deepcopy(self, **kwargs): + self._unimplemented_exception('deepcopy', add_txt=False) + + def deepcopy_metadata(self): + return deepcopy(ga('_user_metadata')) + + # def get(self, field, default=None) def get_extra(self, field, default=None): um = ga(self, '_user_metadata') @@ -393,10 +407,37 @@ class ProxyMetadata(Metadata): raise AttributeError( 'Metadata object has no attribute named: '+ repr(field)) + def set(self, *args, **kwargs): + self._unimplemented_exception('set', add_txt=True) + + def get_identifiers(self): + res = self.get('identifiers') + return {} if res is None else res + + def set_identifiers(self, *args): + self._unimplemented_exception('set_identifiers', add_txt=True) + + def set_identifier(self, *args): + self._unimplemented_exception('set_identifier', add_txt=True) + + def has_identifier(self, typ): + return typ in self.get('identifiers', {}) + + # def standard_field_keys(self) + def custom_field_keys(self): um = ga(self, '_user_metadata') return iter(um.custom_field_keys()) + def all_field_keys(self): + um = ga(self, '_user_metadata') + return ALL_METADATA_FIELDS.union(frozenset(um.all_field_keys())) + + def all_non_none_fields(self): + self._unimplemented_exception('all_non_none_fields', add_txt=False) + + # This version can return custom column metadata while the Metadata version + # won't. def get_standard_metadata(self, field, make_copy=False): field_metadata = ga(self, '_user_metadata') if field in field_metadata and field_metadata[field]['kind'] == 'field': @@ -405,9 +446,47 @@ class ProxyMetadata(Metadata): return field_metadata[field] return None - def all_field_keys(self): + # def get_all_standard_metadata(self, make_copy) + + def get_all_user_metadata(self, make_copy): um = ga(self, '_user_metadata') - return frozenset(ALL_METADATA_FIELDS.union(frozenset(um))) + if make_copy: + res = {k: deepcopy(um[k]) for k in um.custom_field_keys()} + else: + res = {k: um[k] for k in um.custom_field_keys()} + return res + + # The Metadata version of this method works only with custom field keys. It + # isn't clear how this method differs from get_standard_metadata other than + # it will return non-'field' metadata. Leave it in case someone depends on + # that. + def get_user_metadata(self, field, make_copy=False): + um = ga(self, '_user_metadata') + try: + ans = um[field] + except KeyError: + pass + else: + if make_copy: + ans = deepcopy(ans) + return ans + + def set_all_user_metadata(self, *args): + self._unimplemented_exception('set_all_user_metadata', add_txt=True) + + def set_user_metadata(self, *args): + self._unimplemented_exception('set_user_metadata', add_txt=True) + + def remove_stale_user_metadata(self, *args): + self._unimplemented_exception('remove_stale_user_metadata', add_txt=True) + + def template_to_attribute(self, *args): + self._unimplemented_exception('template_to_attribute', add_txt=True) + + def smart_update(self, *args, **kwargs): + self._unimplemented_exception('smart_update', add_txt=True) + + # The rest of the methods in Metadata can be used as is. @property def _proxy_metadata(self): diff --git a/src/calibre/db/tests/reading.py b/src/calibre/db/tests/reading.py index a6daf73d18..905d740c97 100644 --- a/src/calibre/db/tests/reading.py +++ b/src/calibre/db/tests/reading.py @@ -649,6 +649,65 @@ class ReadingTest(BaseTest): mi, pmi = cache.get_metadata(1), cache.get_proxy_metadata(1) self.assertEqual(mi.get('#comp1'), pmi.get('#comp1')) + # Test overridden Metadata methods + + self.assertTrue(pmi.has_key('tags') == mi.has_key('tags')) + + self.assertFalse(pmi.has_key('taggs'), 'taggs attribute') + self.assertTrue(pmi.has_key('taggs') == mi.has_key('taggs')) + + self.assertSetEqual(set(pmi.custom_field_keys()), set(mi.custom_field_keys())) + + self.assertEqual(pmi.get_extra('#series', 0), 3) + self.assertEqual(pmi.get_extra('#series', 0), mi.get_extra('#series', 0)) + + self.assertDictEqual(pmi.get_identifiers(), {'test': 'two'}) + self.assertDictEqual(pmi.get_identifiers(), mi.get_identifiers()) + + self.assertTrue(pmi.has_identifier('test')) + self.assertTrue(pmi.has_identifier('test') == mi.has_identifier('test')) + + self.assertListEqual(list(pmi.custom_field_keys()), list(mi.custom_field_keys())) + + # ProxyMetadata has the virtual fields while Metadata does not. + self.assertSetEqual(set(pmi.all_field_keys())-{'id', 'series_sort', 'path', + 'in_tag_browser', 'sort', 'ondevice', + 'au_map', 'marked', '#series_index'}, + set(mi.all_field_keys())) + + # mi.get_standard_metadata() doesn't include the rec_index metadata key + fm_pmi = pmi.get_standard_metadata('series') + fm_pmi.pop('rec_index') + self.assertDictEqual(fm_pmi, mi.get_standard_metadata('series', make_copy=False)) + + # The ProxyMetadata versions don't include the values. Note that the mi + # version of get_standard_metadata won't return custom columns while the + # ProxyMetadata version will + fm_mi = mi.get_user_metadata('#series', make_copy=False) + fm_mi.pop('#extra#') + fm_mi.pop('#value#') + self.assertDictEqual(pmi.get_standard_metadata('#series'), fm_mi) + self.assertDictEqual(pmi.get_user_metadata('#series'), fm_mi) + + fm_mi = mi.get_all_user_metadata(make_copy=False) + for one in fm_mi: + fm_mi[one].pop('#extra#', None) + fm_mi[one].pop('#value#', None) + self.assertDictEqual(pmi.get_all_user_metadata(make_copy=False), fm_mi) + + # Check the unimplemented methods + self.assertRaises(NotImplementedError, lambda: 'foo' in pmi) + self.assertRaises(NotImplementedError, pmi.set, 'a', 'a') + self.assertRaises(NotImplementedError, pmi.set_identifiers, 'a', 'a') + self.assertRaises(NotImplementedError, pmi.set_identifier, 'a', 'a') + self.assertRaises(NotImplementedError, pmi.all_non_none_fields) + self.assertRaises(NotImplementedError, pmi.set_all_user_metadata, {}) + self.assertRaises(NotImplementedError, pmi.set_user_metadata, 'a', {}) + self.assertRaises(NotImplementedError, pmi.remove_stale_user_metadata, {}) + self.assertRaises(NotImplementedError, pmi.template_to_attribute, {}, {}) + self.assertRaises(NotImplementedError, pmi.smart_update, {}) + + # }}} def test_marked_field(self): # {{{