From 42889a5f3cded2e000f2e585fcfc5684a17a8255 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Fri, 22 May 2015 13:55:22 -0400 Subject: [PATCH] Implement 'pubsub.client.Client'. Wraps: - 'pubsub.api' functions - 'pubsub.topic.Topic' (via a factory / proxy) - 'pubsub.subscription.Subscription' (via a factory / proxy) See: https://github.com/GoogleCloudPlatform/gcloud-python/issues/861#issuecomment-104353673 --- docs/pubsub-usage.rst | 85 +++++++++++++ gcloud/_helpers.py | 34 ++++++ gcloud/pubsub/client.py | 130 ++++++++++++++++++++ gcloud/pubsub/test_client.py | 226 +++++++++++++++++++++++++++++++++++ gcloud/test__helpers.py | 89 ++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 gcloud/pubsub/client.py create mode 100644 gcloud/pubsub/test_client.py diff --git a/docs/pubsub-usage.rst b/docs/pubsub-usage.rst index 795a64d9464b..20637e34ceb9 100644 --- a/docs/pubsub-usage.rst +++ b/docs/pubsub-usage.rst @@ -265,3 +265,88 @@ Fetch messages for a pull subscription without blocking (none pending): >>> messages = [recv[1] for recv in received] >>> [message.id for message in messages] [] + +Using Clients +------------- + +A :class:`gcloud.pubsub.client.Client` instance explicitly manages a +:class:`gcloud.pubsub.connection.Connection` and an associated project +ID. Applications can then use the APIs which might otherwise take a +``connection`` or ``project`` parameter, knowing that the values configured +in the client will be passed. + +Create a client using the defaults from the environment: + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> client = Client() + +Create a client using an explicit connection, but the default project: + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> from gcloud.pubsub.connection import Connection + >>> connection = Connection.from_service_account_json('/path/to/creds.json') + >>> client = Client(connection=connection) + +Create a client using an explicit project ID, but the connetion inferred +from the environment: + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> client = Client(project='your-project-id') + +Listing topics using a client (note that the client's connection +is used to make the request, and its project is passed as a parameter): + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> client = Client(project='your-project-id') + >>> topics, next_page_token = client.list_topics() # API request + +Listing subscriptions using a client (note that the client's connection +is used to make the request, and its project is passed as a parameter): + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> client = Client(project='your-project-id') + >>> topics, next_page_token = client.list_topics() # API request + >>> subscription, next_page_tokens = list_subscriptions() # API request + +Instantiate a topic using a client (note that the client's project is passed +through to the topic constructor, and that the returned object is a proxy +which ensures that an API requests made via the topic use the client's +connection): + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> client = Client(project='your-project-id') + >>> topic = client.topic('topic-name') + >>> topic.exists() # API request + False + >>> topic.create() # API request + >>> topic.exists() # API request + True + +Instantiate a subscription using a client (note that the client's project is +passed through to the subscription constructor, and that the returned object +is a proxy which ensures that an API requests made via the subscription use +the client's connection): + +.. doctest:: + + >>> from gcloud.pubsub.client import Client + >>> client = Client(project='your-project-id') + >>> topic = client.topic('topic-name') + >>> subscription = topic.subscription('subscription-name') + >>> subscription.exists() # API request + False + >>> subscription.create() # API request + >>> subscription.exists() # API request + True diff --git a/gcloud/_helpers.py b/gcloud/_helpers.py index f62bb964c333..714a6b5df8ab 100644 --- a/gcloud/_helpers.py +++ b/gcloud/_helpers.py @@ -15,6 +15,9 @@ This module is not part of the public API surface of `gcloud`. """ + +import functools +import inspect import os import socket @@ -238,3 +241,34 @@ def __init__(self, project=None, implicit=False): _DEFAULTS = _DefaultsContainer(implicit=True) + + +class _ClientProxy(object): + """Proxy for :class:`gcloud.pubsub.topic.Topic`. + + :param wrapped: Domain instance being proxied. + + :param client: Client used to pass connection / project as needed to + methods of ``wrapped``. + """ + def __init__(self, wrapped, client): + self._wrapped = wrapped + self._client = client + + def __getattr__(self, name): + """Proxy to wrapped object. + + Pass 'connection' and 'project' from our client to methods which take + either / both. + """ + found = getattr(self._wrapped, name) + if inspect.ismethod(found): + args, _, _ = inspect.getargs(found.__code__) + curried = {} + if 'connection' in args: + curried['connection'] = self._client.connection + if 'project' in args: + curried['project'] = self._client.project + if curried: + found = functools.partial(found, **curried) + return found diff --git a/gcloud/pubsub/client.py b/gcloud/pubsub/client.py new file mode 100644 index 000000000000..9c0a35285d96 --- /dev/null +++ b/gcloud/pubsub/client.py @@ -0,0 +1,130 @@ +# Copyright 2015 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Convenience proxies + +Define wrappers for ``api`` functions, :class:`gcloud.pubsub.topic.Topic`, and +:class:`gcloud.pubsub.subscription.Subscription`, passing the memoized +connection / project as needed. +""" + +from gcloud._helpers import get_default_project +from gcloud._helpers import _ClientProxy +from gcloud.pubsub._implicit_environ import _require_connection +from gcloud.pubsub import api +from gcloud.pubsub.subscription import Subscription +from gcloud.pubsub.topic import Topic + + +class Client(object): + """Wrap :mod:`gcloud.pubsub` API objects. + + :type connection: :class:`gcloud.pubsub.connection.Connection` or None + :param connection: The configured connection. Defaults to one inferred + from the environment. + + :type project: str or None + :param connection: The configured project. Defaults to the value inferred + from the environment. + """ + + def __init__(self, connection=None, project=None): + self.connection = _require_connection(connection) + if project is None: + project = get_default_project() + self.project = project + + def topic(self, name): + """Proxy for :class:`gcloud.pubsub.topic.Topic`. + + :type name: string + :param name: the name of the topic + + :rtype: :class:`_Topic` + :returns: a proxy for a newly created Topic, using the passed name + and the client's project. + """ + topic = Topic(name, self.project) + return _Topic(topic, self) + + def list_topics(self, page_size=None, page_token=None): + """Proxy for :func:`gcloud.pubsub.api.list_topics`. + + Passes configured connection and project. + """ + topics, next_page_token = api.list_topics( + page_size=page_size, + page_token=page_token, + connection=self.connection, + project=self.project) + proxies = [_Topic(topic, self) for topic in topics] + return proxies, next_page_token + + def list_subscriptions(self, page_size=None, page_token=None, + topic_name=None): + """Proxy for :func:`gcloud.pubsub.api.list_subscriptions`. + + Passes configured connection and project. + """ + subscriptions, next_page_token = api.list_subscriptions( + page_size=page_size, + page_token=page_token, + topic_name=topic_name, + connection=self.connection, + project=self.project) + topics = dict([(sub.topic.name, _Topic(sub.topic, self)) + for sub in subscriptions]) + proxies = [ + _Subscription(sub, self, topics[sub.topic.name]) + for sub in subscriptions] + return proxies, next_page_token + + +class _Topic(_ClientProxy): + """Proxy for :class:`gcloud.pubsub.topic.Topic`. + + :type wrapped: :class:`gcloud.pubsub.topic.Topic` + :param wrapped: Topic being proxied. + + :type client: :class:`gcloud.pubsub.client.Client` + :param client: Client used to pass connection / project. + """ + def subscription(self, name, ack_deadline=None, push_endpoint=None): + """ Proxy through to :class:`gcloud.pubsub.subscription.Subscription`. + + :rtype: :class:`_Subscription` + """ + subscription = Subscription( + name, + self._wrapped, + ack_deadline=ack_deadline, + push_endpoint=push_endpoint) + return _Subscription(subscription, self._client, self) + + +class _Subscription(_ClientProxy): + """Proxy for :class:`gcloud.pubsub.subscription.Subscription`. + + :type wrapped: :class:`gcloud.pubsub.topic.Subscription` + :param wrapped: Subscription being proxied. + + :type client: :class:`gcloud.pubsub.client.Client` + :param client: Client used to pass connection / project. + + :type topic: :class:`gcloud.pubsub.client._Topic` + :param topic: proxy for the wrapped subscription's topic. + """ + def __init__(self, wrapped, client, topic): + super(_Subscription, self).__init__(wrapped, client) + self.topic = topic diff --git a/gcloud/pubsub/test_client.py b/gcloud/pubsub/test_client.py new file mode 100644 index 000000000000..99ad745e54be --- /dev/null +++ b/gcloud/pubsub/test_client.py @@ -0,0 +1,226 @@ +# Copyright 2015 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest2 + + +class TestClient(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.pubsub.client import Client + return Client + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_implicit(self): + from gcloud._testing import _monkey_defaults as _monkey_base_defaults + from gcloud.pubsub._testing import _monkey_defaults + PROJECT = 'PROJECT' + connection = _Connection() + with _monkey_base_defaults(project=PROJECT): + with _monkey_defaults(connection=connection): + client = self._makeOne() + self.assertTrue(client.connection is connection) + self.assertEqual(client.project, PROJECT) + + def test_ctor_explicit(self): + PROJECT = 'PROJECT' + connection = _Connection() + client = self._makeOne(connection, PROJECT) + self.assertTrue(client.connection is connection) + self.assertEqual(client.project, PROJECT) + + def test_topic(self): + from gcloud.pubsub.client import _Topic + from gcloud.pubsub.topic import Topic + PROJECT = 'PROJECT' + TOPIC_NAME = 'topic_name' + connection = _Connection() + client = self._makeOne(connection, PROJECT) + topic = client.topic(TOPIC_NAME) + self.assertTrue(isinstance(topic, _Topic)) + self.assertTrue(isinstance(topic._wrapped, Topic)) + self.assertTrue(topic._client is client) + self.assertEqual(topic.name, TOPIC_NAME) + self.assertEqual(topic.full_name, + 'projects/%s/topics/%s' % (PROJECT, TOPIC_NAME)) + + def test_list_topics_defaults(self): + from gcloud.pubsub.client import _Topic + TOPIC_NAME = 'topic_name' + PROJECT = 'PROJECT' + TOPIC_PATH = 'projects/%s/topics/%s' % (PROJECT, TOPIC_NAME) + returned = {'topics': [{'name': TOPIC_PATH}]} + connection = _Connection(returned) + client = self._makeOne(connection, PROJECT) + topics, next_page_token = client.list_topics() + self.assertEqual(len(topics), 1) + self.assertTrue(isinstance(topics[0], _Topic)) + self.assertEqual(topics[0].name, TOPIC_NAME) + self.assertEqual(next_page_token, None) + self.assertEqual(len(connection._requested), 1) + req = connection._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/projects/%s/topics' % PROJECT) + self.assertEqual(req['query_params'], {}) + + def test_list_topics_explicit(self): + from gcloud.pubsub.client import _Topic + TOPIC_NAME = 'topic_name' + PROJECT = 'PROJECT' + TOPIC_PATH = 'projects/%s/topics/%s' % (PROJECT, TOPIC_NAME) + TOKEN1 = 'TOKEN1' + TOKEN2 = 'TOKEN2' + SIZE = 1 + returned = {'topics': [{'name': TOPIC_PATH}], + 'nextPageToken': TOKEN2} + connection = _Connection(returned) + client = self._makeOne(connection, PROJECT) + topics, next_page_token = client.list_topics(SIZE, TOKEN1) + self.assertEqual(len(topics), 1) + self.assertTrue(isinstance(topics[0], _Topic)) + self.assertTrue(topics[0]._client is client) + self.assertEqual(topics[0].name, TOPIC_NAME) + self.assertEqual(next_page_token, TOKEN2) + self.assertEqual(len(connection._requested), 1) + req = connection._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/projects/%s/topics' % PROJECT) + self.assertEqual(req['query_params'], + {'pageSize': SIZE, 'pageToken': TOKEN1}) + + def test_list_subscriptions_defaults(self): + from gcloud.pubsub.client import _Subscription + from gcloud.pubsub.client import _Topic + PROJECT = 'PROJECT' + SUB_NAME = 'subscription_name' + SUB_PATH = 'projects/%s/subscriptions/%s' % (PROJECT, SUB_NAME) + TOPIC_NAME = 'topic_name' + TOPIC_PATH = 'projects/%s/topics/%s' % (PROJECT, TOPIC_NAME) + SUB_INFO = [{'name': SUB_PATH, 'topic': TOPIC_PATH}] + returned = {'subscriptions': SUB_INFO} + connection = _Connection(returned) + client = self._makeOne(connection, PROJECT) + subscriptions, next_page_token = client.list_subscriptions() + self.assertEqual(len(subscriptions), 1) + self.assertTrue(isinstance(subscriptions[0], _Subscription)) + self.assertTrue(subscriptions[0]._client is client) + self.assertEqual(subscriptions[0].name, SUB_NAME) + self.assertTrue(isinstance(subscriptions[0].topic, _Topic)) + self.assertTrue(subscriptions[0].topic._client is client) + self.assertEqual(subscriptions[0].topic.name, TOPIC_NAME) + self.assertEqual(next_page_token, None) + self.assertEqual(len(connection._requested), 1) + req = connection._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/projects/%s/subscriptions' % PROJECT) + self.assertEqual(req['query_params'], {}) + + def test_list_subscriptions_w_paging(self): + from gcloud.pubsub.client import _Subscription + from gcloud.pubsub.client import _Topic + PROJECT = 'PROJECT' + SUB_NAME = 'subscription_name' + SUB_PATH = 'projects/%s/subscriptions/%s' % (PROJECT, SUB_NAME) + TOPIC_NAME = 'topic_name' + TOPIC_PATH = 'projects/%s/topics/%s' % (PROJECT, TOPIC_NAME) + ACK_DEADLINE = 42 + PUSH_ENDPOINT = 'https://push.example.com/endpoint' + TOKEN1 = 'TOKEN1' + TOKEN2 = 'TOKEN2' + SIZE = 1 + SUB_INFO = [{'name': SUB_PATH, + 'topic': TOPIC_PATH, + 'ackDeadlineSeconds': ACK_DEADLINE, + 'pushConfig': {'pushEndpoint': PUSH_ENDPOINT}}] + returned = {'subscriptions': SUB_INFO, 'nextPageToken': TOKEN2} + connection = _Connection(returned) + client = self._makeOne(connection, PROJECT) + subscriptions, next_page_token = client.list_subscriptions( + SIZE, TOKEN1) + self.assertEqual(len(subscriptions), 1) + self.assertTrue(isinstance(subscriptions[0], _Subscription)) + self.assertEqual(subscriptions[0].name, SUB_NAME) + self.assertTrue(isinstance(subscriptions[0].topic, _Topic)) + self.assertTrue(subscriptions[0].topic._client is client) + self.assertEqual(subscriptions[0].topic.name, TOPIC_NAME) + self.assertEqual(subscriptions[0].ack_deadline, ACK_DEADLINE) + self.assertEqual(subscriptions[0].push_endpoint, PUSH_ENDPOINT) + self.assertEqual(next_page_token, TOKEN2) + self.assertEqual(len(connection._requested), 1) + req = connection._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/projects/%s/subscriptions' % PROJECT) + self.assertEqual(req['query_params'], + {'pageSize': SIZE, 'pageToken': TOKEN1}) + + +class Test_Topic(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud.pubsub.client import _Topic + return _Topic + + def _makeOne(self, wrapped, client): + return self._getTargetClass()(wrapped, client) + + def test_ctor_and_properties(self): + TOPIC_NAME = 'TOPIC' + TOPIC_FULL_NAME = 'projects/PROJECT/topics/%s' % TOPIC_NAME + TOPIC_PATH = '/%s' % TOPIC_FULL_NAME + client = _Dummy() + wrapped = _Dummy(name=TOPIC_NAME, + full_name=TOPIC_FULL_NAME, + path=TOPIC_PATH) + topic = self._makeOne(wrapped, client) + self.assertTrue(topic._wrapped is wrapped) + self.assertTrue(topic._client is client) + self.assertEqual(topic.name, TOPIC_NAME) + self.assertEqual(topic.full_name, TOPIC_FULL_NAME) + self.assertEqual(topic.path, TOPIC_PATH) + + def test_subscription(self): + from gcloud.pubsub.client import _Subscription + from gcloud.pubsub.client import _Topic + from gcloud.pubsub.subscription import Subscription + TOPIC_NAME = 'topic_name' + SUB_NAME = 'sub_name' + client = _Dummy() + wrapped = _Dummy(name=TOPIC_NAME) + topic = self._makeOne(wrapped, client) + subscription = topic.subscription(SUB_NAME) + self.assertTrue(isinstance(subscription, _Subscription)) + self.assertTrue(isinstance(subscription._wrapped, Subscription)) + self.assertTrue(subscription._client is client) + self.assertTrue(isinstance(subscription.topic, _Topic)) + self.assertEqual(subscription.name, SUB_NAME) + + +class _Connection(object): + + def __init__(self, *responses): + self._responses = responses + self._requested = [] + + def api_request(self, **kw): + self._requested.append(kw) + response, self._responses = self._responses[0], self._responses[1:] + return response + + +class _Dummy(object): + + def __init__(self, **kw): + self.__dict__.update(kw) diff --git a/gcloud/test__helpers.py b/gcloud/test__helpers.py index 21cfadd433ba..7e3c9daf3689 100644 --- a/gcloud/test__helpers.py +++ b/gcloud/test__helpers.py @@ -302,6 +302,95 @@ def test_descriptor_for_project(self): self.assertTrue('project' in _helpers._DEFAULTS.__dict__) +class _Dummy(object): + + def __init__(self, **kw): + self.__dict__.update(kw) + + +class Test_ClientProxy(unittest2.TestCase): + + def _getTargetClass(self): + from gcloud._helpers import _ClientProxy + return _ClientProxy + + def _makeOne(self, wrapped, client): + return self._getTargetClass()(wrapped, client) + + def test_ctor_and_attr_and_property(self): + NAME = 'name' + + class _Wrapped(object): + + def __init__(self, **kw): + self.__dict__.update(kw) + + @property + def a_property(self): + return NAME + + wrapped = _Wrapped(name=NAME) + client = object() + proxy = self._makeOne(wrapped, client) + self.assertTrue(proxy._wrapped is wrapped) + self.assertTrue(proxy._client is client) + self.assertEqual(proxy.name, NAME) + self.assertEqual(proxy.a_property, NAME) + self.assertRaises(AttributeError, getattr, proxy, 'nonesuch') + + def test_method_taking_neither_connection_nor_project(self): + + class _Wrapped(_Dummy): + + def a_method(self, *args, **kw): + return args, kw + + wrapped = _Wrapped() + client = object() + proxy = self._makeOne(wrapped, client) + self.assertEqual(proxy.a_method('foo', bar=1), (('foo',), {'bar': 1})) + + def test_method_taking_connection_not_project(self): + + class _Wrapped(_Dummy): + + def a_method(self, connection): + return connection + + wrapped = _Wrapped() + connection = object() + client = _Dummy(connection=connection) + proxy = self._makeOne(wrapped, client) + self.assertEqual(proxy.a_method(), connection) + + def test_method_taking_project_not_connection(self): + PROJECT = 'PROJECT' + + class _Wrapped(_Dummy): + + def a_method(self, project): + return project + + wrapped = _Wrapped() + client = _Dummy(project=PROJECT) + proxy = self._makeOne(wrapped, client) + self.assertEqual(proxy.a_method(), PROJECT) + + def test_method_taking_connection_and_project(self): + PROJECT = 'PROJECT' + + class _Wrapped(_Dummy): + + def a_method(self, connection, project): + return connection, project + + wrapped = _Wrapped() + connection = object() + client = _Dummy(connection=connection, project=PROJECT) + proxy = self._makeOne(wrapped, client) + self.assertEqual(proxy.a_method(), (connection, PROJECT)) + + class _AppIdentity(object): def __init__(self, app_id):