diff --git a/gcloud/bigquery/_helpers.py b/gcloud/bigquery/_helpers.py new file mode 100644 index 000000000000..b19ffd3a2742 --- /dev/null +++ b/gcloud/bigquery/_helpers.py @@ -0,0 +1,74 @@ +# 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. + +"""BigQuery utility functions.""" + + +import datetime +import sys + +import pytz + +_EPOCH = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc) + + +def _millis(when): + """Convert a zone-aware datetime to integer milliseconds. + + :type when: ``datetime.datetime`` + :param when: the datetime to convert + + :rtype: integer + :returns: milliseconds since epoch for ``when`` + """ + return int(_total_seconds(when - _EPOCH) * 1000) + + +def _datetime_from_prop(value): + """Convert non-none timestamp to datetime, assuming UTC. + + :rtype: ``datetime.datetime``, or ``NoneType`` + """ + if value is not None: + # back-end returns timestamps as milliseconds since the epoch + value = datetime.datetime.utcfromtimestamp(value / 1000.0) + return value.replace(tzinfo=pytz.utc) + + +def _prop_from_datetime(value): + """Convert non-none datetime to timestamp, assuming UTC. + + :type value: ``datetime.datetime``, or None + :param value: the timestamp + + :rtype: integer, or ``NoneType`` + :returns: the timestamp, in milliseconds, or None + """ + if value is not None: + if value.tzinfo is None: + # Assume UTC + value = value.replace(tzinfo=pytz.utc) + # back-end wants timestamps as milliseconds since the epoch + return _millis(value) + + +if sys.version_info[:2] < (2, 7): + def _total_seconds(offset): # pragma: NO COVER + """Backport of timedelta.total_seconds() from python 2.7+.""" + seconds = offset.days * 24 * 60 * 60 + offset.seconds + microseconds = seconds * 10**6 + offset.microseconds + return microseconds / (10**6 * 1.0) +else: + def _total_seconds(offset): + return offset.total_seconds() diff --git a/gcloud/bigquery/dataset.py b/gcloud/bigquery/dataset.py index e417cfcb398a..aaee1c7bd3a6 100644 --- a/gcloud/bigquery/dataset.py +++ b/gcloud/bigquery/dataset.py @@ -13,13 +13,10 @@ # limitations under the License. """Define API Datasets.""" - -import datetime - -import pytz import six from gcloud.exceptions import NotFound +from gcloud.bigquery._helpers import _datetime_from_prop class Dataset(object): @@ -356,14 +353,3 @@ def delete(self, client=None): """ client = self._require_client(client) client.connection.api_request(method='DELETE', path=self.path) - - -def _datetime_from_prop(value): - """Convert non-none timestamp to datetime, assuming UTC. - - :rtype: ``datetime.datetime``, or ``NoneType`` - """ - if value is not None: - # back-end returns timestamps as milliseconds since the epoch - value = datetime.datetime.utcfromtimestamp(value / 1000.0) - return value.replace(tzinfo=pytz.utc) diff --git a/gcloud/bigquery/table.py b/gcloud/bigquery/table.py new file mode 100644 index 000000000000..82c5417d717d --- /dev/null +++ b/gcloud/bigquery/table.py @@ -0,0 +1,239 @@ +# 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. + +"""Define API Datasets.""" + +import datetime + +import six + +from gcloud.bigquery._helpers import _datetime_from_prop +from gcloud.bigquery._helpers import _prop_from_datetime + + +class Table(object): + """Tables represent a set of rows whose values correspond to a schema. + + See: + https://cloud.google.com/bigquery/docs/reference/v2/tables + + :type name: string + :param name: the name of the table + + :type dataset: :class:`gcloud.bigquery.dataset.Dataset` + :param dataset: The dataset which contains the table. + """ + + def __init__(self, name, dataset): + self.name = name + self._dataset = dataset + self._properties = {} + + @property + def path(self): + """URL path for the table's APIs. + + :rtype: string + :returns: the path based on project and dataste name. + """ + return '%s/tables/%s' % (self._dataset.path, self.name) + + @property + def created(self): + """Datetime at which the table was created. + + :rtype: ``datetime.datetime``, or ``NoneType`` + :returns: the creation time (None until set from the server). + """ + return _datetime_from_prop(self._properties.get('creationTime')) + + @property + def etag(self): + """ETag for the table resource. + + :rtype: string, or ``NoneType`` + :returns: the ETag (None until set from the server). + """ + return self._properties.get('etag') + + @property + def modified(self): + """Datetime at which the table was last modified. + + :rtype: ``datetime.datetime``, or ``NoneType`` + :returns: the modification time (None until set from the server). + """ + return _datetime_from_prop(self._properties.get('lastModifiedTime')) + + @property + def num_bytes(self): + """The size of the table in bytes. + + :rtype: integer, or ``NoneType`` + :returns: the byte count (None until set from the server). + """ + return self._properties.get('numBytes') + + @property + def num_rows(self): + """The number of rows in the table. + + :rtype: integer, or ``NoneType`` + :returns: the row count (None until set from the server). + """ + return self._properties.get('numRows') + + @property + def self_link(self): + """URL for the table resource. + + :rtype: string, or ``NoneType`` + :returns: the URL (None until set from the server). + """ + return self._properties.get('selfLink') + + @property + def table_id(self): + """ID for the table resource. + + :rtype: string, or ``NoneType`` + :returns: the ID (None until set from the server). + """ + return self._properties.get('id') + + @property + def table_type(self): + """The type of the table. + + Possible values are "TABLE" or "VIEW". + + :rtype: string, or ``NoneType`` + :returns: the URL (None until set from the server). + """ + return self._properties.get('type') + + @property + def description(self): + """Description of the table. + + :rtype: string, or ``NoneType`` + :returns: The description as set by the user, or None (the default). + """ + return self._properties.get('description') + + @description.setter + def description(self, value): + """Update description of the table. + + :type value: string, or ``NoneType`` + :param value: new description + + :raises: ValueError for invalid value types. + """ + if not isinstance(value, six.string_types) and value is not None: + raise ValueError("Pass a string, or None") + self._properties['description'] = value + + @property + def expires(self): + """Datetime at which the table will be removed. + + :rtype: ``datetime.datetime``, or ``NoneType`` + :returns: the expiration time, or None + """ + return _datetime_from_prop(self._properties.get('expirationTime')) + + @expires.setter + def expires(self, value): + """Update atetime at which the table will be removed. + + :type value: ``datetime.datetime``, or ``NoneType`` + :param value: the new expiration time, or None + """ + if not isinstance(value, datetime.datetime) and value is not None: + raise ValueError("Pass a datetime, or None") + self._properties['expirationTime'] = _prop_from_datetime(value) + + @property + def friendly_name(self): + """Title of the table. + + :rtype: string, or ``NoneType`` + :returns: The name as set by the user, or None (the default). + """ + return self._properties.get('friendlyName') + + @friendly_name.setter + def friendly_name(self, value): + """Update title of the table. + + :type value: string, or ``NoneType`` + :param value: new title + + :raises: ValueError for invalid value types. + """ + if not isinstance(value, six.string_types) and value is not None: + raise ValueError("Pass a string, or None") + self._properties['friendlyName'] = value + + @property + def location(self): + """Location in which the table is hosted. + + :rtype: string, or ``NoneType`` + :returns: The location as set by the user, or None (the default). + """ + return self._properties.get('location') + + @location.setter + def location(self, value): + """Update location in which the table is hosted. + + :type value: string, or ``NoneType`` + :param value: new location + + :raises: ValueError for invalid value types. + """ + if not isinstance(value, six.string_types) and value is not None: + raise ValueError("Pass a string, or None") + self._properties['location'] = value + + @property + def view_query(self): + """SQL query defining the table as a view. + + :rtype: string, or ``NoneType`` + :returns: The query as set by the user, or None (the default). + """ + view = self._properties.get('view') + if view is not None: + return view.get('query') + + @view_query.setter + def view_query(self, value): + """Update SQL query defining the table as a view. + + :type value: string + :param value: new location + + :raises: ValueError for invalid value types. + """ + if not isinstance(value, six.string_types): + raise ValueError("Pass a string") + self._properties['view'] = {'query': value} + + @view_query.deleter + def view_query(self): + """Delete SQL query defining the table as a view.""" + self._properties.pop('view', None) diff --git a/gcloud/bigquery/test__helpers.py b/gcloud/bigquery/test__helpers.py new file mode 100644 index 000000000000..6dca59467c64 --- /dev/null +++ b/gcloud/bigquery/test__helpers.py @@ -0,0 +1,92 @@ +# 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 Test__millis(unittest2.TestCase): + + def _callFUT(self, value): + from gcloud.bigquery._helpers import _millis + return _millis(value) + + def test_one_second_from_epoch(self): + import datetime + import pytz + WHEN = datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=pytz.utc) + self.assertEqual(self._callFUT(WHEN), 1000) + + +class Test__datetime_from_prop(unittest2.TestCase): + + def _callFUT(self, value): + from gcloud.bigquery._helpers import _datetime_from_prop + return _datetime_from_prop(value) + + def test_w_none(self): + self.assertTrue(self._callFUT(None) is None) + + def test_w_millis(self): + import datetime + import pytz + from gcloud.bigquery._helpers import _total_seconds + NOW = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + EPOCH = datetime.datetime(1970, 1, 1, tzinfo=pytz.utc) + MILLIS = _total_seconds(NOW - EPOCH) * 1000 + self.assertEqual(self._callFUT(MILLIS), NOW) + + +class Test__prop_from_datetime(unittest2.TestCase): + + def _callFUT(self, value): + from gcloud.bigquery._helpers import _prop_from_datetime + return _prop_from_datetime(value) + + def test_w_none(self): + self.assertTrue(self._callFUT(None) is None) + + def test_w_utc_datetime(self): + import datetime + import pytz + from gcloud.bigquery._helpers import _total_seconds + NOW = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) + EPOCH = datetime.datetime(1970, 1, 1, tzinfo=pytz.utc) + MILLIS = int(_total_seconds(NOW - EPOCH) * 1000) + result = self._callFUT(NOW) + self.assertTrue(isinstance(result, int)) + self.assertEqual(result, MILLIS) + + def test_w_non_utc_datetime(self): + import datetime + import pytz + from gcloud.bigquery._helpers import _total_seconds + eastern = pytz.timezone('US/Eastern') + NOW = datetime.datetime(2015, 7, 28, 16, 34, 47, tzinfo=eastern) + EPOCH = datetime.datetime(1970, 1, 1, tzinfo=pytz.utc) + MILLIS = int(_total_seconds(NOW - EPOCH) * 1000) + result = self._callFUT(NOW) + self.assertTrue(isinstance(result, int)) + self.assertEqual(result, MILLIS) + + def test_w_naive_datetime(self): + import datetime + import pytz + from gcloud.bigquery._helpers import _total_seconds + NOW = datetime.datetime.utcnow() + UTC_NOW = NOW.replace(tzinfo=pytz.utc) + EPOCH = datetime.datetime(1970, 1, 1, tzinfo=pytz.utc) + MILLIS = int(_total_seconds(UTC_NOW - EPOCH) * 1000) + result = self._callFUT(NOW) + self.assertTrue(isinstance(result, int)) + self.assertEqual(result, MILLIS) diff --git a/gcloud/bigquery/test_table.py b/gcloud/bigquery/test_table.py new file mode 100644 index 000000000000..5a4bee8e9eb5 --- /dev/null +++ b/gcloud/bigquery/test_table.py @@ -0,0 +1,185 @@ +# 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 TestTable(unittest2.TestCase): + PROJECT = 'project' + DS_NAME = 'dataset-name' + TABLE_NAME = 'table-name' + + def _getTargetClass(self): + from gcloud.bigquery.table import Table + return Table + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + self.assertEqual(table.name, self.TABLE_NAME) + self.assertTrue(table._dataset is dataset) + self.assertEqual( + table.path, + '/projects/%s/datasets/%s/tables/%s' % ( + self.PROJECT, self.DS_NAME, self.TABLE_NAME)) + + self.assertEqual(table.created, None) + self.assertEqual(table.etag, None) + self.assertEqual(table.modified, None) + self.assertEqual(table.num_bytes, None) + self.assertEqual(table.num_rows, None) + self.assertEqual(table.self_link, None) + self.assertEqual(table.table_id, None) + self.assertEqual(table.table_type, None) + + self.assertEqual(table.description, None) + self.assertEqual(table.expires, None) + self.assertEqual(table.friendly_name, None) + self.assertEqual(table.location, None) + self.assertEqual(table.view_query, None) + + def test_props_set_by_server(self): + import datetime + import pytz + from gcloud.bigquery._helpers import _millis + CREATED = datetime.datetime(2015, 7, 29, 12, 13, 22, tzinfo=pytz.utc) + MODIFIED = datetime.datetime(2015, 7, 29, 14, 47, 15, tzinfo=pytz.utc) + TABLE_ID = '%s:%s:%s' % ( + self.PROJECT, self.DS_NAME, self.TABLE_NAME) + URL = 'http://example.com/projects/%s/datasets/%s/tables/%s' % ( + self.PROJECT, self.DS_NAME, self.TABLE_NAME) + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table._properties['creationTime'] = _millis(CREATED) + table._properties['etag'] = 'ETAG' + table._properties['lastModifiedTime'] = _millis(MODIFIED) + table._properties['numBytes'] = 12345 + table._properties['numRows'] = 66 + table._properties['selfLink'] = URL + table._properties['id'] = TABLE_ID + table._properties['type'] = 'TABLE' + + self.assertEqual(table.created, CREATED) + self.assertEqual(table.etag, 'ETAG') + self.assertEqual(table.modified, MODIFIED) + self.assertEqual(table.num_bytes, 12345) + self.assertEqual(table.num_rows, 66) + self.assertEqual(table.self_link, URL) + self.assertEqual(table.table_id, TABLE_ID) + self.assertEqual(table.table_type, 'TABLE') + + def test_description_setter_bad_value(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + with self.assertRaises(ValueError): + table.description = 12345 + + def test_description_setter(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table.description = 'DESCRIPTION' + self.assertEqual(table.description, 'DESCRIPTION') + + def test_expires_setter_bad_value(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + with self.assertRaises(ValueError): + table.expires = object() + + def test_expires_setter(self): + import datetime + import pytz + WHEN = datetime.datetime(2015, 7, 28, 16, 39, tzinfo=pytz.utc) + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table.expires = WHEN + self.assertEqual(table.expires, WHEN) + + def test_friendly_name_setter_bad_value(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + with self.assertRaises(ValueError): + table.friendly_name = 12345 + + def test_friendly_name_setter(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table.friendly_name = 'FRIENDLY' + self.assertEqual(table.friendly_name, 'FRIENDLY') + + def test_location_setter_bad_value(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + with self.assertRaises(ValueError): + table.location = 12345 + + def test_location_setter(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table.location = 'LOCATION' + self.assertEqual(table.location, 'LOCATION') + + def test_view_query_setter_bad_value(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + with self.assertRaises(ValueError): + table.view_query = 12345 + + def test_view_query_setter(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table.view_query = 'select * from foo' + self.assertEqual(table.view_query, 'select * from foo') + + def test_view_query_deleter(self): + client = _Client(self.PROJECT) + dataset = _Dataset(client) + table = self._makeOne(self.TABLE_NAME, dataset) + table.view_query = 'select * from foo' + del table.view_query + self.assertEqual(table.view_query, None) + + +class _Client(object): + + def __init__(self, project='project', connection=None): + self.project = project + self.connection = connection + + +class _Dataset(object): + + def __init__(self, client, name=TestTable.DS_NAME): + self._client = client + self._name = name + + @property + def path(self): + return '/projects/%s/datasets/%s' % ( + self._client.project, self._name)