From 04be54675d3bff91db9993ff2df496aea7c52205 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Tue, 8 Mar 2016 13:06:08 -0500 Subject: [PATCH 1/7] Add 'Metric.from_api_repr'. --- gcloud/logging/metric.py | 53 ++++++++++++++++++++++++ gcloud/logging/test_metric.py | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/gcloud/logging/metric.py b/gcloud/logging/metric.py index 3f5c95c3809d..983756f05700 100644 --- a/gcloud/logging/metric.py +++ b/gcloud/logging/metric.py @@ -14,9 +14,39 @@ """Define Logging API Metrics.""" +import re + +from gcloud._helpers import _name_from_project_path from gcloud.exceptions import NotFound +_METRIC_TEMPLATE = re.compile(r""" + projects/ # static prefix + (?P[^/]+) # initial letter, wordchars + hyphen + /metrics/ # static midfix + (?P[^/]+) # initial letter, wordchars + allowed punc +""", re.VERBOSE) + + +def _metric_name_from_path(path, project): + """Validate a metric URI path and get the metric name. + + :type path: string + :param path: URI path for a metric API request. + + :type project: string + :param project: The project associated with the request. It is + included for validation purposes. + + :rtype: string + :returns: Metric name parsed from ``path``. + :raises: :class:`ValueError` if the ``path`` is ill-formed or if + the project from the ``path`` does not agree with the + ``project`` passed in. + """ + return _name_from_project_path(path, project, _METRIC_TEMPLATE) + + class Metric(object): """Metrics represent named filters for log entries. @@ -63,6 +93,29 @@ def path(self): """URL path for the metric's APIs""" return '/%s' % (self.full_name,) + @classmethod + def from_api_repr(cls, resource, client): + """Factory: construct a metric given its API representation + + :type resource: dict + :param resource: metric resource representation returned from the API + + :type client: :class:`gcloud.pubsub.client.Client` + :param client: Client which holds credentials and project + configuration for the metric. + + :rtype: :class:`gcloud.logging.metric.Metric` + :returns: Metric parsed from ``resource``. + :raises: :class:`ValueError` if ``client`` is not ``None`` and the + project from the resource does not agree with the project + from the client. + """ + metric_name = _metric_name_from_path(resource['name'], client.project) + filter_ = resource['filter'] + description = resource.get('description', '') + return cls(metric_name, filter_, client=client, + description=description) + def _require_client(self, client): """Check client or verify over-ride. diff --git a/gcloud/logging/test_metric.py b/gcloud/logging/test_metric.py index 43f5b1efb2ec..6c28fad3443c 100644 --- a/gcloud/logging/test_metric.py +++ b/gcloud/logging/test_metric.py @@ -15,6 +15,38 @@ import unittest2 +class Test__metric_name_from_path(unittest2.TestCase): + + def _callFUT(self, path, project): + from gcloud.logging.metric import _metric_name_from_path + return _metric_name_from_path(path, project) + + def test_invalid_path_length(self): + PATH = 'projects/foo' + PROJECT = None + self.assertRaises(ValueError, self._callFUT, PATH, PROJECT) + + def test_invalid_path_format(self): + METRIC_NAME = 'METRIC_NAME' + PROJECT = 'PROJECT' + PATH = 'foo/%s/bar/%s' % (PROJECT, METRIC_NAME) + self.assertRaises(ValueError, self._callFUT, PATH, PROJECT) + + def test_invalid_project(self): + METRIC_NAME = 'METRIC_NAME' + PROJECT1 = 'PROJECT1' + PROJECT2 = 'PROJECT2' + PATH = 'projects/%s/metrics/%s' % (PROJECT1, METRIC_NAME) + self.assertRaises(ValueError, self._callFUT, PATH, PROJECT2) + + def test_valid_data(self): + METRIC_NAME = 'METRIC_NAME' + PROJECT = 'PROJECT' + PATH = 'projects/%s/metrics/%s' % (PROJECT, METRIC_NAME) + metric_name = self._callFUT(PATH, PROJECT) + self.assertEqual(metric_name, METRIC_NAME) + + class TestMetric(unittest2.TestCase): PROJECT = 'test-project' @@ -56,6 +88,50 @@ def test_ctor_explicit(self): self.assertEqual(metric.full_name, FULL) self.assertEqual(metric.path, '/%s' % (FULL,)) + def test_from_api_repr_minimal(self): + CLIENT = _Client(project=self.PROJECT) + FULL = 'projects/%s/metrics/%s' % (self.PROJECT, self.METRIC_NAME) + RESOURCE = { + 'name': FULL, + 'filter': self.FILTER, + } + klass = self._getTargetClass() + metric = klass.from_api_repr(RESOURCE, client=CLIENT) + self.assertEqual(metric.name, self.METRIC_NAME) + self.assertEqual(metric.filter_, self.FILTER) + self.assertEqual(metric.description, '') + self.assertTrue(metric._client is CLIENT) + self.assertEqual(metric.project, self.PROJECT) + self.assertEqual(metric.full_name, FULL) + + def test_from_api_repr_w_description(self): + CLIENT = _Client(project=self.PROJECT) + FULL = 'projects/%s/metrics/%s' % (self.PROJECT, self.METRIC_NAME) + DESCRIPTION = 'DESCRIPTION' + RESOURCE = { + 'name': FULL, + 'filter': self.FILTER, + 'description': DESCRIPTION, + } + klass = self._getTargetClass() + metric = klass.from_api_repr(RESOURCE, client=CLIENT) + self.assertEqual(metric.name, self.METRIC_NAME) + self.assertEqual(metric.filter_, self.FILTER) + self.assertEqual(metric.description, DESCRIPTION) + self.assertTrue(metric._client is CLIENT) + self.assertEqual(metric.project, self.PROJECT) + self.assertEqual(metric.full_name, FULL) + + def test_from_api_repr_with_mismatched_project(self): + PROJECT1 = 'PROJECT1' + PROJECT2 = 'PROJECT2' + CLIENT = _Client(project=PROJECT1) + FULL = 'projects/%s/metrics/%s' % (PROJECT2, self.METRIC_NAME) + RESOURCE = {'name': FULL, 'filter': self.FILTER} + klass = self._getTargetClass() + self.assertRaises(ValueError, klass.from_api_repr, + RESOURCE, client=CLIENT) + def test_create_w_bound_client(self): FULL = 'projects/%s/metrics/%s' % (self.PROJECT, self.METRIC_NAME) RESOURCE = { From 42988068e14620397d888800d1043d8cf6725adf Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Tue, 8 Mar 2016 13:16:02 -0500 Subject: [PATCH 2/7] Add 'Client.list_metrics' API wrapper. --- gcloud/logging/client.py | 40 +++++++++++++- gcloud/logging/test_client.py | 99 +++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index 1713d8d47538..982252e9d053 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -20,6 +20,7 @@ from gcloud.logging.entries import StructEntry from gcloud.logging.entries import TextEntry from gcloud.logging.logger import Logger +from gcloud.logging.metric import Metric from gcloud.logging.sink import Sink @@ -79,7 +80,8 @@ def _entry_from_resource(self, resource, loggers): raise ValueError('Cannot parse job resource') def list_entries(self, projects=None, filter_=None, order_by=None, - page_size=None, page_token=None): + page_size=None, + page_token=None): """Return a page of log entries. See: @@ -112,6 +114,7 @@ def list_entries(self, projects=None, filter_=None, order_by=None, more topics can be retrieved with another call (pass that value as ``page_token``). """ + # pylint: disable=too-many-branches if projects is None: projects = [self.project] @@ -190,3 +193,38 @@ def list_sinks(self, page_size=None, page_token=None): sinks = [Sink.from_api_repr(resource, self) for resource in resp.get('sinks', ())] return sinks, resp.get('nextPageToken') + + def list_metrics(self, page_size=None, page_token=None): + """List metrics for the project associated with this client. + + See: + https://cloud.google.com/logging/docs/api/ref_v2beta1/rest/v2beta1/projects.metrics/list + + :type page_size: int + :param page_size: maximum number of metrics to return, If not passed, + defaults to a value set by the API. + + :type page_token: string + :param page_token: opaque marker for the next "page" of metrics. If not + passed, the API will return the first page of + metrics. + + :rtype: tuple, (list, str) + :returns: list of :class:`gcloud.logging.metric.Metric`, plus a + "next page token" string: if not None, indicates that + more metrics can be retrieved with another call (pass that + """ + params = {} + + if page_size is not None: + params['pageSize'] = page_size + + if page_token is not None: + params['pageToken'] = page_token + + path = '/projects/%s/metrics' % (self.project,) + resp = self.connection.api_request(method='GET', path=path, + query_params=params) + metrics = [Metric.from_api_repr(resource, self) + for resource in resp.get('metrics', ())] + return metrics, resp.get('nextPageToken') diff --git a/gcloud/logging/test_client.py b/gcloud/logging/test_client.py index 94a9997e978e..e9e6d0f105e0 100644 --- a/gcloud/logging/test_client.py +++ b/gcloud/logging/test_client.py @@ -270,6 +270,105 @@ def test_list_sinks_missing_key(self): self.assertEqual(req['path'], '/projects/%s/sinks' % PROJECT) self.assertEqual(req['query_params'], {}) + def test_list_metrics_no_paging(self): + from gcloud.logging.metric import Metric + PROJECT = 'PROJECT' + CREDS = _Credentials() + + CLIENT_OBJ = self._makeOne(project=PROJECT, credentials=CREDS) + + METRIC_NAME = 'metric_name' + FILTER = 'logName:syslog AND severity>=ERROR' + DESCRIPTION = 'DESCRIPTION' + METRIC_PATH = 'projects/%s/metrics/%s' % (PROJECT, METRIC_NAME) + + RETURNED = { + 'metrics': [{ + 'name': METRIC_PATH, + 'filter': FILTER, + 'description': DESCRIPTION, + }], + } + # Replace the connection on the client with one of our own. + CLIENT_OBJ.connection = _Connection(RETURNED) + + # Execute request. + metrics, next_page_token = CLIENT_OBJ.list_metrics() + # Test values are correct. + self.assertEqual(len(metrics), 1) + metric = metrics[0] + self.assertTrue(isinstance(metric, Metric)) + self.assertEqual(metric.name, METRIC_NAME) + self.assertEqual(metric.filter_, FILTER) + self.assertEqual(metric.description, DESCRIPTION) + self.assertEqual(next_page_token, None) + self.assertEqual(len(CLIENT_OBJ.connection._requested), 1) + req = CLIENT_OBJ.connection._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/projects/%s/metrics' % PROJECT) + self.assertEqual(req['query_params'], {}) + + def test_list_metrics_with_paging(self): + from gcloud.logging.metric import Metric + PROJECT = 'PROJECT' + CREDS = _Credentials() + + CLIENT_OBJ = self._makeOne(project=PROJECT, credentials=CREDS) + + METRIC_NAME = 'metric_name' + FILTER = 'logName:syslog AND severity>=ERROR' + DESCRIPTION = 'DESCRIPTION' + METRIC_PATH = 'projects/%s/metrics/%s' % (PROJECT, METRIC_NAME) + TOKEN1 = 'TOKEN1' + TOKEN2 = 'TOKEN2' + SIZE = 1 + RETURNED = { + 'metrics': [{ + 'name': METRIC_PATH, + 'filter': FILTER, + 'description': DESCRIPTION, + }], + 'nextPageToken': TOKEN2, + } + # Replace the connection on the client with one of our own. + CLIENT_OBJ.connection = _Connection(RETURNED) + + # Execute request. + metrics, next_page_token = CLIENT_OBJ.list_metrics(SIZE, TOKEN1) + # Test values are correct. + self.assertEqual(len(metrics), 1) + metric = metrics[0] + self.assertTrue(isinstance(metric, Metric)) + self.assertEqual(metric.name, METRIC_NAME) + self.assertEqual(metric.filter_, FILTER) + self.assertEqual(metric.description, DESCRIPTION) + self.assertEqual(next_page_token, TOKEN2) + req = CLIENT_OBJ.connection._requested[0] + self.assertEqual(req['path'], '/projects/%s/metrics' % PROJECT) + self.assertEqual(req['query_params'], + {'pageSize': SIZE, 'pageToken': TOKEN1}) + + def test_list_metrics_missing_key(self): + PROJECT = 'PROJECT' + CREDS = _Credentials() + + CLIENT_OBJ = self._makeOne(project=PROJECT, credentials=CREDS) + + RETURNED = {} + # Replace the connection on the client with one of our own. + CLIENT_OBJ.connection = _Connection(RETURNED) + + # Execute request. + metrics, next_page_token = CLIENT_OBJ.list_metrics() + # Test values are correct. + self.assertEqual(len(metrics), 0) + self.assertEqual(next_page_token, None) + self.assertEqual(len(CLIENT_OBJ.connection._requested), 1) + req = CLIENT_OBJ.connection._requested[0] + self.assertEqual(req['method'], 'GET') + self.assertEqual(req['path'], '/projects/%s/metrics' % PROJECT) + self.assertEqual(req['query_params'], {}) + class _Credentials(object): From 5d342faef2ec1762ddfd42e1be9dccdfdbe43c81 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Tue, 8 Mar 2016 13:28:10 -0500 Subject: [PATCH 3/7] Add 'Client.metric' factory. --- gcloud/logging/client.py | 18 ++++++++++++++ gcloud/logging/test_client.py | 47 +++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index 982252e9d053..5f7cd9046db3 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -194,6 +194,24 @@ def list_sinks(self, page_size=None, page_token=None): for resource in resp.get('sinks', ())] return sinks, resp.get('nextPageToken') + def metric(self, name, filter_, description=''): + """Creates a metric bound to the current client. + + :type name: string + :param name: the name of the metric to be constructed. + + :type filter_: string + :param filter_: the advanced logs filter expression defining the + entries tracked by the metric. + + :type description: string + :param description: the description of the metric to be constructed. + + :rtype: :class:`gcloud.pubsub.logger.Logger` + :returns: Logger created with the current client. + """ + return Metric(name, filter_, client=self, description=description) + def list_metrics(self, page_size=None, page_token=None): """List metrics for the project associated with this client. diff --git a/gcloud/logging/test_client.py b/gcloud/logging/test_client.py index e9e6d0f105e0..59bc10a177f9 100644 --- a/gcloud/logging/test_client.py +++ b/gcloud/logging/test_client.py @@ -22,6 +22,9 @@ class TestClient(unittest2.TestCase): SINK_NAME = 'SINK_NAME' FILTER = 'logName:syslog AND severity>=ERROR' DESTINATION_URI = 'faux.googleapis.com/destination' + METRIC_NAME = 'metric_name' + FILTER = 'logName:syslog AND severity>=ERROR' + DESCRIPTION = 'DESCRIPTION' def _getTargetClass(self): from gcloud.logging.client import Client @@ -270,6 +273,20 @@ def test_list_sinks_missing_key(self): self.assertEqual(req['path'], '/projects/%s/sinks' % PROJECT) self.assertEqual(req['query_params'], {}) + def test_metric(self): + from gcloud.logging.metric import Metric + creds = _Credentials() + + client_obj = self._makeOne(project=self.PROJECT, credentials=creds) + metric = client_obj.metric(self.METRIC_NAME, self.FILTER, + description=self.DESCRIPTION) + self.assertTrue(isinstance(metric, Metric)) + self.assertEqual(metric.name, self.METRIC_NAME) + self.assertEqual(metric.filter_, self.FILTER) + self.assertEqual(metric.description, self.DESCRIPTION) + self.assertTrue(metric.client is client_obj) + self.assertEqual(metric.project, self.PROJECT) + def test_list_metrics_no_paging(self): from gcloud.logging.metric import Metric PROJECT = 'PROJECT' @@ -277,16 +294,13 @@ def test_list_metrics_no_paging(self): CLIENT_OBJ = self._makeOne(project=PROJECT, credentials=CREDS) - METRIC_NAME = 'metric_name' - FILTER = 'logName:syslog AND severity>=ERROR' - DESCRIPTION = 'DESCRIPTION' - METRIC_PATH = 'projects/%s/metrics/%s' % (PROJECT, METRIC_NAME) + METRIC_PATH = 'projects/%s/metrics/%s' % (PROJECT, self.METRIC_NAME) RETURNED = { 'metrics': [{ 'name': METRIC_PATH, - 'filter': FILTER, - 'description': DESCRIPTION, + 'filter': self.FILTER, + 'description': self.DESCRIPTION, }], } # Replace the connection on the client with one of our own. @@ -298,9 +312,9 @@ def test_list_metrics_no_paging(self): self.assertEqual(len(metrics), 1) metric = metrics[0] self.assertTrue(isinstance(metric, Metric)) - self.assertEqual(metric.name, METRIC_NAME) - self.assertEqual(metric.filter_, FILTER) - self.assertEqual(metric.description, DESCRIPTION) + self.assertEqual(metric.name, self.METRIC_NAME) + self.assertEqual(metric.filter_, self.FILTER) + self.assertEqual(metric.description, self.DESCRIPTION) self.assertEqual(next_page_token, None) self.assertEqual(len(CLIENT_OBJ.connection._requested), 1) req = CLIENT_OBJ.connection._requested[0] @@ -315,18 +329,15 @@ def test_list_metrics_with_paging(self): CLIENT_OBJ = self._makeOne(project=PROJECT, credentials=CREDS) - METRIC_NAME = 'metric_name' - FILTER = 'logName:syslog AND severity>=ERROR' - DESCRIPTION = 'DESCRIPTION' - METRIC_PATH = 'projects/%s/metrics/%s' % (PROJECT, METRIC_NAME) + METRIC_PATH = 'projects/%s/metrics/%s' % (PROJECT, self.METRIC_NAME) TOKEN1 = 'TOKEN1' TOKEN2 = 'TOKEN2' SIZE = 1 RETURNED = { 'metrics': [{ 'name': METRIC_PATH, - 'filter': FILTER, - 'description': DESCRIPTION, + 'filter': self.FILTER, + 'description': self.DESCRIPTION, }], 'nextPageToken': TOKEN2, } @@ -339,9 +350,9 @@ def test_list_metrics_with_paging(self): self.assertEqual(len(metrics), 1) metric = metrics[0] self.assertTrue(isinstance(metric, Metric)) - self.assertEqual(metric.name, METRIC_NAME) - self.assertEqual(metric.filter_, FILTER) - self.assertEqual(metric.description, DESCRIPTION) + self.assertEqual(metric.name, self.METRIC_NAME) + self.assertEqual(metric.filter_, self.FILTER) + self.assertEqual(metric.description, self.DESCRIPTION) self.assertEqual(next_page_token, TOKEN2) req = CLIENT_OBJ.connection._requested[0] self.assertEqual(req['path'], '/projects/%s/metrics' % PROJECT) From affbc5d3be0107de85241ce46ec46a19b8f33cec Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Mon, 14 Mar 2016 13:51:51 -0400 Subject: [PATCH 4/7] Undo extra linewrap, maybe added during conflict resolution. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1590#discussion_r56043988 --- gcloud/logging/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index 5f7cd9046db3..7a3917eb5720 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -80,8 +80,7 @@ def _entry_from_resource(self, resource, loggers): raise ValueError('Cannot parse job resource') def list_entries(self, projects=None, filter_=None, order_by=None, - page_size=None, - page_token=None): + page_size=None, page_token=None): """Return a page of log entries. See: From 3101fb00f931027ae4d3e564112a1d666b8e1e76 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Mon, 14 Mar 2016 14:11:21 -0400 Subject: [PATCH 5/7] Drop no-longer-needed pylint disable. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1590#discussion_r56044689 --- gcloud/logging/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index 7a3917eb5720..b59908e334bc 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -113,7 +113,6 @@ def list_entries(self, projects=None, filter_=None, order_by=None, more topics can be retrieved with another call (pass that value as ``page_token``). """ - # pylint: disable=too-many-branches if projects is None: projects = [self.project] From 02eecd1edf2a198a87b6f59709c22f6a024251bd Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Mon, 14 Mar 2016 14:12:24 -0400 Subject: [PATCH 6/7] Fix docstring copy-pasta. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1590#discussion_r56045255. --- gcloud/logging/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index b59908e334bc..8978c79b3a64 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -205,8 +205,8 @@ def metric(self, name, filter_, description=''): :type description: string :param description: the description of the metric to be constructed. - :rtype: :class:`gcloud.pubsub.logger.Logger` - :returns: Logger created with the current client. + :rtype: :class:`gcloud.pubsub.metric.Metric` + :returns: Metric created with the current client. """ return Metric(name, filter_, client=self, description=description) From f1ca149df7569d12df0c4dcaea48a24d8cbad01d Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Mon, 14 Mar 2016 14:14:25 -0400 Subject: [PATCH 7/7] Re-add docstring line lost in conflict resolution. Addresses: https://github.com/GoogleCloudPlatform/gcloud-python/pull/1590#discussion_r56045365 --- gcloud/logging/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index 8978c79b3a64..0344dde22555 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -229,6 +229,7 @@ def list_metrics(self, page_size=None, page_token=None): :returns: list of :class:`gcloud.logging.metric.Metric`, plus a "next page token" string: if not None, indicates that more metrics can be retrieved with another call (pass that + value as ``page_token``). """ params = {}