diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000..25168dc876 --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +[flake8] +exclude = + __pycache__, + .git, + *.pyc, + conf.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..d645695673 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/MANIFEST.in b/MANIFEST.in index cb3a2b9ef4..9f7100c952 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include README.rst -graft google -graft unit_tests -global-exclude *.pyc +include README.rst LICENSE +recursive-include google *.json *.proto +recursive-include unit_tests * +global-exclude *.pyc __pycache__ diff --git a/google/cloud/spanner/__init__.py b/google/cloud/spanner/__init__.py index 25e8ec04c2..31913d8b12 100644 --- a/google/cloud/spanner/__init__.py +++ b/google/cloud/spanner/__init__.py @@ -27,3 +27,7 @@ from google.cloud.spanner.pool import AbstractSessionPool from google.cloud.spanner.pool import BurstyPool from google.cloud.spanner.pool import FixedSizePool + + +__all__ = ['__version__', 'AbstractSessionPool', 'BurstyPool', 'Client', + 'FixedSizePool', 'KeyRange', 'KeySet'] diff --git a/nox.py b/nox.py new file mode 100644 index 0000000000..0ef56fb280 --- /dev/null +++ b/nox.py @@ -0,0 +1,87 @@ +# Copyright 2016 Google Inc. +# +# 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. + +from __future__ import absolute_import + +import os + +import nox + + +@nox.session +@nox.parametrize('python_version', ['2.7', '3.4', '3.5', '3.6']) +def unit_tests(session, python_version): + """Run the unit test suite.""" + + # Run unit tests against all supported versions of Python. + session.interpreter = 'python%s' % python_version + + # Install all test dependencies, then install this package in-place. + session.install('mock', 'pytest', 'pytest-cov', '../core/') + session.install('-e', '.') + + # Run py.test against the unit tests. + session.run('py.test', '--quiet', + '--cov=google.cloud.spanner', '--cov=tests.unit', '--cov-append', + '--cov-config=.coveragerc', '--cov-report=', '--cov-fail-under=97', + 'tests/unit', + ) + + +@nox.session +@nox.parametrize('python_version', ['2.7', '3.6']) +def system_tests(session, python_version): + """Run the system test suite.""" + + # Sanity check: Only run system tests if the environment variable is set. + if not os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', ''): + return + + # Run the system tests against latest Python 2 and Python 3 only. + session.interpreter = 'python%s' % python_version + + # Install all test dependencies, then install this package into the + # virutalenv's dist-packages. + session.install('mock', 'pytest', + '../core/', '../test_utils/') + session.install('.') + + # Run py.test against the system tests. + session.run('py.test', '--quiet', 'tests/system.py') + + +@nox.session +def lint(session): + """Run flake8. + + Returns a failure if flake8 finds linting errors or sufficiently + serious code quality issues. + """ + session.interpreter = 'python3.6' + session.install('flake8') + session.install('.') + session.run('flake8', 'google/cloud/spanner') + + +@nox.session +def cover(session): + """Run the final coverage report. + + This outputs the coverage report aggregating coverage from the unit + test runs (not system test runs), and then erases coverage data. + """ + session.interpreter = 'python3.6' + session.install('coverage', 'pytest-cov') + session.run('coverage', 'report', '--show-missing', '--fail-under=100') + session.run('coverage', 'erase') diff --git a/setup.py b/setup.py index bf0a989556..cf59f658dd 100644 --- a/setup.py +++ b/setup.py @@ -66,7 +66,7 @@ 'google', 'google.cloud', ], - packages=find_packages(), + packages=find_packages(exclude=('unit_tests*',)), install_requires=REQUIREMENTS, **SETUP_BASE ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/system.py b/tests/system.py new file mode 100644 index 0000000000..cddfa937e9 --- /dev/null +++ b/tests/system.py @@ -0,0 +1,445 @@ +# 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 operator +import os +import unittest + +from google.cloud.proto.spanner.v1.type_pb2 import STRING +from google.cloud.proto.spanner.v1.type_pb2 import Type +from google.cloud.spanner.client import Client +from google.cloud.spanner.pool import BurstyPool +from google.cloud.spanner._fixtures import DDL_STATEMENTS + +from test_utils.retry import RetryErrors +from test_utils.retry import RetryInstanceState +from test_utils.retry import RetryResult +from test_utils.system import unique_resource_id + +IS_CIRCLE = os.getenv('CIRCLECI') == 'true' +CREATE_INSTANCE = IS_CIRCLE or os.getenv( + 'GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE') is not None + +if CREATE_INSTANCE: + INSTANCE_ID = 'google-cloud' + unique_resource_id('-') +else: + INSTANCE_ID = os.environ.get('GOOGLE_CLOUD_TESTS_SPANNER_INSTANCE', + 'google-cloud-python-systest') +DATABASE_ID = 'test_database' +EXISTING_INSTANCES = [] + + +class Config(object): + """Run-time configuration to be modified at set-up. + + This is a mutable stand-in to allow test set-up to modify + global state. + """ + CLIENT = None + INSTANCE_CONFIG = None + INSTANCE = None + + +def _retry_on_unavailable(exc): + """Retry only errors whose status code is 'UNAVAILABLE'.""" + from grpc import StatusCode + return exc.code() == StatusCode.UNAVAILABLE + + +def _has_all_ddl(database): + return len(database.ddl_statements) == len(DDL_STATEMENTS) + + +def setUpModule(): + from grpc._channel import _Rendezvous + Config.CLIENT = Client() + retry = RetryErrors(_Rendezvous, error_predicate=_retry_on_unavailable) + + configs = list(retry(Config.CLIENT.list_instance_configs)()) + + if len(configs) < 1: + raise ValueError('List instance configs failed in module set up.') + + Config.INSTANCE_CONFIG = configs[0] + config_name = configs[0].name + + def _list_instances(): + return list(Config.CLIENT.list_instances()) + + instances = retry(_list_instances)() + EXISTING_INSTANCES[:] = instances + + if CREATE_INSTANCE: + Config.INSTANCE = Config.CLIENT.instance(INSTANCE_ID, config_name) + created_op = Config.INSTANCE.create() + created_op.result(30) # block until completion + + else: + Config.INSTANCE = Config.CLIENT.instance(INSTANCE_ID) + Config.INSTANCE.reload() + + +def tearDownModule(): + if CREATE_INSTANCE: + Config.INSTANCE.delete() + + +class TestInstanceAdminAPI(unittest.TestCase): + + def setUp(self): + self.instances_to_delete = [] + + def tearDown(self): + for instance in self.instances_to_delete: + instance.delete() + + def test_list_instances(self): + instances = list(Config.CLIENT.list_instances()) + # We have added one new instance in `setUpModule`. + if CREATE_INSTANCE: + self.assertEqual(len(instances), len(EXISTING_INSTANCES) + 1) + for instance in instances: + instance_existence = (instance in EXISTING_INSTANCES or + instance == Config.INSTANCE) + self.assertTrue(instance_existence) + + def test_reload_instance(self): + # Use same arguments as Config.INSTANCE (created in `setUpModule`) + # so we can use reload() on a fresh instance. + instance = Config.CLIENT.instance( + INSTANCE_ID, Config.INSTANCE_CONFIG.name) + # Make sure metadata unset before reloading. + instance.display_name = None + + instance.reload() + self.assertEqual(instance.display_name, Config.INSTANCE.display_name) + + @unittest.skipUnless(CREATE_INSTANCE, 'Skipping instance creation') + def test_create_instance(self): + ALT_INSTANCE_ID = 'new' + unique_resource_id('-') + instance = Config.CLIENT.instance( + ALT_INSTANCE_ID, Config.INSTANCE_CONFIG.name) + operation = instance.create() + # Make sure this instance gets deleted after the test case. + self.instances_to_delete.append(instance) + + # We want to make sure the operation completes. + operation.result(30) # raises on failure / timeout. + + # Create a new instance instance and make sure it is the same. + instance_alt = Config.CLIENT.instance( + ALT_INSTANCE_ID, Config.INSTANCE_CONFIG.name) + instance_alt.reload() + + self.assertEqual(instance, instance_alt) + self.assertEqual(instance.display_name, instance_alt.display_name) + + def test_update_instance(self): + OLD_DISPLAY_NAME = Config.INSTANCE.display_name + NEW_DISPLAY_NAME = 'Foo Bar Baz' + Config.INSTANCE.display_name = NEW_DISPLAY_NAME + operation = Config.INSTANCE.update() + + # We want to make sure the operation completes. + operation.result(30) # raises on failure / timeout. + + # Create a new instance instance and reload it. + instance_alt = Config.CLIENT.instance(INSTANCE_ID, None) + self.assertNotEqual(instance_alt.display_name, NEW_DISPLAY_NAME) + instance_alt.reload() + self.assertEqual(instance_alt.display_name, NEW_DISPLAY_NAME) + + # Make sure to put the instance back the way it was for the + # other test cases. + Config.INSTANCE.display_name = OLD_DISPLAY_NAME + Config.INSTANCE.update() + + +class TestDatabaseAdminAPI(unittest.TestCase): + + @classmethod + def setUpClass(cls): + pool = BurstyPool() + cls._db = Config.INSTANCE.database(DATABASE_ID, pool=pool) + cls._db.create() + + @classmethod + def tearDownClass(cls): + cls._db.drop() + + def setUp(self): + self.to_delete = [] + + def tearDown(self): + for doomed in self.to_delete: + doomed.drop() + + def test_list_databases(self): + # Since `Config.INSTANCE` is newly created in `setUpModule`, the + # database created in `setUpClass` here will be the only one. + databases = list(Config.INSTANCE.list_databases()) + self.assertEqual(databases, [self._db]) + + def test_create_database(self): + pool = BurstyPool() + temp_db_id = 'temp-db' # test w/ hyphen + temp_db = Config.INSTANCE.database(temp_db_id, pool=pool) + operation = temp_db.create() + self.to_delete.append(temp_db) + + # We want to make sure the operation completes. + operation.result(30) # raises on failure / timeout. + + name_attr = operator.attrgetter('name') + expected = sorted([temp_db, self._db], key=name_attr) + + databases = list(Config.INSTANCE.list_databases()) + found = sorted(databases, key=name_attr) + self.assertEqual(found, expected) + + def test_update_database_ddl(self): + pool = BurstyPool() + temp_db_id = 'temp_db' + temp_db = Config.INSTANCE.database(temp_db_id, pool=pool) + create_op = temp_db.create() + self.to_delete.append(temp_db) + + # We want to make sure the operation completes. + create_op.result(90) # raises on failure / timeout. + + operation = temp_db.update_ddl(DDL_STATEMENTS) + + # We want to make sure the operation completes. + operation.result(90) # raises on failure / timeout. + + temp_db.reload() + + self.assertEqual(len(temp_db.ddl_statements), len(DDL_STATEMENTS)) + + +class TestSessionAPI(unittest.TestCase): + TABLE = 'contacts' + COLUMNS = ('contact_id', 'first_name', 'last_name', 'email') + ROW_DATA = ( + (1, u'Phred', u'Phlyntstone', u'phred@example.com'), + (2, u'Bharney', u'Rhubble', u'bharney@example.com'), + (3, u'Wylma', u'Phlyntstone', u'wylma@example.com'), + ) + SQL = 'SELECT * FROM contacts ORDER BY contact_id' + + @classmethod + def setUpClass(cls): + pool = BurstyPool() + cls._db = Config.INSTANCE.database( + DATABASE_ID, ddl_statements=DDL_STATEMENTS, pool=pool) + operation = cls._db.create() + operation.result(30) # raises on failure / timeout. + + @classmethod + def tearDownClass(cls): + cls._db.drop() + + def setUp(self): + self.to_delete = [] + + def tearDown(self): + for doomed in self.to_delete: + doomed.delete() + + def _check_row_data(self, row_data): + self.assertEqual(len(row_data), len(self.ROW_DATA)) + for found, expected in zip(row_data, self.ROW_DATA): + self.assertEqual(len(found), len(expected)) + for f_cell, e_cell in zip(found, expected): + self.assertEqual(f_cell, e_cell) + + def test_session_crud(self): + retry_true = RetryResult(operator.truth) + retry_false = RetryResult(operator.not_) + session = self._db.session() + self.assertFalse(session.exists()) + session.create() + retry_true(session.exists)() + session.delete() + retry_false(session.exists)() + + def test_batch_insert_then_read(self): + from google.cloud.spanner import KeySet + keyset = KeySet(all_=True) + + retry = RetryInstanceState(_has_all_ddl) + retry(self._db.reload)() + + session = self._db.session() + session.create() + self.to_delete.append(session) + + batch = session.batch() + batch.delete(self.TABLE, keyset) + batch.insert(self.TABLE, self.COLUMNS, self.ROW_DATA) + batch.commit() + + snapshot = session.snapshot(read_timestamp=batch.committed) + rows = list(snapshot.read(self.TABLE, self.COLUMNS, keyset)) + self._check_row_data(rows) + + def test_batch_insert_or_update_then_query(self): + + retry = RetryInstanceState(_has_all_ddl) + retry(self._db.reload)() + + session = self._db.session() + session.create() + self.to_delete.append(session) + + with session.batch() as batch: + batch.insert_or_update(self.TABLE, self.COLUMNS, self.ROW_DATA) + + snapshot = session.snapshot(read_timestamp=batch.committed) + rows = list(snapshot.execute_sql(self.SQL)) + self._check_row_data(rows) + + def test_transaction_read_and_insert_then_rollback(self): + from google.cloud.spanner import KeySet + keyset = KeySet(all_=True) + + retry = RetryInstanceState(_has_all_ddl) + retry(self._db.reload)() + + session = self._db.session() + session.create() + self.to_delete.append(session) + + with session.batch() as batch: + batch.delete(self.TABLE, keyset) + + transaction = session.transaction() + transaction.begin() + rows = list(transaction.read(self.TABLE, self.COLUMNS, keyset)) + self.assertEqual(rows, []) + + transaction.insert(self.TABLE, self.COLUMNS, self.ROW_DATA) + + # Inserted rows can't be read until after commit. + rows = list(transaction.read(self.TABLE, self.COLUMNS, keyset)) + self.assertEqual(rows, []) + transaction.rollback() + + rows = list(session.read(self.TABLE, self.COLUMNS, keyset)) + self.assertEqual(rows, []) + + def test_transaction_read_and_insert_or_update_then_commit(self): + from google.cloud.spanner import KeySet + keyset = KeySet(all_=True) + + retry = RetryInstanceState(_has_all_ddl) + retry(self._db.reload)() + + session = self._db.session() + session.create() + self.to_delete.append(session) + + with session.batch() as batch: + batch.delete(self.TABLE, keyset) + + with session.transaction() as transaction: + rows = list(transaction.read(self.TABLE, self.COLUMNS, keyset)) + self.assertEqual(rows, []) + + transaction.insert_or_update( + self.TABLE, self.COLUMNS, self.ROW_DATA) + + # Inserted rows can't be read until after commit. + rows = list(transaction.read(self.TABLE, self.COLUMNS, keyset)) + self.assertEqual(rows, []) + + rows = list(session.read(self.TABLE, self.COLUMNS, keyset)) + self._check_row_data(rows) + + def _set_up_table(self, row_count): + from google.cloud.spanner import KeySet + + def _row_data(max_index): + for index in range(max_index): + yield [index, 'First%09d' % (index,), 'Last09%d' % (index), + 'test-%09d@example.com' % (index,)] + + keyset = KeySet(all_=True) + + retry = RetryInstanceState(_has_all_ddl) + retry(self._db.reload)() + + session = self._db.session() + session.create() + self.to_delete.append(session) + + with session.transaction() as transaction: + transaction.delete(self.TABLE, keyset) + transaction.insert(self.TABLE, self.COLUMNS, _row_data(row_count)) + + return session, keyset, transaction.committed + + def test_read_w_manual_consume(self): + ROW_COUNT = 4000 + session, keyset, committed = self._set_up_table(ROW_COUNT) + + snapshot = session.snapshot(read_timestamp=committed) + streamed = snapshot.read(self.TABLE, self.COLUMNS, keyset) + + retrieved = 0 + while True: + try: + streamed.consume_next() + except StopIteration: + break + retrieved += len(streamed.rows) + streamed.rows[:] = () + + self.assertEqual(retrieved, ROW_COUNT) + self.assertEqual(streamed._current_row, []) + self.assertEqual(streamed._pending_chunk, None) + + def test_execute_sql_w_manual_consume(self): + ROW_COUNT = 4000 + session, _, committed = self._set_up_table(ROW_COUNT) + + snapshot = session.snapshot(read_timestamp=committed) + streamed = snapshot.execute_sql(self.SQL) + + retrieved = 0 + while True: + try: + streamed.consume_next() + except StopIteration: + break + retrieved += len(streamed.rows) + streamed.rows[:] = () + + self.assertEqual(retrieved, ROW_COUNT) + self.assertEqual(streamed._current_row, []) + self.assertEqual(streamed._pending_chunk, None) + + def test_execute_sql_w_query_param(self): + SQL = 'SELECT * FROM contacts WHERE first_name = @first_name' + ROW_COUNT = 10 + session, _, committed = self._set_up_table(ROW_COUNT) + + snapshot = session.snapshot(read_timestamp=committed) + rows = list(snapshot.execute_sql( + SQL, + params={'first_name': 'First%09d' % (0,)}, + param_types={'first_name': Type(code=STRING)}, + )) + + self.assertEqual(len(rows), 1) diff --git a/unit_tests/__init__.py b/tests/unit/__init__.py similarity index 100% rename from unit_tests/__init__.py rename to tests/unit/__init__.py diff --git a/unit_tests/streaming-read-acceptance-test.json b/tests/unit/streaming-read-acceptance-test.json similarity index 100% rename from unit_tests/streaming-read-acceptance-test.json rename to tests/unit/streaming-read-acceptance-test.json diff --git a/unit_tests/test__helpers.py b/tests/unit/test__helpers.py similarity index 100% rename from unit_tests/test__helpers.py rename to tests/unit/test__helpers.py diff --git a/unit_tests/test_batch.py b/tests/unit/test_batch.py similarity index 100% rename from unit_tests/test_batch.py rename to tests/unit/test_batch.py diff --git a/unit_tests/test_client.py b/tests/unit/test_client.py similarity index 100% rename from unit_tests/test_client.py rename to tests/unit/test_client.py diff --git a/unit_tests/test_database.py b/tests/unit/test_database.py similarity index 100% rename from unit_tests/test_database.py rename to tests/unit/test_database.py diff --git a/unit_tests/test_instance.py b/tests/unit/test_instance.py similarity index 100% rename from unit_tests/test_instance.py rename to tests/unit/test_instance.py diff --git a/unit_tests/test_keyset.py b/tests/unit/test_keyset.py similarity index 100% rename from unit_tests/test_keyset.py rename to tests/unit/test_keyset.py diff --git a/unit_tests/test_pool.py b/tests/unit/test_pool.py similarity index 100% rename from unit_tests/test_pool.py rename to tests/unit/test_pool.py diff --git a/unit_tests/test_session.py b/tests/unit/test_session.py similarity index 100% rename from unit_tests/test_session.py rename to tests/unit/test_session.py diff --git a/unit_tests/test_snapshot.py b/tests/unit/test_snapshot.py similarity index 100% rename from unit_tests/test_snapshot.py rename to tests/unit/test_snapshot.py diff --git a/unit_tests/test_streamed.py b/tests/unit/test_streamed.py similarity index 100% rename from unit_tests/test_streamed.py rename to tests/unit/test_streamed.py diff --git a/unit_tests/test_transaction.py b/tests/unit/test_transaction.py similarity index 100% rename from unit_tests/test_transaction.py rename to tests/unit/test_transaction.py diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 9e509cc9b0..0000000000 --- a/tox.ini +++ /dev/null @@ -1,31 +0,0 @@ -[tox] -envlist = - py27,py34,py35,cover - -[testing] -deps = - {toxinidir}/../core - pytest - mock -covercmd = - py.test --quiet \ - --cov=google.cloud.spanner \ - --cov=unit_tests \ - --cov-config {toxinidir}/.coveragerc \ - unit_tests - -[testenv] -commands = - py.test --quiet {posargs} unit_tests -deps = - {[testing]deps} - -[testenv:cover] -basepython = - python2.7 -commands = - {[testing]covercmd} -deps = - {[testenv]deps} - coverage - pytest-cov