diff --git a/src/common/caps.py b/src/common/caps.py index ec0a9be51..fbda610b1 100644 --- a/src/common/caps.py +++ b/src/common/caps.py @@ -39,51 +39,30 @@ import gajim import helpers from common.xmpp import NS_XHTML_IM, NS_RECEIPTS, NS_ESESSION, NS_CHATSTATES - # Features where we cannot safely assume that the other side supports them FEATURE_BLACKLIST = [NS_CHATSTATES, NS_XHTML_IM, NS_RECEIPTS, NS_ESESSION] - class AbstractClientCaps(object): ''' Base class representing a client and its capabilities as advertised by a caps tag in a presence ''' - def __init__(self, caps_cache, caps_hash, node): - self._caps_cache = caps_cache + def __init__(self, caps_hash, node): self._hash = caps_hash self._node = node - def query_client_of_jid_if_unknown(self, connection, jid): - ''' - Asynchronously query the give jid for its (node, ver, exts) caps data - using a disco query. - - Query will only be sent if the data is not already cached. - ''' - q = self._lookup_in_cache() - if q.queried == 0: - self._discover(connection, jid) - q.queried = 1 - - def contains_feature(self, requested_feature): - ''' Returns true if these capabilities contain the given feature ''' - cach_entry = self._lookup_in_cache() - supported_features = cach_entry.features - if requested_feature in supported_features: - return True - elif supported_features == [] and cach_entry.queried in (0, 1): - # assume feature is supported, if not blacklisted - return requested_feature not in FEATURE_BLACKLIST - else: - return False - + def get_discover_strategy(self): + return self._discover + def _discover(self, connection, jid): ''' To be implemented by subclassess ''' raise NotImplementedError() - def _lookup_in_cache(self): + def get_cache_lookup_strategy(self): + return self._lookup_in_cache + + def _lookup_in_cache(self, caps_cache): ''' To be implemented by subclassess ''' raise NotImplementedError() @@ -91,13 +70,13 @@ class AbstractClientCaps(object): class ClientCaps(AbstractClientCaps): ''' The current XEP-115 implementation ''' - def __init__(self, caps_cache, caps_hash, node, hash_method): - AbstractClientCaps.__init__(self, caps_cache, caps_hash, node) + def __init__(self, caps_hash, node, hash_method): + AbstractClientCaps.__init__(self, caps_hash, node) assert hash_method != 'old' self._hash_method = hash_method - def _lookup_in_cache(self): - return self._caps_cache[(self._hash_method, self._hash)] + def _lookup_in_cache(self, caps_cache): + return caps_cache[(self._hash_method, self._hash)] def _discover(self, connection, jid): connection.discoverInfo(jid, '%s#%s' % (self._node, self._hash)) @@ -106,11 +85,11 @@ class ClientCaps(AbstractClientCaps): class OldClientCaps(AbstractClientCaps): ''' Old XEP-115 implemtation. Kept around for background competability. ''' - def __init__(self, caps_cache, caps_hash, node): - AbstractClientCaps.__init__(self, caps_cache, caps_hash, node) + def __init__(self, caps_hash, node): + AbstractClientCaps.__init__(self, caps_hash, node) - def _lookup_in_cache(self): - return self._caps_cache[('old', self._node + '#' + self._hash)] + def _lookup_in_cache(self, caps_cache): + return caps_cache[('old', self._node + '#' + self._hash)] def _discover(self, connection, jid): connection.discoverInfo(jid) @@ -125,32 +104,19 @@ class NullClientCaps(AbstractClientCaps): ''' def __init__(self): - pass + AbstractClientCaps.__init__(self, None, None) - def query_client_of_jid_if_unknown(self, connection, jid): - pass + def _lookup_in_cache(self, caps_cache): + return caps_cache[('old', '')] - def contains_feature(self, feature): - return feature not in FEATURE_BLACKLIST + def _discover(self, connection, jid): + pass class CapsCache(object): - ''' This object keeps the mapping between caps data and real disco + ''' + This object keeps the mapping between caps data and real disco features they represent, and provides simple way to query that info. - - It is application-wide, that is there's one object for all - connections. - - Goals: - * handle storing/retrieving info from database - * cache info in memory - * expose simple interface - - Properties: - * one object for all connections (move to logger.py?) - * store info efficiently (a set() of urls -- we can assume there won't be - too much of these, ensure that (X,Y,Z1) and (X,Y,Z2) has different - features. ''' def __init__(self, logger=None): ''' Create a cache for entity capabilities. ''' @@ -219,15 +185,14 @@ class CapsCache(object): def update(self, identities, features): # NOTE: self refers to CapsCache object, not to CacheItem - self.identities=identities - self.features=features + self.identities = identities + self.features = features self._logger.add_caps_entry(self.hash_method, self.hash, identities, features) self.__CacheItem = CacheItem # prepopulate data which we are sure of; note: we do not log these info - for account in gajim.connections: gajimcaps = self[('sha-1', gajim.caps_hash[account])] gajimcaps.identities = [gajim.gajim_identity] @@ -257,48 +222,19 @@ class CapsCache(object): self.__cache[(hash_method, hash_)] = x return x - def preload(self, con, jid, node, hash_method, hash_): + def query_client_of_jid_if_unknown(self, connection, jid, client_caps): ''' Preload data about (node, ver, exts) caps using disco query to jid using proper connection. Don't query if the data is already in cache. ''' - if hash_method == 'old': - q = self[(hash_method, node + '#' + hash_)] - else: - q = self[(hash_method, hash_)] - - if q.queried==0: + lookup_cache_item = client_caps.get_cache_lookup_strategy() + q = lookup_cache_item(self) + + if q.queried == 0: # do query for bare node+hash pair # this will create proper object - q.queried=1 - if hash_method == 'old': - con.discoverInfo(jid) - else: - con.discoverInfo(jid, '%s#%s' % (node, hash_)) - - def is_supported(self, contact, feature): - if not contact: - return False - - # Unfortunately, if all resources are offline, the contact - # includes the last resource that was online. Check for its - # show, so we can be sure it's existant. Otherwise, we still - # return caps for a contact that has no resources left. - if contact.show == 'offline': - return False - - # FIXME: We assume everything is supported if we got no caps. - # This is the "Asterix way", after 0.12 release, I will - # likely implement a fallback to disco (could be disabled - # for mobile users who pay for traffic) - if contact.caps_hash_method == 'old': - features = self[(contact.caps_hash_method, contact.caps_node + '#' + \ - contact.caps_hash)].features - else: - features = self[(contact.caps_hash_method, contact.caps_hash)].features - if feature in features or features == []: - return True - - return False + q.queried = 1 + discover = client_caps.get_discover_strategy() + discover(connection, jid) gajim.capscache = CapsCache(gajim.logger) diff --git a/src/common/contacts.py b/src/common/contacts.py index 7693031ed..05d7cfe1b 100644 --- a/src/common/contacts.py +++ b/src/common/contacts.py @@ -30,12 +30,15 @@ import common.gajim +from common.caps import NullClientCaps, FEATURE_BLACKLIST + + class Contact(object): '''Information concerning each contact''' def __init__(self, jid='', name='', groups=[], show='', status='', sub='', - ask='', resource='', priority=0, keyID='', caps_node=None, - caps_hash_method=None, caps_hash=None, our_chatstate=None, chatstate=None, - last_status_time=None, msg_id = None, composing_xep = None, mood={}, tune={}, + ask='', resource='', priority=0, keyID='', client_caps=None, caps_cache=None, + our_chatstate=None, chatstate=None, last_status_time=None, msg_id = None, + composing_xep = None, mood={}, tune={}, activity={}): self.jid = jid self.name = name @@ -49,11 +52,9 @@ class Contact(object): self.priority = priority self.keyID = keyID - # Capabilities; filled by caps.py/ConnectionCaps object - # every time it gets these from presence stanzas - self.caps_node = caps_node - self.caps_hash_method = caps_hash_method - self.caps_hash = caps_hash + # Entity Capabilities + self._client_caps = client_caps or NullClientCaps() + self._caps_cache = caps_cache or common.gajim.capscache # please read xep-85 http://www.xmpp.org/extensions/xep-0085.html # we keep track of xep85 support with the peer by three extra states: @@ -135,32 +136,34 @@ class Contact(object): return False - def _set_supported_caps(self, value): - ''' - Set an EntityCapabilities object - ''' - self._caps = value - - def _get_supported_caps(self): - ''' - Returns a function which delegates to the EntityCapabilites support checker - - This allows easy checks like: - if contact.supports(NS_COOL_FEATURE): ... - ''' - def test(feature): - if self.show == 'offline': - # Unfortunately, if all resources are offline, the contact - # includes the last resource that was online. Check for its - # show, so we can be sure it's existant. Otherwise, we still - # return caps for a contact that has no resources left. - return False - else: - return self._caps.contains_feature(feature) - return test - - supports = property(_get_supported_caps, _set_supported_caps) + def supports(self, requested_feature): + if self.show == 'offline': + # Unfortunately, if all resources are offline, the contact + # includes the last resource that was online. Check for its + # show, so we can be sure it's existant. Otherwise, we still + # return caps for a contact that has no resources left. + return False + else: + return self._client_supports(requested_feature) + def _client_supports(self, requested_feature): + lookup_item = self._client_caps.get_cache_lookup_strategy() + cache_item = lookup_item(self._caps_cache) + + supported_features = cache_item.features + if requested_feature in supported_features: + return True + elif supported_features == [] and cache_item.queried in (0, 1): + # assume feature is supported, if we don't know yet, what the client + # is capable of + return requested_feature not in FEATURE_BLACKLIST + else: + return False + + def set_supported_client_caps(self, client_caps): + ''' Set an EntityCapabilities object ''' + self._client_caps = client_caps + class GC_Contact: '''Information concerning each groupchat contact''' @@ -175,9 +178,11 @@ class GC_Contact: self.affiliation = affiliation self.jid = jid self.resource = resource - self.caps_node = None - self.caps_hash_method = None - self.caps_hash = None + + # Entity Capabilities + self._client_caps = NullClientCaps() + self._caps_cache = common.gajim.capscache + self.our_chatstate = our_chatstate self.composing_xep = composing_xep self.chatstate = chatstate @@ -188,31 +193,33 @@ class GC_Contact: def get_shown_name(self): return self.name - def _set_supported_caps(self, value): - ''' - Set an EntityCapabilities object - ''' - self._caps = value - - def _get_supported_caps(self): - ''' - Returns a function which delegates to the EntityCapabilites support checker - - This allows easy checks like: - if contact.supports(NS_COOL_FEATURE): ... - ''' - def test(feature): - if self.show == 'offline': - # Unfortunately, if all resources are offline, the contact - # includes the last resource that was online. Check for its - # show, so we can be sure it's existant. Otherwise, we still - # return caps for a contact that has no resources left. - return False - else: - return self._caps.contains_feature(feature) - return test - - supports = property(_get_supported_caps, _set_supported_caps) + def supports(self, requested_feature): + if self.show == 'offline': + # Unfortunately, if all resources are offline, the contact + # includes the last resource that was online. Check for its + # show, so we can be sure it's existant. Otherwise, we still + # return caps for a contact that has no resources left. + return False + else: + return self._client_supports(requested_feature) + + def _client_supports(self, requested_feature): + lookup_item = self._client_caps.get_cache_lookup_strategy() + cache_item = lookup_item(self._caps_cache) + + supported_features = cache_item.features + if requested_feature in supported_features: + return True + elif supported_features == [] and cache_item.queried in (0, 1): + # assume feature is supported, if we don't know yet, what the client + # is capable of + return requested_feature not in FEATURE_BLACKLIST + else: + return False + + def set_supported_client_caps(self, client_caps): + ''' Set an EntityCapabilities object ''' + self._client_caps = client_caps class Contacts: '''Information concerning all contacts and groupchat contacts''' @@ -246,8 +253,8 @@ class Contacts: del self._metacontacts_tags[account] def create_contact(self, jid='', name='', groups=[], show='', status='', - sub='', ask='', resource='', priority=0, keyID='', caps_node=None, - caps_hash_method=None, caps_hash=None, our_chatstate=None, + sub='', ask='', resource='', priority=0, keyID='', client_caps=None, + caps_cache=None, our_chatstate=None, chatstate=None, last_status_time=None, composing_xep=None, mood={}, tune={}, activity={}): @@ -259,8 +266,8 @@ class Contacts: return Contact(jid=jid, name=name, groups=groups_unique, show=show, status=status, sub=sub, ask=ask, resource=resource, priority=priority, - keyID=keyID, caps_node=caps_node, caps_hash_method=caps_hash_method, - caps_hash=caps_hash, our_chatstate=our_chatstate, chatstate=chatstate, + keyID=keyID, client_caps=client_caps, caps_cache=caps_cache, + our_chatstate=our_chatstate, chatstate=chatstate, last_status_time=last_status_time, composing_xep=composing_xep, mood=mood, tune=tune, activity=activity) @@ -269,8 +276,8 @@ class Contacts: groups=contact.groups, show=contact.show, status=contact.status, sub=contact.sub, ask=contact.ask, resource=contact.resource, priority=contact.priority, keyID=contact.keyID, - caps_node=contact.caps_node, caps_hash_method=contact.caps_hash_method, - caps_hash=contact.caps_hash, our_chatstate=contact.our_chatstate, + client_caps=contact._client_caps, caps_cache=contact._caps_cache, + our_chatstate=contact.our_chatstate, chatstate=contact.chatstate, last_status_time=contact.last_status_time) def add_contact(self, account, contact): @@ -640,9 +647,8 @@ class Contacts: jid = gc_contact.get_full_jid() return Contact(jid=jid, resource=gc_contact.resource, name=gc_contact.name, groups=[], show=gc_contact.show, - status=gc_contact.status, sub='none', caps_node=gc_contact.caps_node, - caps_hash_method=gc_contact.caps_hash_method, - caps_hash=gc_contact.caps_hash) + status=gc_contact.status, sub='none', client_caps=gc_contact._client_caps, + caps_cache=gc_contact._caps_cache) def create_gc_contact(self, room_jid='', name='', show='', status='', role='', affiliation='', jid='', resource=''): diff --git a/src/common/gajim.py b/src/common/gajim.py index 3511fe2d9..d0f6c1d49 100644 --- a/src/common/gajim.py +++ b/src/common/gajim.py @@ -32,8 +32,6 @@ import logging import locale import config -from contacts import Contacts -from events import Events import xmpp try: @@ -101,6 +99,9 @@ else: os_info = None # used to cache os information +from contacts import Contacts +from events import Events + gmail_domains = ['gmail.com', 'googlemail.com'] transport_type = {} # list the type of transport diff --git a/test/test_caps.py b/test/test_caps.py index 32823b078..5e0918ea6 100644 --- a/test/test_caps.py +++ b/test/test_caps.py @@ -6,11 +6,11 @@ import unittest import lib lib.setup_env() +from common import gajim from common import helpers -from common.contacts import Contact from common.xmpp import NS_MUC, NS_PING, NS_XHTML_IM - from common.caps import CapsCache, ClientCaps, OldClientCaps +from common.contacts import Contact from mock import Mock @@ -20,7 +20,7 @@ class CommonCapsTest(unittest.TestCase): def setUp(self): self.caps_method = 'sha-1' self.caps_hash = 'm3P2WeXPMGVH2tZPe7yITnfY0Dw=' - self.caps = (self.caps_method, self.caps_hash) + self.client_caps = (self.caps_method, self.caps_hash) self.node = "http://gajim.org" self.identity = {'category': 'client', 'type': 'pc', 'name':'Gajim'} @@ -35,6 +35,10 @@ class CommonCapsTest(unittest.TestCase): self.logger = Mock(returnValues={"iter_caps_data":db_caps_cache}) self.cc = CapsCache(self.logger) + # This is a temporary hack required by the way contacts rely on the + # existance of a cache. Hopefully this can be refactored to work via + # dependency injection + gajim.capscache = self.cc class TestCapsCache(CommonCapsTest): @@ -42,13 +46,13 @@ class TestCapsCache(CommonCapsTest): def test_set_retrieve(self): ''' Test basic set / retrieve cycle ''' - self.cc[self.caps].identities = self.identities - self.cc[self.caps].features = self.features + self.cc[self.client_caps].identities = self.identities + self.cc[self.client_caps].features = self.features - self.assert_(NS_MUC in self.cc[self.caps].features) - self.assert_(NS_PING not in self.cc[self.caps].features) + self.assert_(NS_MUC in self.cc[self.client_caps].features) + self.assert_(NS_PING not in self.cc[self.client_caps].features) - identities = self.cc[self.caps].identities + identities = self.cc[self.client_caps].identities self.assertEqual(1, len(identities)) @@ -57,9 +61,9 @@ class TestCapsCache(CommonCapsTest): self.assertEqual('pc', identity['type']) def test_update(self): - ''' Test caps update gets logged into db ''' + ''' Test client_caps update gets logged into db ''' - item = self.cc[self.caps] + item = self.cc[self.client_caps] item.update(self.identities, self.features) self.logger.mockCheckCall(0, "add_caps_entry", self.caps_method, @@ -67,45 +71,30 @@ class TestCapsCache(CommonCapsTest): def test_initialize_from_db(self): ''' Read cashed dummy data from db ''' - self.assertEqual(self.cc[self.caps].queried, 0) + self.assertEqual(self.cc[self.client_caps].queried, 0) self.cc.initialize_from_db() - self.assertEqual(self.cc[self.caps].queried, 2) + self.assertEqual(self.cc[self.client_caps].queried, 2) def test_preload_triggering_query(self): ''' Make sure that preload issues a disco ''' connection = Mock() + client_caps = ClientCaps(self.caps_hash, self.node, self.caps_method) + + self.cc.query_client_of_jid_if_unknown(connection, "test@gajim.org", + client_caps) - self.cc.preload(connection, "test@gajim.org", self.node, - self.caps_method, self.caps_hash) - - connection.mockCheckCall(0, "discoverInfo", "test@gajim.org", - "http://gajim.org#m3P2WeXPMGVH2tZPe7yITnfY0Dw=") + self.assertEqual(1, len(connection.mockGetAllCalls())) def test_no_preload_query_if_cashed(self): ''' Preload must not send a query if the data is already cached ''' connection = Mock() - + client_caps = ClientCaps(self.caps_hash, self.node, self.caps_method) + self.cc.initialize_from_db() - self.cc.preload(connection, "test@gajim.org", self.node, - self.caps_method, self.caps_hash) + self.cc.query_client_of_jid_if_unknown(connection, "test@gajim.org", + client_caps) self.assertEqual(0, len(connection.mockGetAllCalls())) - - def test_is_supported(self): - contact = Contact(caps_node=self.node, - caps_hash_method=self.caps_method, - caps_hash=self.caps_hash) - - self.assertTrue(self.cc.is_supported(contact, NS_PING), - msg="Assume everything is supported, if we don't have caps") - - self.cc.initialize_from_db() - - self.assertFalse(self.cc.is_supported(contact, NS_PING), - msg="Must return false on unsupported feature") - - self.assertTrue(self.cc.is_supported(contact, NS_MUC), - msg="Must return True on supported feature") def test_hash(self): '''tests the hash computation''' @@ -117,41 +106,36 @@ class TestClientCaps(CommonCapsTest): def setUp(self): CommonCapsTest.setUp(self) - self.caps = ClientCaps(self.cc, self.caps_hash, self.node, - self.caps_method) - - def test_no_query_client_of_jid(self): - ''' Client must not be queried if the data is already cached ''' - connection = Mock() - self.cc.initialize_from_db() - self.caps.query_client_of_jid_if_unknown(connection, "test@gajim.org") + self.client_caps = ClientCaps(self.caps_hash, self.node, self.caps_method) - self.assertEqual(0, len(connection.mockGetAllCalls())) - - def test_query_client_of_jid_if_unknown(self): + def test_query_by_get_discover_strategy(self): ''' Client must be queried if the data is unkown ''' connection = Mock() - self.caps.query_client_of_jid_if_unknown(connection, "test@gajim.org") + discover = self.client_caps.get_discover_strategy() + discover(connection, "test@gajim.org") connection.mockCheckCall(0, "discoverInfo", "test@gajim.org", "http://gajim.org#m3P2WeXPMGVH2tZPe7yITnfY0Dw=") - def test_is_supported(self): - self.assertTrue(self.caps.contains_feature(NS_PING), + def test_client_supports(self): + contact = Contact(caps_cache=self.cc) + contact.set_supported_client_caps(self.client_caps) + + self.assertTrue(contact.supports(NS_PING), msg="Assume supported, if we don't have caps") - self.assertFalse(self.caps.contains_feature(NS_XHTML_IM), + self.assertFalse(contact.supports(NS_XHTML_IM), msg="Must not assume blacklisted feature is supported on default") self.cc.initialize_from_db() - self.assertFalse(self.caps.contains_feature(NS_PING), + self.assertFalse(contact.supports(NS_PING), msg="Must return false on unsupported feature") - self.assertTrue(self.caps.contains_feature(NS_XHTML_IM), + self.assertTrue(contact.supports(NS_XHTML_IM), msg="Must return True on supported feature") - self.assertTrue(self.caps.contains_feature(NS_MUC), + self.assertTrue(contact.supports(NS_MUC), msg="Must return True on supported feature") @@ -159,25 +143,17 @@ class TestOldClientCaps(TestClientCaps): def setUp(self): TestClientCaps.setUp(self) - self.caps = OldClientCaps(self.cc, self.caps_hash, self.node) - - def test_no_query_client_of_jid(self): - ''' Client must not be queried if the data is already cached ''' - connection = Mock() - self.cc.initialize_from_db() - self.caps.query_client_of_jid_if_unknown(connection, "test@gajim.org") - - self.assertEqual(0, len(connection.mockGetAllCalls())) + self.client_caps = OldClientCaps(self.caps_hash, self.node) - def test_query_client_of_jid_if_unknown(self): - ''' Client must be queried if the data is unkown ''' + def test_query_by_get_discover_strategy(self): + ''' Client must be queried if the data is unknown ''' connection = Mock() - self.caps.query_client_of_jid_if_unknown(connection, "test@gajim.org") + discover = self.client_caps.get_discover_strategy() + discover(connection, "test@gajim.org") connection.mockCheckCall(0, "discoverInfo", "test@gajim.org") - if __name__ == '__main__': unittest.main() diff --git a/test/test_contacts.py b/test/test_contacts.py index 606646aa3..1ddce82e9 100644 --- a/test/test_contacts.py +++ b/test/test_contacts.py @@ -6,36 +6,43 @@ import unittest import lib lib.setup_env() -from common.contacts import Contact +from common.contacts import Contact, GC_Contact from common.caps import NullClientCaps +from common.xmpp import NS_MUC -from mock import Mock +class TestCommonContact(unittest.TestCase): + + def setUp(self): + self.contact = Contact() -class TestContact(unittest.TestCase): + def test_default_client_supports(self): + ''' + Test the caps support method of contacts. + See test_caps for more enhanced tests. + ''' + + self.assertTrue(self.contact.supports(NS_MUC), + msg="Must not backtrace on simple check for supported feature") + + client_caps = NullClientCaps() + self.contact.set_supported_client_caps(client_caps) + + self.assertTrue(self.contact.supports(NS_MUC), + msg="Must not backtrace on simple check for supported feature") + + +class TestContact(TestCommonContact): + + def setUp(self): + TestCommonContact.setUp(self) + self.contact = Contact() - def test_supports(self): - ''' Test the Entity Capabilities part of the contact instance ''' - - NS_MUC = 'http://jabber.org/protocol/muc' - - # Test with mocks to get basic set/get property behaviour checked - all_supported_mock_entity_caps = Mock( - returnValues={"contains_feature": True}) - nothing_supported_mock_entity_caps = Mock( - returnValues={"contains_feature": False}) - - contact = Contact() - - contact.supports = all_supported_mock_entity_caps - self.assertTrue(contact.supports(NS_MUC)) - - contact.supports = nothing_supported_mock_entity_caps - self.assertFalse(contact.supports(NS_MUC)) - - # Test with EntityCapabilites to detect API changes - contact.supports = NullClientCaps() - self.assertTrue(contact.supports(NS_MUC), - msg="Default behaviour is to support everything on unknown caps") + +class TestGC_Contact(TestCommonContact): + + def setUp(self): + TestCommonContact.setUp(self) + self.contact = GC_Contact() if __name__ == "__main__":