diff --git a/docs/index.rst b/docs/index.rst index 5dd66181f949..e2edfb05c7e8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -113,6 +113,7 @@ logging-usage Client logging-logger + logging-entries .. toctree:: :maxdepth: 0 diff --git a/docs/logging-entries.rst b/docs/logging-entries.rst new file mode 100644 index 000000000000..a7b96721d30b --- /dev/null +++ b/docs/logging-entries.rst @@ -0,0 +1,8 @@ +Entries +======= + +.. automodule:: gcloud.logging.entries + :members: + :undoc-members: + :show-inheritance: + diff --git a/gcloud/_helpers.py b/gcloud/_helpers.py index dd85a54e405f..7b91c00271da 100644 --- a/gcloud/_helpers.py +++ b/gcloud/_helpers.py @@ -38,6 +38,16 @@ _NOW = datetime.datetime.utcnow # To be replaced by tests. _RFC3339_MICROS = '%Y-%m-%dT%H:%M:%S.%fZ' +_RFC3339_NO_FRACTION = '%Y-%m-%dT%H:%M:%S' +# datetime.strptime cannot handle nanosecond precision: parse w/ regex +_RFC3339_NANOS = re.compile(r""" + (?P + \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} # YYYY-MM-DDTHH:MM:SS + ) + \. # decimal point + (?P\d{9}) # nanoseconds + Z # Zulu +""", re.VERBOSE) class _LocalStack(Local): @@ -301,7 +311,7 @@ def _total_seconds(offset): def _rfc3339_to_datetime(dt_str): - """Convert a string to a native timestamp. + """Convert a microsecond-precision timetamp to a native datetime. :type dt_str: str :param dt_str: The string to convert. @@ -313,6 +323,32 @@ def _rfc3339_to_datetime(dt_str): dt_str, _RFC3339_MICROS).replace(tzinfo=UTC) +def _rfc3339_nanos_to_datetime(dt_str): + """Convert a nanosecond-precision timestamp to a native datetime. + + .. note:: + + Python datetimes do not support nanosecond precision; this function + therefore truncates such values to microseconds. + + :type dt_str: str + :param dt_str: The string to convert. + + :rtype: :class:`datetime.datetime` + :returns: The datetime object created from the string. + """ + with_nanos = _RFC3339_NANOS.match(dt_str) + if with_nanos is None: + raise ValueError( + 'Timestamp: %r, does not match pattern: %r' % ( + dt_str, _RFC3339_NANOS.pattern)) + bare_seconds = datetime.datetime.strptime( + with_nanos.group('no_fraction'), _RFC3339_NO_FRACTION) + nanos = int(with_nanos.group('nanos')) + micros = nanos // 1000 + return bare_seconds.replace(microsecond=micros, tzinfo=UTC) + + def _datetime_to_rfc3339(value): """Convert a native timestamp to a string. diff --git a/gcloud/logging/__init__.py b/gcloud/logging/__init__.py index 2a4ab1ab7806..67b0386329e9 100644 --- a/gcloud/logging/__init__.py +++ b/gcloud/logging/__init__.py @@ -19,3 +19,5 @@ SCOPE = Connection.SCOPE +ASCENDING = 'timestamp asc' +DESCENDING = 'timestamp desc' diff --git a/gcloud/logging/_helpers.py b/gcloud/logging/_helpers.py new file mode 100644 index 000000000000..aadd2aacdc52 --- /dev/null +++ b/gcloud/logging/_helpers.py @@ -0,0 +1,46 @@ +# Copyright 2016 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. + +"""Helper functions for shared behavior.""" + +import re + +from gcloud._helpers import _name_from_project_path + + +_LOGGER_TEMPLATE = re.compile(r""" + projects/ # static prefix + (?P[^/]+) # initial letter, wordchars + hyphen + /logs/ # static midfix + (?P[^/]+) # initial letter, wordchars + allowed punc +""", re.VERBOSE) + + +def logger_name_from_path(path, project): + """Validate a logger URI path and get the logger name. + + :type path: string + :param path: URI path for a logger API request. + + :type project: string + :param project: The project associated with the request. It is + included for validation purposes. + + :rtype: string + :returns: Topic 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, _LOGGER_TEMPLATE) diff --git a/gcloud/logging/client.py b/gcloud/logging/client.py index 4499fcdf9c4e..7f053eeeb775 100644 --- a/gcloud/logging/client.py +++ b/gcloud/logging/client.py @@ -17,6 +17,8 @@ from gcloud.client import JSONClient from gcloud.logging.connection import Connection +from gcloud.logging.entries import StructEntry +from gcloud.logging.entries import TextEntry from gcloud.logging.logger import Logger @@ -53,3 +55,82 @@ def logger(self, name): :returns: Logger created with the current client. """ return Logger(name, client=self) + + def _entry_from_resource(self, resource, loggers): + """Detect correct entry type from resource and instantiate. + + :type resource: dict + :param resource: one entry resource from API response + + :type loggers: dict or None + :param loggers: A mapping of logger fullnames -> loggers. If not + passed, the entry will have a newly-created logger. + + :rtype; One of: + :class:`gcloud.logging.entries.TextEntry`, + :class:`gcloud.logging.entries.StructEntry`, + :returns: the entry instance, constructed via the resource + """ + if 'textPayload' in resource: + return TextEntry.from_api_repr(resource, self, loggers) + elif 'jsonPayload' in resource: + return StructEntry.from_api_repr(resource, self, loggers) + raise ValueError('Cannot parse job resource') + + def list_entries(self, projects=None, filter_=None, order_by=None, + page_size=None, page_token=None): + """Return a page of log entries. + + See: + https://cloud.google.com/logging/docs/api/ref_v2beta1/rest/v2beta1/entries/list + + :type projects: list of strings + :param projects: project IDs to include. If not passed, + defaults to the project bound to the client. + + :type filter_: string + :param filter_: a filter expression. See: + https://cloud.google.com/logging/docs/view/advanced_filters + + :type order_by: string + :param order_by: One of :data:`gcloud.logging.ASCENDING` or + :data:`gcloud.logging.DESCENDING`. + + :type page_size: int + :param page_size: maximum number of topics 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 topics. If not + passed, the API will return the first page of + topics. + + :rtype: tuple, (list, str) + :returns: list of :class:`gcloud.logging.entry.TextEntry`, plus a + "next page token" string: if not None, indicates that + more topics can be retrieved with another call (pass that + value as ``page_token``). + """ + if projects is None: + projects = [self.project] + + params = {'projectIds': projects} + + if filter_ is not None: + params['filter'] = filter_ + + if order_by is not None: + params['orderBy'] = order_by + + if page_size is not None: + params['pageSize'] = page_size + + if page_token is not None: + params['pageToken'] = page_token + + resp = self.connection.api_request(method='POST', path='/entries:list', + data=params) + loggers = {} + entries = [self._entry_from_resource(resource, loggers) + for resource in resp.get('entries', ())] + return entries, resp.get('nextPageToken') diff --git a/gcloud/logging/entries.py b/gcloud/logging/entries.py new file mode 100644 index 000000000000..badeea2bb81b --- /dev/null +++ b/gcloud/logging/entries.py @@ -0,0 +1,93 @@ +# Copyright 2016 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. + +"""Log entries within the Google Cloud Logging API.""" + +from gcloud._helpers import _rfc3339_nanos_to_datetime +from gcloud.logging._helpers import logger_name_from_path + + +class _BaseEntry(object): + """Base class for TextEntry, StructEntry. + + :type payload: text or dict + :param payload: The payload passed as ``textPayload``, ``jsonPayload``, + or ``protoPayload``. + + :type logger: :class:`gcloud.logging.logger.Logger` + :param logger: the logger used to write the entry. + + :type insert_id: text, or :class:`NoneType` + :param insert_id: (optional) the ID used to identify an entry uniquely. + + :type timestamp: :class:`datetime.datetime`, or :class:`NoneType` + :param timestamp: (optional) timestamp for the entry + """ + def __init__(self, payload, logger, insert_id=None, timestamp=None): + self.payload = payload + self.logger = logger + self.insert_id = insert_id + self.timestamp = timestamp + + @classmethod + def from_api_repr(cls, resource, client, loggers=None): + """Factory: construct an entry given its API representation + + :type resource: dict + :param resource: text entry resource representation returned from + the API + + :type client: :class:`gcloud.logging.client.Client` + :param client: Client which holds credentials and project + configuration. + + :type loggers: dict or None + :param loggers: A mapping of logger fullnames -> loggers. If not + passed, the entry will have a newly-created logger. + + :rtype: :class:`gcloud.logging.entries.TextEntry` + :returns: Text entry parsed from ``resource``. + """ + if loggers is None: + loggers = {} + logger_fullname = resource['logName'] + logger = loggers.get(logger_fullname) + if logger is None: + logger_name = logger_name_from_path( + logger_fullname, client.project) + logger = loggers[logger_fullname] = client.logger(logger_name) + payload = resource[cls._PAYLOAD_KEY] + insert_id = resource.get('insertId') + timestamp = resource.get('timestamp') + if timestamp is not None: + timestamp = _rfc3339_nanos_to_datetime(timestamp) + return cls(payload, logger, insert_id, timestamp) + + +class TextEntry(_BaseEntry): + """Entry created via a write request with ``textPayload``. + + See: + https://cloud.google.com/logging/docs/api/ref_v2beta1/rest/v2beta1/LogEntry + """ + _PAYLOAD_KEY = 'textPayload' + + +class StructEntry(_BaseEntry): + """Entry created via a write request with ``jsonPayload``. + + See: + https://cloud.google.com/logging/docs/api/ref_v2beta1/rest/v2beta1/LogEntry + """ + _PAYLOAD_KEY = 'jsonPayload' diff --git a/gcloud/logging/test__helpers.py b/gcloud/logging/test__helpers.py new file mode 100644 index 000000000000..a70d40218186 --- /dev/null +++ b/gcloud/logging/test__helpers.py @@ -0,0 +1,36 @@ +# Copyright 2016 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 Test_logger_name_from_path(unittest2.TestCase): + + def _callFUT(self, path, project): + from gcloud.logging._helpers import logger_name_from_path + return logger_name_from_path(path, project) + + def test_w_simple_name(self): + LOGGER_NAME = 'LOGGER_NAME' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/logs/%s' % (PROJECT, LOGGER_NAME) + logger_name = self._callFUT(PATH, PROJECT) + self.assertEqual(logger_name, LOGGER_NAME) + + def test_w_name_w_all_extras(self): + LOGGER_NAME = 'LOGGER_NAME-part.one~part.two%part-three' + PROJECT = 'my-project-1234' + PATH = 'projects/%s/logs/%s' % (PROJECT, LOGGER_NAME) + logger_name = self._callFUT(PATH, PROJECT) + self.assertEqual(logger_name, LOGGER_NAME) diff --git a/gcloud/logging/test_client.py b/gcloud/logging/test_client.py index 707b3a381209..11a6935fd1b2 100644 --- a/gcloud/logging/test_client.py +++ b/gcloud/logging/test_client.py @@ -34,12 +34,123 @@ def test_ctor(self): def test_logger(self): creds = _Credentials() + client = self._makeOne(project=self.PROJECT, credentials=creds) + logger = client.logger(self.LOGGER_NAME) + self.assertEqual(logger.name, self.LOGGER_NAME) + self.assertTrue(logger.client is client) + self.assertEqual(logger.project, self.PROJECT) + + def test__entry_from_resource_unknown_type(self): + PROJECT = 'PROJECT' + creds = _Credentials() + client = self._makeOne(PROJECT, creds) + loggers = {} + with self.assertRaises(ValueError): + client._entry_from_resource({'unknownPayload': {}}, loggers) + + def test_list_entries_defaults(self): + from datetime import datetime + from gcloud._helpers import UTC + from gcloud.logging.entries import TextEntry + from gcloud.logging.test_entries import _datetime_to_rfc3339_w_nanos + NOW = datetime.utcnow().replace(tzinfo=UTC) + TIMESTAMP = _datetime_to_rfc3339_w_nanos(NOW) + IID1 = 'IID1' + TEXT = 'TEXT' + SENT = { + 'projectIds': [self.PROJECT], + } + TOKEN = 'TOKEN' + RETURNED = { + 'entries': [{ + 'textPayload': TEXT, + 'insertId': IID1, + 'resource': { + 'type': 'global', + }, + 'timestamp': TIMESTAMP, + 'logName': 'projects/%s/logs/%s' % ( + self.PROJECT, self.LOGGER_NAME), + }], + 'nextPageToken': TOKEN, + } + creds = _Credentials() + client = self._makeOne(project=self.PROJECT, credentials=creds) + conn = client.connection = _Connection(RETURNED) + entries, token = client.list_entries() + self.assertEqual(len(entries), 1) + entry = entries[0] + self.assertTrue(isinstance(entry, TextEntry)) + self.assertEqual(entry.insert_id, IID1) + self.assertEqual(entry.payload, TEXT) + self.assertEqual(entry.timestamp, NOW) + logger = entry.logger + self.assertEqual(logger.name, self.LOGGER_NAME) + self.assertTrue(logger.client is client) + self.assertEqual(logger.project, self.PROJECT) + self.assertEqual(token, TOKEN) + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'POST') + self.assertEqual(req['path'], '/entries:list') + self.assertEqual(req['data'], SENT) - client_obj = self._makeOne(project=self.PROJECT, credentials=creds) - logger = client_obj.logger(self.LOGGER_NAME) + def test_list_entries_explicit(self): + from datetime import datetime + from gcloud._helpers import UTC + from gcloud.logging import DESCENDING + from gcloud.logging.entries import StructEntry + from gcloud.logging.test_entries import _datetime_to_rfc3339_w_nanos + PROJECT1 = 'PROJECT1' + PROJECT2 = 'PROJECT2' + FILTER = 'logName:LOGNAME' + NOW = datetime.utcnow().replace(tzinfo=UTC) + TIMESTAMP = _datetime_to_rfc3339_w_nanos(NOW) + IID1 = 'IID1' + PAYLOAD = {'message': 'MESSAGE', 'weather': 'partly cloudy'} + TOKEN = 'TOKEN' + PAGE_SIZE = 42 + SENT = { + 'projectIds': [PROJECT1, PROJECT2], + 'filter': FILTER, + 'orderBy': DESCENDING, + 'pageSize': PAGE_SIZE, + 'pageToken': TOKEN, + } + RETURNED = { + 'entries': [{ + 'jsonPayload': PAYLOAD, + 'insertId': IID1, + 'resource': { + 'type': 'global', + }, + 'timestamp': TIMESTAMP, + 'logName': 'projects/%s/logs/%s' % ( + self.PROJECT, self.LOGGER_NAME), + }], + } + creds = _Credentials() + client = self._makeOne(project=self.PROJECT, credentials=creds) + conn = client.connection = _Connection(RETURNED) + entries, token = client.list_entries( + projects=[PROJECT1, PROJECT2], filter_=FILTER, order_by=DESCENDING, + page_size=PAGE_SIZE, page_token=TOKEN) + self.assertEqual(len(entries), 1) + entry = entries[0] + self.assertTrue(isinstance(entry, StructEntry)) + self.assertEqual(entry.insert_id, IID1) + self.assertEqual(entry.payload, PAYLOAD) + self.assertEqual(entry.timestamp, NOW) + logger = entry.logger self.assertEqual(logger.name, self.LOGGER_NAME) - self.assertTrue(logger.client is client_obj) + self.assertTrue(logger.client is client) self.assertEqual(logger.project, self.PROJECT) + self.assertEqual(token, None) + self.assertEqual(len(conn._requested), 1) + req = conn._requested[0] + self.assertEqual(req['method'], 'POST') + self.assertEqual(req['path'], '/entries:list') + self.assertEqual(req['data'], SENT) class _Credentials(object): @@ -53,3 +164,15 @@ def create_scoped_required(): def create_scoped(self, scope): self._scopes = scope return self + + +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 diff --git a/gcloud/logging/test_entries.py b/gcloud/logging/test_entries.py new file mode 100644 index 000000000000..40815ba6ff7c --- /dev/null +++ b/gcloud/logging/test_entries.py @@ -0,0 +1,243 @@ +# Copyright 2016 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 TestTextEntry(unittest2.TestCase): + + PROJECT = 'PROJECT' + LOGGER_NAME = 'LOGGER_NAME' + + def _getTargetClass(self): + from gcloud.logging.entries import TextEntry + return TextEntry + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_defaults(self): + PAYLOAD = 'PAYLOAD' + logger = _Logger(self.LOGGER_NAME, self.PROJECT) + entry = self._makeOne(PAYLOAD, logger) + self.assertEqual(entry.payload, PAYLOAD) + self.assertTrue(entry.logger is logger) + self.assertTrue(entry.insert_id is None) + self.assertTrue(entry.timestamp is None) + + def test_ctor_explicit(self): + import datetime + PAYLOAD = 'PAYLOAD' + IID = 'IID' + TIMESTAMP = datetime.datetime.now() + logger = _Logger(self.LOGGER_NAME, self.PROJECT) + entry = self._makeOne(PAYLOAD, logger, IID, TIMESTAMP) + self.assertEqual(entry.payload, PAYLOAD) + self.assertTrue(entry.logger is logger) + self.assertEqual(entry.insert_id, IID) + self.assertEqual(entry.timestamp, TIMESTAMP) + + def test_from_api_repr_missing_data_no_loggers(self): + client = _Client(self.PROJECT) + PAYLOAD = 'PAYLOAD' + LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME) + API_REPR = { + 'textPayload': PAYLOAD, + 'logName': LOG_NAME, + } + klass = self._getTargetClass() + entry = klass.from_api_repr(API_REPR, client) + self.assertEqual(entry.payload, PAYLOAD) + self.assertTrue(entry.insert_id is None) + self.assertTrue(entry.timestamp is None) + logger = entry.logger + self.assertTrue(isinstance(logger, _Logger)) + self.assertTrue(logger.client is client) + self.assertEqual(logger.name, self.LOGGER_NAME) + + def test_from_api_repr_w_loggers_no_logger_match(self): + from datetime import datetime + from gcloud._helpers import UTC + client = _Client(self.PROJECT) + PAYLOAD = 'PAYLOAD' + IID = 'IID' + NOW = datetime.utcnow().replace(tzinfo=UTC) + TIMESTAMP = _datetime_to_rfc3339_w_nanos(NOW) + LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME) + API_REPR = { + 'textPayload': PAYLOAD, + 'logName': LOG_NAME, + 'insertId': IID, + 'timestamp': TIMESTAMP, + } + loggers = {} + klass = self._getTargetClass() + entry = klass.from_api_repr(API_REPR, client, loggers=loggers) + self.assertEqual(entry.payload, PAYLOAD) + self.assertEqual(entry.insert_id, IID) + self.assertEqual(entry.timestamp, NOW) + logger = entry.logger + self.assertTrue(isinstance(logger, _Logger)) + self.assertTrue(logger.client is client) + self.assertEqual(logger.name, self.LOGGER_NAME) + self.assertEqual(loggers, {LOG_NAME: logger}) + + def test_from_api_repr_w_loggers_w_logger_match(self): + from datetime import datetime + from gcloud._helpers import UTC + client = _Client(self.PROJECT) + PAYLOAD = 'PAYLOAD' + IID = 'IID' + NOW = datetime.utcnow().replace(tzinfo=UTC) + TIMESTAMP = _datetime_to_rfc3339_w_nanos(NOW) + LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME) + API_REPR = { + 'textPayload': PAYLOAD, + 'logName': LOG_NAME, + 'insertId': IID, + 'timestamp': TIMESTAMP, + } + LOGGER = object() + loggers = {LOG_NAME: LOGGER} + klass = self._getTargetClass() + entry = klass.from_api_repr(API_REPR, client, loggers=loggers) + self.assertEqual(entry.payload, PAYLOAD) + self.assertEqual(entry.insert_id, IID) + self.assertEqual(entry.timestamp, NOW) + self.assertTrue(entry.logger is LOGGER) + + +class TestStructEntry(unittest2.TestCase): + + PROJECT = 'PROJECT' + LOGGER_NAME = 'LOGGER_NAME' + + def _getTargetClass(self): + from gcloud.logging.entries import StructEntry + return StructEntry + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_defaults(self): + PAYLOAD = {'message': 'MESSAGE', 'weather': 'partly cloudy'} + logger = _Logger(self.LOGGER_NAME, self.PROJECT) + entry = self._makeOne(PAYLOAD, logger) + self.assertEqual(entry.payload, PAYLOAD) + self.assertTrue(entry.logger is logger) + self.assertTrue(entry.insert_id is None) + self.assertTrue(entry.timestamp is None) + + def test_ctor_explicit(self): + import datetime + PAYLOAD = {'message': 'MESSAGE', 'weather': 'partly cloudy'} + IID = 'IID' + TIMESTAMP = datetime.datetime.now() + logger = _Logger(self.LOGGER_NAME, self.PROJECT) + entry = self._makeOne(PAYLOAD, logger, IID, TIMESTAMP) + self.assertEqual(entry.payload, PAYLOAD) + self.assertTrue(entry.logger is logger) + self.assertEqual(entry.insert_id, IID) + self.assertEqual(entry.timestamp, TIMESTAMP) + + def test_from_api_repr_missing_data_no_loggers(self): + client = _Client(self.PROJECT) + PAYLOAD = {'message': 'MESSAGE', 'weather': 'partly cloudy'} + LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME) + API_REPR = { + 'jsonPayload': PAYLOAD, + 'logName': LOG_NAME, + } + klass = self._getTargetClass() + entry = klass.from_api_repr(API_REPR, client) + self.assertEqual(entry.payload, PAYLOAD) + self.assertTrue(entry.insert_id is None) + self.assertTrue(entry.timestamp is None) + logger = entry.logger + self.assertTrue(isinstance(logger, _Logger)) + self.assertTrue(logger.client is client) + self.assertEqual(logger.name, self.LOGGER_NAME) + + def test_from_api_repr_w_loggers_no_logger_match(self): + from datetime import datetime + from gcloud._helpers import UTC + client = _Client(self.PROJECT) + PAYLOAD = {'message': 'MESSAGE', 'weather': 'partly cloudy'} + IID = 'IID' + NOW = datetime.utcnow().replace(tzinfo=UTC) + TIMESTAMP = _datetime_to_rfc3339_w_nanos(NOW) + LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME) + API_REPR = { + 'jsonPayload': PAYLOAD, + 'logName': LOG_NAME, + 'insertId': IID, + 'timestamp': TIMESTAMP, + } + loggers = {} + klass = self._getTargetClass() + entry = klass.from_api_repr(API_REPR, client, loggers=loggers) + self.assertEqual(entry.payload, PAYLOAD) + self.assertEqual(entry.insert_id, IID) + self.assertEqual(entry.timestamp, NOW) + logger = entry.logger + self.assertTrue(isinstance(logger, _Logger)) + self.assertTrue(logger.client is client) + self.assertEqual(logger.name, self.LOGGER_NAME) + self.assertEqual(loggers, {LOG_NAME: logger}) + + def test_from_api_repr_w_loggers_w_logger_match(self): + from datetime import datetime + from gcloud._helpers import UTC + client = _Client(self.PROJECT) + PAYLOAD = {'message': 'MESSAGE', 'weather': 'partly cloudy'} + IID = 'IID' + NOW = datetime.utcnow().replace(tzinfo=UTC) + TIMESTAMP = _datetime_to_rfc3339_w_nanos(NOW) + LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME) + API_REPR = { + 'jsonPayload': PAYLOAD, + 'logName': LOG_NAME, + 'insertId': IID, + 'timestamp': TIMESTAMP, + } + LOGGER = object() + loggers = {LOG_NAME: LOGGER} + klass = self._getTargetClass() + entry = klass.from_api_repr(API_REPR, client, loggers=loggers) + self.assertEqual(entry.payload, PAYLOAD) + self.assertEqual(entry.insert_id, IID) + self.assertEqual(entry.timestamp, NOW) + self.assertTrue(entry.logger is LOGGER) + + +def _datetime_to_rfc3339_w_nanos(value): + from gcloud._helpers import _RFC3339_NO_FRACTION + no_fraction = value.strftime(_RFC3339_NO_FRACTION) + return '%s.%09dZ' % (no_fraction, value.microsecond * 1000) + + +class _Logger(object): + + def __init__(self, name, client): + self.name = name + self.client = client + + +class _Client(object): + + def __init__(self, project): + self.project = project + + def logger(self, name): + return _Logger(name, self) diff --git a/gcloud/test__helpers.py b/gcloud/test__helpers.py index 4f2cb849c89d..00aa5075c731 100644 --- a/gcloud/test__helpers.py +++ b/gcloud/test__helpers.py @@ -411,7 +411,21 @@ def _callFUT(self, dt_str): from gcloud._helpers import _rfc3339_to_datetime return _rfc3339_to_datetime(dt_str) - def test_it(self): + def test_w_bogus_zone(self): + year = 2009 + month = 12 + day = 17 + hour = 12 + minute = 44 + seconds = 32 + micros = 123456789 + + dt_str = '%d-%02d-%02dT%02d:%02d:%02d.%06dBOGUS' % ( + year, month, day, hour, minute, seconds, micros) + with self.assertRaises(ValueError): + self._callFUT(dt_str) + + def test_w_microseconds(self): import datetime from gcloud._helpers import UTC @@ -430,6 +444,76 @@ def test_it(self): year, month, day, hour, minute, seconds, micros, UTC) self.assertEqual(result, expected_result) + def test_w_naonseconds(self): + year = 2009 + month = 12 + day = 17 + hour = 12 + minute = 44 + seconds = 32 + nanos = 123456789 + + dt_str = '%d-%02d-%02dT%02d:%02d:%02d.%09dZ' % ( + year, month, day, hour, minute, seconds, nanos) + with self.assertRaises(ValueError): + self._callFUT(dt_str) + + +class Test__rfc3339_nanos_to_datetime(unittest2.TestCase): + + def _callFUT(self, dt_str): + from gcloud._helpers import _rfc3339_nanos_to_datetime + return _rfc3339_nanos_to_datetime(dt_str) + + def test_w_bogus_zone(self): + year = 2009 + month = 12 + day = 17 + hour = 12 + minute = 44 + seconds = 32 + micros = 123456789 + + dt_str = '%d-%02d-%02dT%02d:%02d:%02d.%06dBOGUS' % ( + year, month, day, hour, minute, seconds, micros) + with self.assertRaises(ValueError): + self._callFUT(dt_str) + + def test_w_microseconds(self): + + year = 2009 + month = 12 + day = 17 + hour = 12 + minute = 44 + seconds = 32 + micros = 123456 + + dt_str = '%d-%02d-%02dT%02d:%02d:%02d.%06dZ' % ( + year, month, day, hour, minute, seconds, micros) + with self.assertRaises(ValueError): + self._callFUT(dt_str) + + def test_w_naonseconds(self): + import datetime + from gcloud._helpers import UTC + + year = 2009 + month = 12 + day = 17 + hour = 12 + minute = 44 + seconds = 32 + nanos = 123456789 + micros = nanos // 1000 + + dt_str = '%d-%02d-%02dT%02d:%02d:%02d.%06dZ' % ( + year, month, day, hour, minute, seconds, nanos) + result = self._callFUT(dt_str) + expected_result = datetime.datetime( + year, month, day, hour, minute, seconds, micros, UTC) + self.assertEqual(result, expected_result) + class Test__datetime_to_rfc3339(unittest2.TestCase):