From e8e00477fa012d7912ae67acc7d36bb672b3703d Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 24 Nov 2025 11:06:39 +0000 Subject: [PATCH 01/23] chore: Create setup for SpannerLib-Python project --- .../wrappers/spannerlib-python/README.md | 0 .../spannerlib-python/.gitignore | 43 ++++ .../spannerlib-python/GEMINI.md | 0 .../spannerlib-python/LICENSE | 202 +++++++++++++++ .../spannerlib-python/MANIFEST.in | 1 + .../spannerlib-python/README.md | 129 ++++++++++ .../spannerlib-python/build-shared-lib.sh | 79 ++++++ .../google/cloud/spannerlib/__init__.py | 18 ++ .../cloud/spannerlib/internal/__init__.py | 15 ++ .../spannerlib-python/noxfile.py | 235 ++++++++++++++++++ .../spannerlib-python/pyproject.toml | 50 ++++ .../spannerlib-python/requirements.txt | 2 + .../spannerlib-python/samples/README.md | 1 + .../spannerlib-python/samples/quickstart.py | 15 ++ .../samples/requirements.txt | 1 + .../spannerlib-python/setup.py | 9 + .../spannerlib-artifacts/.gitkeep | 1 + .../spannerlib-python/tests/__init__.py | 15 ++ .../tests/system/__init__.py | 15 ++ .../tests/system/test_placeholder.py | 24 ++ .../spannerlib-python/tests/unit/__init__.py | 15 ++ .../tests/unit/test_placeholder.py | 24 ++ 22 files changed, 894 insertions(+) create mode 100644 spannerlib/wrappers/spannerlib-python/README.md create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/.gitignore create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/GEMINI.md create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/LICENSE create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/MANIFEST.in create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md create mode 100755 spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/README.md create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/quickstart.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py diff --git a/spannerlib/wrappers/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/README.md new file mode 100644 index 00000000..e69de29b diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/.gitignore b/spannerlib/wrappers/spannerlib-python/spannerlib-python/.gitignore new file mode 100644 index 00000000..1480b419 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +.eggs/ +lib/ +lib64/ +*.egg-info/ +*.egg +MANIFEST + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +cover/ + +# Environments +.env +.venv +env/ +venv/ + +# mypy +.mypy_cache/ + +# IDEs and editors +.idea/ +.vscode/ +.DS_Store + +# Build Artifacts +google/cloud/spannerlib/internal/lib + +*_sponge_log.xml diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/GEMINI.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/GEMINI.md new file mode 100644 index 00000000..e69de29b diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/LICENSE b/spannerlib/wrappers/spannerlib-python/spannerlib-python/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/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/spannerlib/wrappers/spannerlib-python/spannerlib-python/MANIFEST.in b/spannerlib/wrappers/spannerlib-python/spannerlib-python/MANIFEST.in new file mode 100644 index 00000000..d7e007a8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/MANIFEST.in @@ -0,0 +1 @@ +recursive-include google/cloud/spannerlib/internal/lib * diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md new file mode 100644 index 00000000..da084f6a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -0,0 +1,129 @@ +# SPANNERLIB-PYTHON: A High-Performance Python Wrapper for the Go Spanner Client Shared lib + +## Introduction +The `spannerlib-python` wrapper provides a high-performance, idiomatic Python interface for Google Cloud Spanner by wrapping the official Go Client Shared library. + +The Go library is compiled into a C-shared library, and this project calls it directly from Python, aiming to combine Go's performance with Python's ease of use. + +## Code Structure + +```bash +spannerlib-python/ +|___google/cloud/spannerlib/ + |___internal - SpannerLib wrapper + |___lib - Spannerlib artifacts +|___tests/ + |___unit/ - Unit tests + |___system/ - System tests +|___samples +README.md +noxfile.py +pyproject.toml - Project config for packaging +``` + +## NOX Setup + +1. Create virtual environment + +**Mac/Linux** +```bash +pip install virtualenv +virtualenv +source /bin/activate +``` + +**Windows** +```bash +pip install virtualenv +virtualenv +\Scripts\activate +``` + +**Install Dependencies** +```bash +pip install -r requirements.txt +``` + +To run the nox tests, navigate to the root directory of this wrapper (`spannerlib-python`) and run: + +**format/Lint** + +```bash +nox -s format lint +``` + +**Unit Tests** + +```bash +nox -s unit +``` + +Run specific tests +```bash +# file +nox -s unit-3.13 -- tests/unit/test_connection.py +# class +nox -s unit-3.13 -- tests/unit/test_connection.py::TestConnection +# method +nox -s unit-3.13 -- tests/unit/test_connection.py::TestConnection::test_close_connection_propagates_error +``` + +**System Tests** + +The system tests require a Cloud Spanner Emulator instance running. + +1. **Pull and Run the Emulator:** + + ```bash + docker pull gcr.io/cloud-spanner-emulator/emulator + docker run -p 9010:9010 -p 9020:9020 -d gcr.io/cloud-spanner-emulator/emulator + ``` + +2. **Set Environment Variable:** + + Ensure the `SPANNER_EMULATOR_HOST` environment variable is set: + ```bash + export SPANNER_EMULATOR_HOST=localhost:9010 + ``` + +3. **Create Test Instance and Database:** + + You need the `gcloud` CLI installed and configured. + ```bash + gcloud spanner instances create test-instance --config=emulator-config --description="Test Instance" --nodes=1 + gcloud spanner databases create testdb --instance=test-instance + ``` + +4. **Run the System Tests:** + + ```bash + nox -s system + ``` + +## Build and install + +**Package** + +Create python wheel + +```bash +pip3 install build +python3 -m build +``` + +**Validate Package** + +```bash +pip3 install twine +twine check dist/* +unzip -l dist/spannerlib-0.1.0-py3-none-any.whl +tar -tvzf dist/spannerlib-0.1.0.tar.gz +``` + +**Install locally** + +```bash +pip3 install -e . +``` + + diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh new file mode 100755 index 00000000..d1b61a8b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Builds the shared library and copies the binaries to the appropriate folders for +# the Python wrapper. +# +# Binaries can be built for +# linux/x64, +# darwin/arm64, and +# windows/x64. +# +# Which ones are actually built depends on the values of the following variables: +# SKIP_MACOS: If set, will skip the darwin/arm64 build +# SKIP_LINUX: If set, will skip the linux/x64 build that uses the default C compiler on the system +# SKIP_LINUX_CROSS_COMPILE: If set, will skip the linux/x64 build that uses the x86_64-unknown-linux-gnu-gcc C compiler. +# This compiler is used when compiling for linux/x64 on MacOS. +# SKIP_WINDOWS: If set, will skip the windows/x64 build. + +# Fail execution if any command errors out +echo -e "Build Spannerlib Shared Lib" + +echo -e "RUNNER_OS DIR: $RUNNER_OS" +# Determine which builds to skip when the script runs on GitHub Actions. +if [ "$RUNNER_OS" == "Windows" ]; then + # Windows does not support any cross-compiling. + export SKIP_MACOS=true + export SKIP_LINUX=true + export SKIP_LINUX_CROSS_COMPILE=true +elif [ "$RUNNER_OS" == "macOS" ]; then + # When running on macOS, cross-compiling is supported. + # We skip the 'normal' Linux build (the one that does not explicitly set a C compiler). + export SKIP_LINUX=true +elif [ "$RUNNER_OS" == "Linux" ]; then + # Linux does not (yet) support cross-compiling to MacOS. + # In addition, we use the 'normal' Linux build when we are already running on Linux. + export SKIP_MACOS=true + export SKIP_LINUX_CROSS_COMPILE=true +fi + +SHARED_LIB_DIR="../../../shared" +TARGET_WRAPPER_DIR="../wrappers/spannerlib-python/spannerlib-python" +ARTIFACTS_DIR="spannerlib-artifacts" + +cd "$SHARED_LIB_DIR" || exit 1 + +./build-binaries.sh + +echo -e "PREPARING ARTIFACTS IN: $(pwd)" +# Navigate to the correct wrapper directory +cd "$TARGET_WRAPPER_DIR" || exit 1 + +echo -e "PREPARING ARTIFACTS IN: $(pwd)" + +# Cleanup old artifacts if they exist +if [ -d "$ARTIFACTS_DIR" ]; then + rm -rf "$ARTIFACTS_DIR" # 2> /dev/null +fi + +mkdir -p "$ARTIFACTS_DIR" + +if [ -z "$SKIP_MACOS" ]; then +echo "Copying MacOS binaries..." + mkdir -p "$ARTIFACTS_DIR/osx-arm64" + cp "$SHARED_LIB_DIR/binaries/osx-arm64/spannerlib.dylib" "$ARTIFACTS_DIR/osx-arm64/spannerlib.dylib" + cp "$SHARED_LIB_DIR/binaries/osx-arm64/spannerlib.h" "$ARTIFACTS_DIR/osx-arm64/spannerlib.h" +fi + +if [ -z "$SKIP_LINUX_CROSS_COMPILE" ] || [ -z "$SKIP_LINUX" ]; then + echo "Copying Linux binaries..." + mkdir -p "$ARTIFACTS_DIR/linux-x64" + cp "$SHARED_LIB_DIR/binaries/linux-x64/spannerlib.so" "$ARTIFACTS_DIR/linux-x64/spannerlib.so" + cp "$SHARED_LIB_DIR/binaries/linux-x64/spannerlib.h" "$ARTIFACTS_DIR/linux-x64/spannerlib.h" +fi + +if [ -z "$SKIP_WINDOWS" ]; then + echo "Copying Windows binaries..." + mkdir -p "$ARTIFACTS_DIR/win-x64" + cp "$SHARED_LIB_DIR/binaries/win-x64/spannerlib.dll" "$ARTIFACTS_DIR/win-x64/spannerlib.dll" + cp "$SHARED_LIB_DIR/binaries/win-x64/spannerlib.h" "$ARTIFACTS_DIR/win-x64/spannerlib.h" +fi diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py new file mode 100644 index 00000000..09702b99 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2025 Google LLC +# +# 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. + +"""Python wrapper for the Spanner Go library.""" +__version__ = "0.1.0" + +__all__: list[str] = [] diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py new file mode 100644 index 00000000..36df8bd0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# 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. + +# This file is intentionally left blank to mark this directory as a package. diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py new file mode 100644 index 00000000..2eb240dd --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -0,0 +1,235 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Noxfile for spannerlib-python package.""" + +import glob +import os +import platform +import shutil +from typing import List + +import nox + +DEFAULT_PYTHON_VERSION = "3.13" +PYTHON_VERSIONS = ["3.13"] + +UNIT_TEST_PYTHON_VERSIONS: List[str] = ["3.13"] +SYSTEM_TEST_PYTHON_VERSIONS: List[str] = ["3.13"] + + +FLAKE8_VERSION = "flake8>=6.1.0,<7.3.0" +BLACK_VERSION = "black[jupyter]>=23.7.0,<25.11.0" +ISORT_VERSION = "isort>=5.11.0,<7.0.0" +LINT_PATHS = ["google", "tests", "samples", "noxfile.py"] + +STANDARD_DEPENDENCIES = [ + "google-cloud-spanner", +] + +UNIT_TEST_STANDARD_DEPENDENCIES = [ + "mock", + "asyncmock", + "pytest", + "pytest-cov", + "pytest-asyncio", +] + +SYSTEM_TEST_STANDARD_DEPENDENCIES = [ + "pytest", +] + +VERBOSE = True +MODE = "--verbose" if VERBOSE else "--quiet" + +DIST_DIR = "dist" +LIB_DIR = "google/cloud/spannerlib/internal/lib" +ARTIFACT_DIR = "spannerlib-artifacts" + +# Error if a python version is missing +nox.options.error_on_missing_interpreters = True + +nox.options.sessions = ["format_code", "lint", "unit", "system"] + + +@nox.session(python=DEFAULT_PYTHON_VERSION) +def format_code(session): + """ + Run isort to sort imports. Then run black + to format code to uniform standard. + """ + session.install(BLACK_VERSION, ISORT_VERSION) + session.run( + "isort", + "--fss", + *LINT_PATHS, + ) + session.run( + "black", + "--line-length=80", + *LINT_PATHS, + ) + + +@nox.session +def lint(session): + """Run linters. + + Returns a failure if the linters find linting errors or sufficiently + serious code quality issues. + """ + session.install(FLAKE8_VERSION) + session.run( + "flake8", + "--max-line-length=124", + *LINT_PATHS, + ) + + +@nox.session(python=UNIT_TEST_PYTHON_VERSIONS) +def unit(session): + """Run unit tests.""" + + session.install("-e", ".") + session.install(*STANDARD_DEPENDENCIES, *UNIT_TEST_STANDARD_DEPENDENCIES) + + test_paths = ( + session.posargs if session.posargs else [os.path.join("tests", "unit")] + ) + session.run( + "py.test", + MODE, + f"--junitxml=unit_{session.python}_sponge_log.xml", + "--cov=google", + "--cov=tests/unit", + "--cov-append", + "--cov-config=.coveragerc", + "--cov-report=", + "--cov-fail-under=0", + *test_paths, + env={}, + ) + + +@nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS) +def system(session): + """Run system tests.""" + + session.install("-e", ".") + session.install(*STANDARD_DEPENDENCIES, *SYSTEM_TEST_STANDARD_DEPENDENCIES) + + test_paths = ( + session.posargs if session.posargs else [os.path.join("tests", "unit")] + ) + session.run( + "py.test", + MODE, + f"--junitxml=system_{session.python}_sponge_log.xml", + *test_paths, + env={}, + ) + + +def get_spannerlib_artifacts_binary(session): + """ + Returns spannerlib lib and header files. + """ + header = "spannerlib.h" + + lib = None + folder = None + + buildsystem = platform.system() + if buildsystem == "Darwin": + lib, folder = "spannerlib.dylib", "osx-arm64" + elif buildsystem == "Windows": + lib, folder = "spannerlib.dll", "win-x64" + elif buildsystem == "Linux": + lib, folder = "spannerlib.so", "linux-x64" + + if lib is None or folder is None: + session.error(f"Unsupported platform: {buildsystem}") + + return (lib, folder, header) + + +@nox.session +def build_spannerlib(session): + """ + Build SpannerLib artifacts. + """ + session.log("Building spannerlib artifacts...") + + # Run the build script + session.env["RUNNER_OS"] = platform.system() + session.run("bash", "./build-shared-lib.sh", external=True) + + +def copy_artifacts(session): + """ + Copy correct spannerlib artifact to lib folder + """ + session.log("Copy platform specific artifacts to lib dir") + artifact_dir_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), ARTIFACT_DIR + ) + lib_dir_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), LIB_DIR + ) + if os.path.exists(LIB_DIR): + shutil.rmtree(LIB_DIR) + os.makedirs(LIB_DIR) + lib, folder, header = get_spannerlib_artifacts_binary(session) + shutil.copy( + os.path.join(artifact_dir_path, folder, lib), + os.path.join(lib_dir_path, lib), + ) + shutil.copy( + os.path.join(artifact_dir_path, folder, header), + os.path.join(lib_dir_path, header), + ) + + +@nox.session +def build(session): + """ + Prepares the platform-specific artifacts and builds the wheel. + """ + if os.path.exists(DIST_DIR): + shutil.rmtree(DIST_DIR) + + # Install build dependencies + session.install("build", "twine") + + # Run the preparation step + copy_artifacts(session) + + # Build the wheel + session.log("Building...") + session.run("python", "-m", "build") + + # Check the built artifacts with twine + session.log("Checking artifacts with twine...") + artifacts = glob.glob("dist/*") + if not artifacts: + session.error("No built artifacts found in dist/ to check.") + + session.run("twine", "check", *artifacts) + + +@nox.session +def install(session): + """ + Install locally + """ + session.install("-e", ".") diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml new file mode 100644 index 00000000..fcad4576 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "spannerlib" +dynamic = ["version"] +authors = [ + { name="Google LLC", email="googleapis-packages@google.com" }, +] +description = "A Python wrapper for the Go spannerlib" +readme = "README.md" +license = "Apache-2.0" +license-files = [ + "LICENSE", +] +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "google-cloud-spanner", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "nox", +] + +[tool.setuptools] +dynamic = {"version" = {attr = "google.cloud.spannerlib.__version__"}} + +[tool.setuptools.packages.find] +where = ["."] +include = ["google*"] + +[project.urls] +Homepage = "https://github.com/googleapis/go-sql-spanner" +Repository = "https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python" diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt b/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt new file mode 100644 index 00000000..d08e05bd --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt @@ -0,0 +1,2 @@ +nox==2025.11.12 +setuptools>=68.0 diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/README.md new file mode 100644 index 00000000..905066ba --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/README.md @@ -0,0 +1 @@ +# SPANNERLIB-PYTHON Samples diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/quickstart.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/quickstart.py new file mode 100644 index 00000000..716bfc2b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/quickstart.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python + +# Copyright 2025 Google LLC +# +# 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/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt new file mode 100644 index 00000000..f314030a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt @@ -0,0 +1 @@ +google-cloud-spanner==3.59.0 \ No newline at end of file diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py new file mode 100644 index 00000000..8ebb9f40 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py @@ -0,0 +1,9 @@ +"""Setup script for spannerlib-python package.""" +from setuptools import setup + + +setup( + has_ext_modules=lambda: True, + include_package_data=True, + python_requires=">=3.8" +) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep @@ -0,0 +1 @@ + diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/__init__.py new file mode 100644 index 00000000..36df8bd0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# 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. + +# This file is intentionally left blank to mark this directory as a package. diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/__init__.py new file mode 100644 index 00000000..36df8bd0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# 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. + +# This file is intentionally left blank to mark this directory as a package. diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py new file mode 100644 index 00000000..f696fbe4 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py @@ -0,0 +1,24 @@ +# Copyright 2025 Google LLC +# +# 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 unittest + + +class TestPlaceholderE2E(unittest.TestCase): + """Placeholder for system tests.""" + + def test_none(self): + """Placeholder test method.""" + pass diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/__init__.py new file mode 100644 index 00000000..36df8bd0 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# 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. + +# This file is intentionally left blank to mark this directory as a package. diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py new file mode 100644 index 00000000..f696fbe4 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py @@ -0,0 +1,24 @@ +# Copyright 2025 Google LLC +# +# 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 unittest + + +class TestPlaceholderE2E(unittest.TestCase): + """Placeholder for system tests.""" + + def test_none(self): + """Placeholder test method.""" + pass From a52b1ae99923f3aa601a583a3a5bec4124b76e32 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 25 Nov 2025 06:50:17 +0000 Subject: [PATCH 02/23] refactor: rename nox session 'format_code' to 'format' --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index 2eb240dd..0f714f7c 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -59,11 +59,11 @@ # Error if a python version is missing nox.options.error_on_missing_interpreters = True -nox.options.sessions = ["format_code", "lint", "unit", "system"] +nox.options.sessions = ["format", "lint", "unit", "system"] @nox.session(python=DEFAULT_PYTHON_VERSION) -def format_code(session): +def format(session): """ Run isort to sort imports. Then run black to format code to uniform standard. From bd7a7bdb5091cddcec4a4a43d3f156d051910c63 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 25 Nov 2025 06:55:57 +0000 Subject: [PATCH 03/23] build: update default nox test path from unit to system for system tests. --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index 0f714f7c..c5fb4062 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -129,7 +129,7 @@ def system(session): session.install(*STANDARD_DEPENDENCIES, *SYSTEM_TEST_STANDARD_DEPENDENCIES) test_paths = ( - session.posargs if session.posargs else [os.path.join("tests", "unit")] + session.posargs if session.posargs else [os.path.join("tests", "system")] ) session.run( "py.test", From 3de9c3c0aca237613648cc0ac92b7f1c48d39412 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 25 Nov 2025 07:32:25 +0000 Subject: [PATCH 04/23] style: reformat conditional assignment for test paths in noxfile.py --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index c5fb4062..6ee6b587 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -129,7 +129,9 @@ def system(session): session.install(*STANDARD_DEPENDENCIES, *SYSTEM_TEST_STANDARD_DEPENDENCIES) test_paths = ( - session.posargs if session.posargs else [os.path.join("tests", "system")] + session.posargs + if session.posargs + else [os.path.join("tests", "system")] ) session.run( "py.test", From bb7d9070cc88d54d8266663c92c9237ae2fde9b9 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 07:05:01 +0000 Subject: [PATCH 05/23] feat: Add environment variable check for system tests and reorder package installation after other dependencies. --- .../spannerlib-python/spannerlib-python/GEMINI.md | 0 .../spannerlib-python/spannerlib-python/noxfile.py | 12 ++++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/GEMINI.md diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/GEMINI.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/GEMINI.md deleted file mode 100644 index e69de29b..00000000 diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index 6ee6b587..d47cb82f 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -100,8 +100,8 @@ def lint(session): def unit(session): """Run unit tests.""" - session.install("-e", ".") session.install(*STANDARD_DEPENDENCIES, *UNIT_TEST_STANDARD_DEPENDENCIES) + session.install("-e", ".") test_paths = ( session.posargs if session.posargs else [os.path.join("tests", "unit")] @@ -125,8 +125,16 @@ def unit(session): def system(session): """Run system tests.""" - session.install("-e", ".") + # Sanity check: Only run tests if the environment variable is set. + if not os.environ.get( + "GOOGLE_APPLICATION_CREDENTIALS", "" + ) and not os.environ.get("SPANNER_EMULATOR_HOST", ""): + session.skip( + "Credentials or emulator host must be set via environment variable" + ) + session.install(*STANDARD_DEPENDENCIES, *SYSTEM_TEST_STANDARD_DEPENDENCIES) + session.install("-e", ".") test_paths = ( session.posargs From fad115884a411ceb2c83963a45ed315e810561df Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 11:30:07 +0000 Subject: [PATCH 06/23] docs: generalize version in README examples and fix: improve build script robustness with `set -e`. --- .../wrappers/spannerlib-python/spannerlib-python/README.md | 4 ++-- .../spannerlib-python/spannerlib-python/build-shared-lib.sh | 2 ++ .../spannerlib-python/samples/requirements.txt | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md index da084f6a..bdeebf67 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -116,8 +116,8 @@ python3 -m build ```bash pip3 install twine twine check dist/* -unzip -l dist/spannerlib-0.1.0-py3-none-any.whl -tar -tvzf dist/spannerlib-0.1.0.tar.gz +unzip -l dist/spannerlib--py3-none-any.whl +tar -tvzf dist/spannerlib-.tar.gz ``` **Install locally** diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh index d1b61a8b..c7d8ba53 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -16,6 +16,8 @@ # SKIP_WINDOWS: If set, will skip the windows/x64 build. # Fail execution if any command errors out +set -e + echo -e "Build Spannerlib Shared Lib" echo -e "RUNNER_OS DIR: $RUNNER_OS" diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt index f314030a..a40ec5c3 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt @@ -1 +1 @@ -google-cloud-spanner==3.59.0 \ No newline at end of file +google-cloud-spanner==3.59.0 From bb7afc3cfd3289b3609fd1a585635ab7f505bb11 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 11:33:24 +0000 Subject: [PATCH 07/23] chore: increase code coverage failure threshold to 80% --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index d47cb82f..dad00374 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -115,7 +115,7 @@ def unit(session): "--cov-append", "--cov-config=.coveragerc", "--cov-report=", - "--cov-fail-under=0", + "--cov-fail-under=80", *test_paths, env={}, ) From 8aa5d5a3cf8187e4d6b0d0aa001805971e58a73d Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 17:04:13 +0530 Subject: [PATCH 08/23] Update spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../spannerlib-python/tests/unit/test_placeholder.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py index f696fbe4..162b09e4 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py @@ -11,13 +11,11 @@ # 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 unittest -class TestPlaceholderE2E(unittest.TestCase): - """Placeholder for system tests.""" +class TestPlaceholderUnit(unittest.TestCase): + """Placeholder for unit tests.""" def test_none(self): """Placeholder test method.""" From 739443e371b5fd732560810a7377b6f575075046 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 11:37:29 +0000 Subject: [PATCH 09/23] chore: remove `absolute_import` from system test placeholder. --- .../spannerlib-python/tests/system/test_placeholder.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py index f696fbe4..3f4dfd11 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py @@ -11,8 +11,6 @@ # 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 unittest From e425af188751cecd5fb4f2f71b194c918bcf44a6 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 12:12:54 +0000 Subject: [PATCH 10/23] refactor: remove trailing whitespace from TARGET_WRAPPER_DIR assignment in build script --- .../spannerlib-python/spannerlib-python/build-shared-lib.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh index c7d8ba53..9db36d94 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -39,7 +39,7 @@ elif [ "$RUNNER_OS" == "Linux" ]; then fi SHARED_LIB_DIR="../../../shared" -TARGET_WRAPPER_DIR="../wrappers/spannerlib-python/spannerlib-python" +TARGET_WRAPPER_DIR="../wrappers/spannerlib-python/spannerlib-python" ARTIFACTS_DIR="spannerlib-artifacts" cd "$SHARED_LIB_DIR" || exit 1 From e7804e40ec91c1af695501e7c9160ff576f86fdd Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 1 Dec 2025 12:14:00 +0000 Subject: [PATCH 11/23] docs: Remove trailing whitespace from tar command in README. --- .../wrappers/spannerlib-python/spannerlib-python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md index bdeebf67..8c132689 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -117,7 +117,7 @@ python3 -m build pip3 install twine twine check dist/* unzip -l dist/spannerlib--py3-none-any.whl -tar -tvzf dist/spannerlib-.tar.gz +tar -tvzf dist/spannerlib-.tar.gz ``` **Install locally** From 9f28d7edd3f81e811efb8a8b37a03f579ad7ae08 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 2 Dec 2025 05:18:56 +0000 Subject: [PATCH 12/23] fix: skip build_spannerlib session on Windows. --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index dad00374..d5a602ac 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -177,7 +177,11 @@ def get_spannerlib_artifacts_binary(session): def build_spannerlib(session): """ Build SpannerLib artifacts. + Used only in dev env to build SpannerLib artifacts. """ + if platform.system() == "Windows": + session.skip("Skipping build_spannerlib on Windows") + session.log("Building spannerlib artifacts...") # Run the build script From d76e719685bd29f28dd8a0a0f2df23b0df5d6ba1 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 2 Dec 2025 11:22:49 +0530 Subject: [PATCH 13/23] Update README.md to use wildcard * for whl name Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../wrappers/spannerlib-python/spannerlib-python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md index 8c132689..0edc19ae 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -116,7 +116,7 @@ python3 -m build ```bash pip3 install twine twine check dist/* -unzip -l dist/spannerlib--py3-none-any.whl +unzip -l dist/spannerlib-*-*.whl tar -tvzf dist/spannerlib-.tar.gz ``` From b1c7f2ec1ddf96ffc4e898112d858f468f62048b Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 2 Dec 2025 11:24:56 +0530 Subject: [PATCH 14/23] Update build-shared-lib.sh to remove dev env comment Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../spannerlib-python/spannerlib-python/build-shared-lib.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh index 9db36d94..eeef8956 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -54,7 +54,7 @@ echo -e "PREPARING ARTIFACTS IN: $(pwd)" # Cleanup old artifacts if they exist if [ -d "$ARTIFACTS_DIR" ]; then - rm -rf "$ARTIFACTS_DIR" # 2> /dev/null + rm -rf "$ARTIFACTS_DIR" fi mkdir -p "$ARTIFACTS_DIR" From 4afafd114f6a94d45943cf5fb2c5751e8731b82e Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 2 Dec 2025 11:25:32 +0530 Subject: [PATCH 15/23] Update noxfile.py to add more python version test support Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index d5a602ac..26015cb9 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -24,8 +24,8 @@ DEFAULT_PYTHON_VERSION = "3.13" PYTHON_VERSIONS = ["3.13"] -UNIT_TEST_PYTHON_VERSIONS: List[str] = ["3.13"] -SYSTEM_TEST_PYTHON_VERSIONS: List[str] = ["3.13"] +UNIT_TEST_PYTHON_VERSIONS: List[str] = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] +SYSTEM_TEST_PYTHON_VERSIONS: List[str] = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] FLAKE8_VERSION = "flake8>=6.1.0,<7.3.0" From 32daa8ae4231f7cfeae57abbc363caa66026702d Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 2 Dec 2025 06:41:15 +0000 Subject: [PATCH 16/23] chore: Remove unused Python version definitions from noxfile and setup.py. --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 1 - spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py | 1 - 2 files changed, 2 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index 26015cb9..79ab7d5d 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -22,7 +22,6 @@ import nox DEFAULT_PYTHON_VERSION = "3.13" -PYTHON_VERSIONS = ["3.13"] UNIT_TEST_PYTHON_VERSIONS: List[str] = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] SYSTEM_TEST_PYTHON_VERSIONS: List[str] = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py index 8ebb9f40..6d428323 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py @@ -5,5 +5,4 @@ setup( has_ext_modules=lambda: True, include_package_data=True, - python_requires=">=3.8" ) From 47ffbaafb8571befc706549c1251afe3acec6d66 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 2 Dec 2025 08:39:16 +0000 Subject: [PATCH 17/23] style: Reformat Python version lists and set flake8 max line length to 80. --- .../spannerlib-python/noxfile.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index 79ab7d5d..e3e9bb37 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -23,8 +23,22 @@ DEFAULT_PYTHON_VERSION = "3.13" -UNIT_TEST_PYTHON_VERSIONS: List[str] = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] -SYSTEM_TEST_PYTHON_VERSIONS: List[str] = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] +UNIT_TEST_PYTHON_VERSIONS: List[str] = [ + "3.8", + "3.9", + "3.10", + "3.11", + "3.12", + "3.13", +] +SYSTEM_TEST_PYTHON_VERSIONS: List[str] = [ + "3.8", + "3.9", + "3.10", + "3.11", + "3.12", + "3.13", +] FLAKE8_VERSION = "flake8>=6.1.0,<7.3.0" @@ -90,7 +104,7 @@ def lint(session): session.install(FLAKE8_VERSION) session.run( "flake8", - "--max-line-length=124", + "--max-line-length=80", *LINT_PATHS, ) From 5498bd3237e8ba81bb6a921c1dfd816dffb8ff40 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Wed, 3 Dec 2025 05:29:57 +0000 Subject: [PATCH 18/23] fix: update README tar command to use wildcard for version --- .../wrappers/spannerlib-python/spannerlib-python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md index 0edc19ae..e10d6fbe 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -117,7 +117,7 @@ python3 -m build pip3 install twine twine check dist/* unzip -l dist/spannerlib-*-*.whl -tar -tvzf dist/spannerlib-.tar.gz +tar -tvzf dist/spannerlib-*.tar.gz ``` **Install locally** From 45fb2f2f58040b8f89be6580c7ca41fda27a70a1 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Thu, 4 Dec 2025 19:52:01 +0530 Subject: [PATCH 19/23] Chore: Python-Spanner-Driver | Spannerlib Wrapper | Added code to handle spannerlib library interaction (#657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add initial Python wrapper for Spanner library, including internal message structures, error handling, and a protocol definition. * refactor: Introduce `_load_library` for library initialization, remove `_get_lib_path`, and update related tests. * Chore: Python-Spanner-Driver | Spannerlib Wrapper | Added Pool class and relevant tests (#658) * feat: Introduce AbstractLibraryObject and Pool modules with their unit and system tests, replacing placeholder tests. * Chore: Python-Spanner-Driver - Added Connection class and relevant tests (#659) * feat: introduce Connection class and enable Pool to create and manage connections * Chore: Python-Spanner-Driver | Spannerlib Wrapper | Added Execute, ExecuteBatch and WriteMutation methods and relevant tests... (#660) * feat: Introduce `Rows` class and add `execute`, `execute_batch`, and `write_mutations` methods to `Connection`. * feat: Add SQL execution, batch DML, and mutation writing methods to Connection and expose Rows object. * Chore: Python-Spanner-Driver - Added Rows class, its methods and relevant tests... (#661) * test: add system tests for query, batch DML, and mutation operations, including emulator setup. * feat: Add `next()`, `metadata()`, `result_set_stats()`, and `update_count()` methods to the `Rows` class for fetching data and statistics, including unit tests. * test: Add system tests for `Rows` class covering metadata, stats, update count, and row iteration. * Chore: Python-Spanner-Driver - Added transaction functions in Connection class and relevant tests... (#662) * feat: Add transaction management with `begin_transaction`, `commit`, and `rollback` methods and their respective tests. * feat: add system and unit tests for transaction mutation writes, including commit and rollback scenarios. * feat: `update_count` returns -1 instead of 0 when row count stats are unavailable and updates the corresponding test. * refactor: remove direct Pool argument from Rows constructor and access via Connection. * feat: Add `IF NOT EXISTS` to `CREATE TABLE` and `IF EXISTS` to `DROP TABLE` statements, removing explicit exception handling. * feat: `write_mutations` now returns `None` for buffered mutations, updating its return type and related tests. * Update spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/message.py Co-authored-by: Knut Olav Løite * feat: Remove Python 3.8 fallback for `importlib.resources` and few docstring update * feat: Enhance SpannerLibError messages to include gRPC status names and add corresponding tests. * test: update SpannerLibError repr test message and code values --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Knut Olav Løite --- .../spannerlib-python/README.md | 4 +- .../google/cloud/spannerlib/__init__.py | 18 +- .../spannerlib/abstract_library_object.py | 140 ++++ .../google/cloud/spannerlib/connection.py | 255 +++++++ .../cloud/spannerlib/internal/__init__.py | 18 +- .../cloud/spannerlib/internal/errors.py | 83 +++ .../cloud/spannerlib/internal/message.py | 186 +++++ .../cloud/spannerlib/internal/spannerlib.py | 581 ++++++++++++++++ .../internal/spannerlib_protocol.py | 106 +++ .../google/cloud/spannerlib/internal/types.py | 169 +++++ .../google/cloud/spannerlib/pool.py | 99 +++ .../google/cloud/spannerlib/rows.py | 176 +++++ .../spannerlib-python/noxfile.py | 3 + .../spannerlib-python/pyproject.toml | 1 + .../spannerlib-python/requirements.txt | 1 + .../spannerlib-python/tests/system/README.md | 31 + .../spannerlib-python/tests/system/_helper.py | 63 ++ .../tests/system/_setup_env.py | 79 +++ .../tests/system/test_connection.py | 253 +++++++ .../tests/system/test_placeholder.py | 22 - .../tests/system/test_pool.py | 53 ++ .../tests/system/test_rows.py | 126 ++++ .../__init__.py} | 9 - .../tests/unit/internal/test_errors.py | 88 +++ .../tests/unit/internal/test_message.py | 218 ++++++ .../tests/unit/internal/test_spannerlib.py | 413 +++++++++++ .../tests/unit/internal/test_types.py | 168 +++++ .../unit/test_abstract_library_object.py | 180 +++++ .../tests/unit/test_connection.py | 646 ++++++++++++++++++ .../spannerlib-python/tests/unit/test_pool.py | 244 +++++++ .../spannerlib-python/tests/unit/test_rows.py | 441 ++++++++++++ 31 files changed, 4838 insertions(+), 36 deletions(-) create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/abstract_library_object.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/connection.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/errors.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/message.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib_protocol.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/types.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/pool.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/rows.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/README.md create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_helper.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_setup_env.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_connection.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_pool.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_rows.py rename spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/{test_placeholder.py => internal/__init__.py} (76%) create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_errors.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_message.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_spannerlib.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_types.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_abstract_library_object.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_connection.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_pool.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_rows.py diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md index e10d6fbe..c2036ce0 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -111,14 +111,14 @@ pip3 install build python3 -m build ``` -**Validate Package** +**Validate Package** ```bash pip3 install twine twine check dist/* unzip -l dist/spannerlib-*-*.whl tar -tvzf dist/spannerlib-*.tar.gz -``` +``` **Install locally** diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py index 09702b99..ffa40216 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/__init__.py @@ -13,6 +13,20 @@ # limitations under the License. """Python wrapper for the Spanner Go library.""" -__version__ = "0.1.0" +import logging +from typing import Final -__all__: list[str] = [] +from google.cloud.spannerlib.connection import Connection +from google.cloud.spannerlib.pool import Pool +from google.cloud.spannerlib.rows import Rows + +__version__: Final[str] = "0.1.0" + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +__all__: list[str] = [ + "Pool", + "Connection", + "Rows", +] diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/abstract_library_object.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/abstract_library_object.py new file mode 100644 index 00000000..1c11ed9a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/abstract_library_object.py @@ -0,0 +1,140 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Abstract base class for SpannerLib objects.""" + +from abc import ABC, abstractmethod +from typing import Optional +import warnings + +from .internal.spannerlib_protocol import SpannerLibProtocol + + +class ObjectClosedError(RuntimeError): + """Raised when an operation is attempted on a closed/disposed object.""" + + +class AbstractLibraryObject(ABC): + """ + Base class for all objects created by SpannerLib. + + Implements the Context Manager protocol (for 'with' statements) + to handle automatic resource cleanup. + """ + + def __init__(self, spannerlib: SpannerLibProtocol, oid: int) -> None: + """ + Initializes the AbstractLibraryObject. + + Args: + spannerlib: The Spanner library instance. + oid: The unique identifier for this object. + """ + self._spannerlib: SpannerLibProtocol = spannerlib + self._oid: int = oid + self._is_disposed: bool = False + + @property + def spannerlib(self) -> SpannerLibProtocol: + """Returns the associated Spanner library instance.""" + return self._spannerlib + + @property + def oid(self) -> int: + """Returns the object ID.""" + return self._oid + + @property + def closed(self) -> bool: + """Returns True if the object is closed/disposed.""" + return self._is_disposed + + def _check_disposed(self) -> None: + """ + Checks if the object has been disposed. + + Raises: + ObjectClosedError: If the object has already been closed/disposed. + """ + if self._is_disposed: + raise ObjectClosedError( + f"{self.__class__.__name__} has already been disposed." + ) + + def _mark_disposed(self) -> None: + """Marks the object as disposed.""" + self._is_disposed = True + + # ------------------------------------------------------------------------- + # Synchronous Disposal (Context Manager) + # ------------------------------------------------------------------------- + def close(self) -> None: + """ + Closes the object and releases resources. + """ + self._dispose() + + def __enter__(self) -> "AbstractLibraryObject": + """Enters the runtime context related to this object.""" + return self + + def __exit__( + self, + exc_type: Optional[type], + exc_val: Optional[Exception], + exc_tb: Optional[object], + ) -> None: + """Exits the runtime context and closes the object.""" + self.close() + + def _dispose(self) -> None: + """ + Internal disposal logic. + """ + if self._is_disposed: + return + + try: + if self._oid > 0: + self._close_lib_object() + finally: + self._is_disposed = True + + # ------------------------------------------------------------------------- + # Abstract Methods + # ------------------------------------------------------------------------- + @abstractmethod + def _close_lib_object(self) -> None: + """ + Closes the underlying library object. + + Must be implemented by concrete subclasses to call the corresponding + Close function in SpannerLib. + """ + pass + + # ------------------------------------------------------------------------- + # Finalizer + # ------------------------------------------------------------------------- + def __del__(self) -> None: + """ + Finalizer that attempts to clean up resources if not explicitly closed. + """ + if not self._is_disposed: + warnings.warn( + f"Unclosed {self.__class__.__name__} (ID: {self._oid}). " + "Use 'with' or 'async with' to manage resources.", + ResourceWarning, + stacklevel=2, + ) + self._dispose() diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/connection.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/connection.py new file mode 100644 index 00000000..7bb0be71 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/connection.py @@ -0,0 +1,255 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Module for the Connection class +representing a single connection to Spanner.""" +import logging +from typing import TYPE_CHECKING, Optional + +from google.cloud.spanner_v1 import ( + BatchWriteRequest, + CommitResponse, + ExecuteBatchDmlRequest, + ExecuteBatchDmlResponse, + ExecuteSqlRequest, + TransactionOptions, +) + +from .abstract_library_object import AbstractLibraryObject +from .internal.errors import SpannerLibError +from .internal.types import to_bytes +from .rows import Rows + +if TYPE_CHECKING: + from .pool import Pool + +logger = logging.getLogger(__name__) + + +class Connection(AbstractLibraryObject): + """Represents a single connection to the Spanner database. + + This class wraps the connection handle from the underlying Go library, + providing methods to manage the connection lifecycle. + """ + + def __init__(self, oid: int, pool: "Pool") -> None: + """Initializes a Connection object. + + Args: + oid (int): The object ID (handle) of the connection in the Go + library. + pool (Pool): The Pool object from which this connection was + created. + """ + super().__init__(pool.spannerlib, oid) + self._pool = pool + + @property + def pool(self) -> "Pool": + """Returns the pool associated with this connection.""" + return self._pool + + def _close_lib_object(self) -> None: + """Internal method to close the connection in the Go library.""" + try: + logger.debug("Closing connection ID: %d", self.oid) + # Call the Go library function to close the connection. + with self.spannerlib.close_connection( + self.pool.oid, self.oid + ) as msg: + msg.raise_if_error() + logger.debug("Connection ID: %d closed", self.oid) + except SpannerLibError: + logger.exception( + "SpannerLib error closing connection ID: %d", self.oid + ) + raise + except Exception as e: + logger.exception( + "Unexpected error closing connection ID: %d", self.oid + ) + raise SpannerLibError(f"Unexpected error during close: {e}") from e + + def execute(self, request: ExecuteSqlRequest) -> Rows: + """Executes a SQL statement on the connection. + + Args: + request (ExecuteSqlRequest): The ExecuteSqlRequest object. + + Returns: + A Rows object representing the result of the execution. + """ + if self.closed: + raise SpannerLibError("Connection is closed.") + + logger.debug( + "Executing SQL on connection ID: %d for pool ID: %d", + self.oid, + self.pool.oid, + ) + + request_bytes = ExecuteSqlRequest.serialize(request) + + # Call the Go library function to execute the SQL statement. + with self.spannerlib.execute( + self.pool.oid, self.oid, request_bytes + ) as msg: + msg.raise_if_error() + logger.debug( + "SQL execution successful on connection ID: %d." + "Got Rows ID: %d", + self.oid, + msg.object_id, + ) + return Rows(msg.object_id, self) + + def execute_batch( + self, request: ExecuteBatchDmlRequest + ) -> ExecuteBatchDmlResponse: + """Executes a batch of DML statements on the connection. + + Args: + request: The ExecuteBatchDmlRequest object. + + Returns: + An ExecuteBatchDmlResponse object representing the result + of the execution. + """ + if self.closed: + raise SpannerLibError("Connection is closed.") + + logger.debug( + "Executing batch DML on connection ID: %d for pool ID: %d", + self.oid, + self.pool.oid, + ) + + request_bytes = ExecuteBatchDmlRequest.serialize(request) + + # Call the Go library function to execute the batch DML statement. + with self.spannerlib.execute_batch( + self.pool.oid, + self.oid, + request_bytes, + ) as msg: + msg.raise_if_error() + logger.debug( + "Batch DML execution successful on connection ID: %d.", + self.oid, + ) + response_bytes = to_bytes(msg.msg, msg.msg_len) + return ExecuteBatchDmlResponse.deserialize(response_bytes) + + def write_mutations( + self, request: BatchWriteRequest.MutationGroup + ) -> Optional[CommitResponse]: + """Writes a mutation to the connection. + + Args: + request: The BatchWriteRequest_MutationGroup object. + + Returns: + A CommitResponse object if the mutation was applied immediately + (no active transaction), or None if it was buffered. + """ + if self.closed: + raise SpannerLibError("Connection is closed.") + + logger.debug( + "Writing mutation on connection ID: %d for pool ID: %d", + self.oid, + self.pool.oid, + ) + + request_bytes = BatchWriteRequest.MutationGroup.serialize(request) + + # Call the Go library function to write the mutation. + with self.spannerlib.write_mutations( + self.pool.oid, + self.oid, + request_bytes, + ) as msg: + msg.raise_if_error() + logger.debug( + "Mutation write successful on connection ID: %d.", self.oid + ) + if msg.msg_len > 0 and msg.msg: + response_bytes = to_bytes(msg.msg, msg.msg_len) + return CommitResponse.deserialize(response_bytes) + return None + + def begin_transaction(self, options: TransactionOptions = None): + """Begins a new transaction on the connection. + + Args: + options: Optional transaction options from google.cloud.spanner_v1. + + Raises: + SpannerLibError: If the connection is closed. + SpannerLibraryError: If the Go library call fails. + """ + if self.closed: + raise SpannerLibError("Connection is closed.") + + logger.debug( + "Beginning transaction on connection ID: %d for pool ID: %d", + self.oid, + self.pool.oid, + ) + + if options is None: + options = TransactionOptions() + + options_bytes = TransactionOptions.serialize(options) + + with self.spannerlib.begin_transaction( + self.pool.oid, self.oid, options_bytes + ) as msg: + msg.raise_if_error() + logger.debug("Transaction started on connection ID: %d", self.oid) + + def commit(self) -> CommitResponse: + """Commits the transaction. + + Raises: + SpannerLibError: If the connection is closed. + SpannerLibraryError: If the Go library call fails. + + Returns: + A CommitResponse object. + """ + if self.closed: + raise SpannerLibError("Connection is closed.") + + logger.debug("Committing on connection ID: %d", self.oid) + with self.spannerlib.commit(self.pool.oid, self.oid) as msg: + msg.raise_if_error() + logger.debug("Committed") + response_bytes = to_bytes(msg.msg, msg.msg_len) + return CommitResponse.deserialize(response_bytes) + + def rollback(self): + """Rolls back the transaction. + + Raises: + SpannerLibError: If the connection is closed. + SpannerLibraryError: If the Go library call fails. + """ + if self.closed: + raise SpannerLibError("Connection is closed.") + + logger.debug("Rolling back on connection ID: %d", self.oid) + with self.spannerlib.rollback(self.pool.oid, self.oid) as msg: + msg.raise_if_error() + logger.debug("Rolled back") diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py index 36df8bd0..7ba192ed 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/__init__.py @@ -12,4 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -# This file is intentionally left blank to mark this directory as a package. +"""Internal module for the spannerlib package.""" + +from .errors import SpannerError, SpannerLibError +from .message import Message +from .spannerlib import SpannerLib +from .spannerlib_protocol import SpannerLibProtocol +from .types import GoSlice, GoString + +__all__: list[str] = [ + "GoString", + "GoSlice", + "SpannerError", + "SpannerLibError", + "Message", + "SpannerLib", + "SpannerLibProtocol", +] diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/errors.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/errors.py new file mode 100644 index 00000000..45ee52b9 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/errors.py @@ -0,0 +1,83 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Internal error types for the spannerlib package.""" +from typing import Optional + + +class SpannerError(Exception): + """Base exception for all spannerlib-python wrapper errors. + + Catching this exception guarantees catching any error raised explicitly + by this library. + """ + + +_GRPC_STATUS_CODE_TO_NAME = { + 0: "OK", + 1: "CANCELLED", + 2: "UNKNOWN", + 3: "INVALID_ARGUMENT", + 4: "DEADLINE_EXCEEDED", + 5: "NOT_FOUND", + 6: "ALREADY_EXISTS", + 7: "PERMISSION_DENIED", + 8: "RESOURCE_EXHAUSTED", + 9: "FAILED_PRECONDITION", + 10: "ABORTED", + 11: "OUT_OF_RANGE", + 12: "UNIMPLEMENTED", + 13: "INTERNAL", + 14: "UNAVAILABLE", + 15: "DATA_LOSS", + 16: "UNAUTHENTICATED", +} + + +class SpannerLibError(SpannerError): + """Exception raised when the underlying Go library returns an error code.""" + + def __init__(self, message: str, error_code: Optional[int] = None) -> None: + """Initializes the SpannerLibError. + + Args: + message (str): The error description. + error_code (Optional[int]): The gRPC status code + (e.g., 5 for NOT_FOUND). + """ + self.message = message + self.error_code = error_code + + # Format the string representation for immediate clarity in logs. + # Example: "[Err 5 (NOT_FOUND)] Object not found" + if error_code is not None: + status_name = _GRPC_STATUS_CODE_TO_NAME.get(error_code) + if status_name: + formatted_message = ( + f"[Err {error_code} ({status_name})] {message}" + ) + else: + formatted_message = f"[Err {error_code}] {message}" + else: + formatted_message = message + + # Initialize the base Exception with the formatted message so + # standard Python logging/printing tools show the code automatically. + super().__init__(formatted_message) + + def __repr__(self) -> str: + """Standard unambiguous representation for debugging.""" + return ( + f"<{self.__class__.__name__}(code={self.error_code}, " + f"message='{self.message}')>" + ) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/message.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/message.py new file mode 100644 index 00000000..1a0360ff --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/message.py @@ -0,0 +1,186 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Internal message structure for spannerlib-python wrapper.""" +import ctypes +import logging +from types import TracebackType +from typing import Optional, Protocol, Type, runtime_checkable +import warnings + +from .errors import SpannerLibError + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class ReleasableProtocol(Protocol): + """Protocol for libraries that can release pinned memory.""" + + def release(self, handle: int) -> int: + """Calls the Release function from the shared object.""" + ... + + +class Message(ctypes.Structure): + """Represents the raw return structure from SpannerLib (C-Layout). + + This structure maps to the Go return values. + + It acts as a 'Smart Record' that holds a reference to its parent library + to facilitate self-cleanup. + + Memory Safety Note: + If 'pinner_id' is non-zero, Go is holding a reference to memory. + This generic response must be processed and then the pinner must be + freed via the library's free function to prevent memory leaks. + + Attributes: + pinner_id (ctypes.c_longlong): ID for managing memory in Go (r0). + error_code (ctypes.c_int32): Error code, 0 for success (r1). + object_id (ctypes.c_longlong): ID of the created object in Go, + if any (r2). + msg_len (ctypes.c_int32): Length of the error message (r3). + msg (ctypes.c_void_p): Pointer to the error message string, + if any (r4). + """ + + _fields_ = [ + ("pinner_id", ctypes.c_int64), # r0: Handle ID for Go memory pinning + ("error_code", ctypes.c_int32), # r1: 0 = Success, >0 = Error + ("object_id", ctypes.c_int64), # r2: ID of the resulting object + ( + "msg_len", + ctypes.c_int32, + ), # r3: Length of result or error message bytes + ( + "msg", + ctypes.c_void_p, + ), # r4: Pointer to result or error message bytes + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Dependency Injection slot (not part of C structure) + self._lib: Optional[ReleasableProtocol] = None + self._is_released: bool = False + + def bind_library(self, lib: ReleasableProtocol) -> "Message": + """Injects the library instance needed for cleanup. + + Args: + lib: The ctypes library instance containing the + 'Release' function. + """ + self._lib = lib + return self + + def __enter__(self) -> "Message": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self.release() + + @property + def had_error(self) -> bool: + """Checks if the operation failed.""" + return self.error_code > 0 + + @property + def message(self) -> str: + """Decodes the raw C-string message into a Python string safely.""" + if not self.msg or self.msg_len <= 0: + return "" + + try: + # Read exactly msg_len bytes from the pointer + raw_bytes = ctypes.string_at(self.msg, self.msg_len) + return raw_bytes.decode("utf-8") + except UnicodeDecodeError: + return "" + + def raise_if_error(self) -> None: + """Raises a SpannerLibError if the response indicates failure. + + Raises: + SpannerLibError: If error_code != 0. + """ + if self.had_error: + err_msg = self.message or "Unknown error occurred" + logger.error( + "SpannerLib operation failed: %s (Code: %d)", + err_msg, + self.error_code, + ) + raise SpannerLibError(self.message, self.error_code) + + def release(self) -> None: + """Releases memory using the injected library instance.""" + if getattr(self, "_is_released", False): + return + + self._is_released = True + + # 1. Check if we have something to free + if self.pinner_id == 0: + return + + # 2. Check if we have the tool to free it + lib = getattr(self, "_lib", None) + if lib is None: + logger.critical( + "Message (pinner=%d) cannot be released! " + "Library dependency was not injected via bind_library().", + self.pinner_id, + ) + return + + # 3. Execute Safe Release + try: + self._lib.release(self.pinner_id) + logger.debug("Invoked %s.release(%d)", self._lib, self.pinner_id) + except ctypes.ArgumentError as e: + logger.exception("Native release failed: %s", e) + # We do not re-raise here to ensure __exit__ completes cleanly + except Exception as e: + logger.exception("Unexpected error during release: %s", e) + # We do not re-raise here to ensure __exit__ completes cleanly + + def __del__(self, _warnings=warnings) -> None: + """Finalizer: The Safety Net. + + Checks if the resource was leaked. If so, issues a ResourceWarning + and attempts a last-ditch cleanup. + """ + + if getattr(self, "pinner_id", 0) != 0 and not getattr( + self, "_is_released", False + ): + try: + warnings.warn( + "Unclosed SpannerLib Message" + f"(pinner_id={self.pinner_id})", + ResourceWarning, + ) + except Exception: + pass + + try: + self.release() + except Exception: + pass diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib.py new file mode 100644 index 00000000..e3063f22 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib.py @@ -0,0 +1,581 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Module for interacting with the SpannerLib shared library.""" + +from contextlib import contextmanager +import ctypes +from importlib.resources import as_file, files +import logging +from pathlib import Path +import platform +from typing import ClassVar, Final, Generator, Optional + +from .errors import SpannerLibError +from .message import Message +from .types import GoSlice, GoString + +logger = logging.getLogger(__name__) + +CURRENT_PACKAGE: Final[str] = __package__ or "google.cloud.spannerlib.internal" +LIB_DIR_NAME: Final[str] = "lib" + + +@contextmanager +def get_shared_library( + library_name: str, subdirectory: str = LIB_DIR_NAME +) -> Generator[Path, None, None]: + """ + Context manager to yield a physical path to a shared library. + + Compatible with Python 3.8+ and Zip/Egg imports. + """ + try: + + package_root = files(CURRENT_PACKAGE) + resource_ref = package_root.joinpath(subdirectory, library_name) + + with as_file(resource_ref) as lib_path: + yield lib_path + + except (ImportError, TypeError) as e: + raise FileNotFoundError( + f"Could not resolve resource '{library_name}'" + f" in '{CURRENT_PACKAGE}'" + ) from e + + +class SpannerLib: + """ + A Singleton wrapper for the SpannerLib shared library. + """ + + _lib_handle: ClassVar[Optional[ctypes.CDLL]] = None + _instance: ClassVar[Optional["SpannerLib"]] = None + + def __new__(cls) -> "SpannerLib": + if cls._instance is None: + cls._instance = super(SpannerLib, cls).__new__(cls) + cls._instance._initialize() + return cls._instance + + def _initialize(self) -> None: + """ + Internal initialization logic. Called only once by __new__. + """ + if SpannerLib._lib_handle is not None: + return + + self._load_library() + + def _load_library(self) -> None: + """ + Internal method to load the shared library. + """ + filename: str = SpannerLib._get_lib_filename() + + with get_shared_library(filename) as lib_path: + # Sanity check: Ensure the file actually exists + # before handing to ctypes + if not lib_path.exists(): + raise SpannerLibError( + f"Library path does not exist: {lib_path}" + ) + + try: + # ctypes requires a string path + SpannerLib._lib_handle = ctypes.CDLL(str(lib_path)) + self._configure_signatures() + + logger.debug( + "Successfully loaded shared library: %s", str(lib_path) + ) + + except (OSError, FileNotFoundError) as e: + logger.critical( + "Failed to load native library at %s", str(lib_path) + ) + SpannerLib._lib_handle = None + raise SpannerLibError( + f"Could not load native dependency '{lib_path.name}': {e}" + ) from e + + @staticmethod + def _get_lib_filename() -> str: + """ + Returns the filename of the shared library based on the OS. + """ + filename: str = "" + + system_name = platform.system() + + if system_name == "Windows": + filename = "spannerlib.dll" + elif system_name == "Darwin": + filename = "spannerlib.dylib" + elif system_name == "Linux": + filename = "spannerlib.so" + else: + raise SpannerLibError( + f"Unsupported operating system: {system_name}" + ) + return filename + + def _configure_signatures(self) -> None: + """ + Defines the argument and return types for the C functions. + """ + lib = SpannerLib._lib_handle + if lib is None: + raise SpannerLibError( + "Library handle is None during configuration." + ) + + try: + # 1. Release + # Corresponds to: + # GoInt32 Release(GoInt64 pinnerId); + if hasattr(lib, "Release"): + lib.Release.argtypes = [ctypes.c_longlong] + lib.Release.restype = ctypes.c_int32 + + # 2. CreatePool + # Corresponds to: + # CreatePool_return CreatePool(GoString connectionString); + if hasattr(lib, "CreatePool"): + lib.CreatePool.argtypes = [GoString] + lib.CreatePool.restype = Message + + # 3. ClosePool + # Corresponds to: + # ClosePool_return ClosePool(GoInt64 poolId); + if hasattr(lib, "ClosePool"): + lib.ClosePool.argtypes = [ctypes.c_longlong] + lib.ClosePool.restype = Message + + # 4. CreateConnection + # Corresponds to: + # CreateConnection_return CreateConnection(GoInt64 poolId); + if hasattr(lib, "CreateConnection"): + lib.CreateConnection.argtypes = [ctypes.c_longlong] + lib.CreateConnection.restype = Message + + # 5. CloseConnection + # Corresponds to: + # CloseConnection_return CloseConnection(GoInt64 poolId, + # GoInt64 connId); + if hasattr(lib, "CloseConnection"): + lib.CloseConnection.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.CloseConnection.restype = Message + + # 6. Execute + # Corresponds to: + # Execute_return Execute(GoInt64 poolId, GoInt64 connectionId, + # GoSlice statement); + if hasattr(lib, "Execute"): + lib.Execute.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + GoSlice, + ] + lib.Execute.restype = Message + + # 7. ExecuteBatch + # Corresponds to: + # ExecuteBatch_return ExecuteBatch(GoInt64 poolId, + # GoInt64 connectionId, GoSlice statements); + if hasattr(lib, "ExecuteBatch"): + lib.ExecuteBatch.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + GoSlice, + ] + lib.ExecuteBatch.restype = Message + + # 8. Next + # Corresponds to: + # Next_return Next(GoInt64 poolId, GoInt64 connId, + # GoInt64 rowsId, GoInt32 numRows, GoInt32 encodeRowOption); + if hasattr(lib, "Next"): + lib.Next.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_int32, + ctypes.c_int32, + ] + lib.Next.restype = Message + + # 9. CloseRows + # Corresponds to: + # CloseRows_return CloseRows(GoInt64 poolId, GoInt64 connId, + # GoInt64 rowsId); + if hasattr(lib, "CloseRows"): + lib.CloseRows.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.CloseRows.restype = Message + + # 10. Metadata + # Corresponds to: + # Metadata_return Metadata(GoInt64 poolId, GoInt64 connId, + # GoInt64 rowsId); + if hasattr(lib, "Metadata"): + lib.Metadata.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.Metadata.restype = Message + + # 11. ResultSetStats + # Corresponds to: + # ResultSetStats_return ResultSetStats(GoInt64 poolId, + # GoInt64 connId, GoInt64 rowsId); + if hasattr(lib, "ResultSetStats"): + lib.ResultSetStats.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.ResultSetStats.restype = Message + + # 12. BeginTransaction + # Corresponds to: + # BeginTransaction_return BeginTransaction(GoInt64 poolId, + # GoInt64 connectionId, GoSlice txOpts); + if hasattr(lib, "BeginTransaction"): + lib.BeginTransaction.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + GoSlice, + ] + lib.BeginTransaction.restype = Message + + # 13. Commit + # Corresponds to: + # Commit_return Commit(GoInt64 poolId, GoInt64 connectionId); + if hasattr(lib, "Commit"): + lib.Commit.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.Commit.restype = Message + + # 14. Rollback + # Corresponds to: + # Rollback_return Rollback(GoInt64 poolId, GoInt64 connectionId); + if hasattr(lib, "Rollback"): + lib.Rollback.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.Rollback.restype = Message + + # 15. WriteMutations + # Corresponds to: + # WriteMutations_return WriteMutations(GoInt64 poolId, + # GoInt64 connectionId, GoSlice mutationsBytes); + if hasattr(lib, "WriteMutations"): + lib.WriteMutations.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + GoSlice, + ] + lib.WriteMutations.restype = Message + + except AttributeError as e: + raise SpannerLibError( + f"Symbol missing in native library: {e}" + ) from e + + @property + def lib(self) -> ctypes.CDLL: + """Returns the loaded shared library handle.""" + if self._lib_handle is None: + raise SpannerLibError( + "SpannerLib has not been initialized correctly." + ) + return self._lib_handle + + def release(self, handle: int) -> int: + """Calls the Release function from the shared library. + + Args: + handle: The handle to release. + + Returns: + int: The result of the release operation. + """ + return self.lib.Release(ctypes.c_longlong(handle)) + + def create_pool(self, conn_str: str) -> Message: + """Calls the CreatePool function from the shared library. + + Args: + conn_str: The connection string. + + Returns: + Message: The result containing the pool handle. + """ + go_str = GoString.from_str(conn_str) + msg = self.lib.CreatePool(go_str) + return msg.bind_library(self) + + def close_pool(self, pool_handle: int) -> Message: + """Calls the ClosePool function from the shared library. + + Args: + pool_handle: The pool ID. + + Returns: + Message: The result of the close operation. + """ + msg = self.lib.ClosePool(ctypes.c_longlong(pool_handle)) + return msg.bind_library(self) + + def create_connection(self, pool_handle: int) -> Message: + """Calls the CreateConnection function from the shared library. + + Args: + pool_handle: The pool ID. + + Returns: + Message: The result containing the connection handle. + """ + msg = self.lib.CreateConnection(ctypes.c_longlong(pool_handle)) + return msg.bind_library(self) + + def close_connection(self, pool_handle: int, conn_handle: int) -> Message: + """Calls the CloseConnection function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + + Returns: + Message: The result of the close operation. + """ + msg = self.lib.CloseConnection( + ctypes.c_longlong(pool_handle), ctypes.c_longlong(conn_handle) + ) + return msg.bind_library(self) + + def execute( + self, pool_handle: int, conn_handle: int, request: bytes + ) -> Message: + """Calls the Execute function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + request: The serialized ExecuteSqlRequest request. + + Returns: + Message: The result of the execution. + """ + go_slice = GoSlice.from_bytes(request) + msg = self.lib.Execute( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + go_slice, + ) + return msg.bind_library(self) + + def execute_batch( + self, pool_handle: int, conn_handle: int, request: bytes + ) -> Message: + """Calls the ExecuteBatch function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + request: The serialized ExecuteBatchDmlRequest request. + + Returns: + Message: The result of the execution. + """ + go_slice = GoSlice.from_bytes(request) + msg = self.lib.ExecuteBatch( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + go_slice, + ) + return msg.bind_library(self) + + def next( + self, + pool_handle: int, + conn_handle: int, + rows_handle: int, + num_rows: int, + encode_row_option: int, + ) -> Message: + """Calls the Next function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + rows_handle: The rows ID. + num_rows: The number of rows to fetch. + encode_row_option: Option for row encoding. + + Returns: + Message: The result containing the rows. + """ + msg = self.lib.Next( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + ctypes.c_longlong(rows_handle), + ctypes.c_int32(num_rows), + ctypes.c_int32(encode_row_option), + ) + return msg.bind_library(self) + + def close_rows( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the CloseRows function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + rows_handle: The rows ID. + + Returns: + Message: The result of the close operation. + """ + msg = self.lib.CloseRows( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + ctypes.c_longlong(rows_handle), + ) + return msg.bind_library(self) + + def metadata( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the Metadata function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + rows_handle: The rows ID. + + Returns: + Message: The result containing the metadata. + """ + msg = self.lib.Metadata( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + ctypes.c_longlong(rows_handle), + ) + return msg.bind_library(self) + + def result_set_stats( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the ResultSetStats function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + rows_handle: The rows ID. + + Returns: + Message: The result containing the stats. + """ + msg = self.lib.ResultSetStats( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + ctypes.c_longlong(rows_handle), + ) + return msg.bind_library(self) + + def begin_transaction( + self, pool_handle: int, conn_handle: int, tx_opts: bytes + ) -> Message: + """Calls the BeginTransaction function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + tx_opts: The serialized TransactionOptions. + + Returns: + Message: The result of the transaction begin. + """ + go_slice = GoSlice.from_bytes(tx_opts) + msg = self.lib.BeginTransaction( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + go_slice, + ) + return msg.bind_library(self) + + def commit(self, pool_handle: int, conn_handle: int) -> Message: + """Calls the Commit function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + + Returns: + Message: The result of the commit. + """ + msg = self.lib.Commit( + ctypes.c_longlong(pool_handle), ctypes.c_longlong(conn_handle) + ) + return msg.bind_library(self) + + def rollback(self, pool_handle: int, conn_handle: int) -> Message: + """Calls the Rollback function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + + Returns: + Message: The result of the rollback. + """ + msg = self.lib.Rollback( + ctypes.c_longlong(pool_handle), ctypes.c_longlong(conn_handle) + ) + return msg.bind_library(self) + + def write_mutations( + self, pool_handle: int, conn_handle: int, request: bytes + ) -> Message: + """Calls the WriteMutations function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + request: The serialized Mutation request. + (BatchWriteRequest.MutationGroup) + + Returns: + Message: The result of the write operation. + """ + go_slice = GoSlice.from_bytes(request) + msg = self.lib.WriteMutations( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + go_slice, + ) + return msg.bind_library(self) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib_protocol.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib_protocol.py new file mode 100644 index 00000000..b2962d89 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/spannerlib_protocol.py @@ -0,0 +1,106 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Protocol defining the expected interface for the Spanner library.""" +from typing import Protocol, runtime_checkable + +from .message import Message + + +@runtime_checkable +class SpannerLibProtocol(Protocol): + """ + Protocol defining the expected interface for the Spanner library + dependency. + """ + + def release(self, handle: int) -> int: + """Calls the Release function from the shared library.""" + ... + + def create_pool(self, conn_str: str) -> "Message": + """Calls the CreatePool function from the shared library.""" + ... + + def close_pool(self, pool_handle: int) -> "Message": + """Calls the ClosePool function from the shared library.""" + ... + + def create_connection(self, pool_handle: int) -> Message: + """Calls the CreateConnection function from the shared library.""" + ... + + def close_connection(self, pool_handle: int, conn_handle: int) -> Message: + """Calls the CloseConnection function from the shared library.""" + ... + + def execute( + self, pool_handle: int, conn_handle: int, request: bytes + ) -> Message: + """Calls the Execute function from the shared library.""" + ... + + def execute_batch( + self, pool_handle: int, conn_handle: int, request: bytes + ) -> Message: + """Calls the ExecuteBatch function from the shared library.""" + ... + + def next( + self, + pool_handle: int, + conn_handle: int, + rows_handle: int, + num_rows: int, + encode_row_option: int, + ) -> Message: + """Calls the Next function from the shared library.""" + ... + + def close_rows( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the CloseRows function from the shared library.""" + ... + + def metadata( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the Metadata function from the shared library.""" + ... + + def result_set_stats( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the ResultSetStats function from the shared library.""" + ... + + def begin_transaction( + self, pool_handle: int, conn_handle: int, tx_opts: bytes + ) -> Message: + """Calls the BeginTransaction function from the shared library.""" + ... + + def commit(self, pool_handle: int, conn_handle: int) -> Message: + """Calls the Commit function from the shared library.""" + ... + + def rollback(self, pool_handle: int, conn_handle: int) -> Message: + """Calls the Rollback function from the shared library.""" + ... + + def write_mutations( + self, pool_handle: int, conn_handle: int, request: bytes + ) -> Message: + """Calls the WriteMutations function from the shared library.""" + ... diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/types.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/types.py new file mode 100644 index 00000000..877cf543 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/types.py @@ -0,0 +1,169 @@ +# Copyright 2025 Google LLC +# +# 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. +"""CTypes definitions for interacting with the Go library.""" +import ctypes +import logging +from typing import Optional + +# Configure logger +logger = logging.getLogger(__name__) + + +def to_bytes(msg: ctypes.c_void_p, len: ctypes.c_int32) -> bytes: + """Converts shared lib msg to a bytes.""" + return ctypes.string_at(msg, len) + + +class GoString(ctypes.Structure): + """Represents a Go string for C interop. + + This structure maps to the standard Go string header: + struct String { byte* str; int len; }; + + Attributes: + p (ctypes.c_char_p): Pointer to the first byte of the string data. + n (ctypes.c_int64): Length of the string. + """ + + _fields_ = [ + ("p", ctypes.c_char_p), + ("n", ctypes.c_int64), + ] + + def __str__(self) -> str: + """Decodes the GoString back to a Python string.""" + if not self.p or self.n == 0: + return "" + # We must specify the length to read exactly n bytes, as Go strings + # are not null-terminated. + return ctypes.string_at(self.p, self.n).decode("utf-8") + + @classmethod + def from_str(cls, s: Optional[str]) -> "GoString": + """Creates a GoString from a Python string safely. + + CRITICAL: This method attaches the encoded bytes to the structure + instance to prevent Python's Garbage Collector from freeing the + memory while Go is using it. + + Args: + s (str): The Python string. + + Returns: + GoString: The C-compatible structure. + """ + if s is None: + return cls(None, 0) + + try: + encoded_s = s.encode("utf-8") + except UnicodeError as e: + logger.error("Failed to encode string for Go interop: %s", e) + raise + + # Create the structure instance + instance = cls(encoded_s, len(encoded_s)) + + # Monkey-patch the bytes object onto the instance to keep the reference + # alive. This prevents the GC from reaping 'encoded_s' while 'instance' + # exists. + setattr(instance, "_keep_alive_ref", encoded_s) + + return instance + + +class GoSlice(ctypes.Structure): + """Represents a Go slice for C interop. + + This structure maps to the standard Go slice header: + struct Slice { void* data; int64 len; int64 cap; }; + + Attributes: + data (ctypes.c_void_p): Pointer to the first element of the slice. + len (ctypes.c_longlong): Length of the slice. + cap (ctypes.c_longlong): Capacity of the slice. + """ + + _fields_ = [ + ("data", ctypes.c_void_p), + ("len", ctypes.c_longlong), + ("cap", ctypes.c_longlong), + ] + + @classmethod + def from_str(cls, s: Optional[str]) -> "GoSlice": + """Converts a Python string to a GoSlice (byte slice). + + Args: + s (str): The Python string to convert. + + Returns: + GoSlice: The C-compatible structure representing a []byte. + """ + if s is None: + return cls(None, 0, 0) + + encoded_s = s.encode("utf-8") + n = len(encoded_s) + + # Create a C-compatible mutable buffer from the bytes. + # Note: create_string_buffer creates a mutable copy. + # This is safe because: + # 1. It matches Go's []byte which is mutable. + # 2. It isolates the original Python object from modification. + buffer = ctypes.create_string_buffer(encoded_s) + + # Create the GoSlice + instance = cls( + data=ctypes.cast(buffer, ctypes.c_void_p), + # For a new slice from a string, len and cap are the same + len=n, + cap=n, + ) + + # Keep a reference to the buffer to prevent garbage collection + setattr(instance, "_keep_alive_ref", buffer) + + return instance + + @classmethod + def from_bytes(cls, b: bytes) -> "GoSlice": + """Converts Python bytes to a GoSlice (byte slice). + + Args: + b (bytes): The Python bytes to convert. + + Returns: + GoSlice: The C-compatible structure representing a []byte. + """ + n = len(b) + + # Create a C-compatible mutable buffer from the bytes. + # Note: create_string_buffer creates a mutable copy. + # This is safe because: + # 1. It matches Go's []byte which is mutable. + # 2. It isolates the original Python object from modification. + buffer = ctypes.create_string_buffer(b) + + # Create the GoSlice + instance = cls( + data=ctypes.cast(buffer, ctypes.c_void_p), + len=n, + cap=n, + ) + + # Keep a reference to the buffer to prevent garbage collection + setattr(instance, "_keep_alive_ref", buffer) + + return instance diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/pool.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/pool.py new file mode 100644 index 00000000..39e67b1a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/pool.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Module for managing Spanner connection pools.""" +import logging + +from .abstract_library_object import AbstractLibraryObject +from .connection import Connection +from .internal.errors import SpannerLibError +from .internal.spannerlib import SpannerLib + +logger = logging.getLogger(__name__) + + +class Pool(AbstractLibraryObject): + """Manages a pool of connections to the Spanner database. + + This class wraps the connection pool handle from the underlying Go library, + providing methods to create connections and manage the pool lifecycle. + """ + + def _close_lib_object(self) -> None: + """Internal method to close the pool in the Go library.""" + try: + logger.debug("Closing pool ID: %d", self.oid) + # Call the Go library function to close the pool. + with self.spannerlib.close_pool(self.oid) as msg: + msg.raise_if_error() + logger.debug("Pool ID: %d closed", self.oid) + except SpannerLibError: + logger.exception("SpannerLib error closing pool ID: %d", self.oid) + raise + except Exception as e: + logger.exception("Unexpected error closing pool ID: %d", self.oid) + raise SpannerLibError(f"Unexpected error during close: {e}") from e + + @classmethod + def create_pool(cls, connection_string: str) -> "Pool": + """Creates a new connection pool. + + Args: + connection_string (str): The connection string for the database. + + Returns: + Pool: A new Pool object. + """ + logger.debug( + "Creating pool with connection string: %s", + connection_string, + ) + try: + lib = SpannerLib() + # Call the Go library function to create a pool. + with lib.create_pool(connection_string) as msg: + msg.raise_if_error() + pool = cls(lib, msg.object_id) + logger.debug("Pool created with ID: %d", pool.oid) + except SpannerLibError: + logger.exception("Failed to create pool") + raise + except Exception as e: + logger.exception("Unexpected error interacting with Go library") + raise SpannerLibError(f"Unexpected error: {e}") from e + return pool + + def create_connection(self) -> Connection: + """ + Creates a new connection from the pool. + + Returns: + Connection: A new Connection object. + + Raises: + SpannerLibError: If the pool is closed. + """ + if self.closed: + logger.error("Attempted to create connection from a closed pool") + raise SpannerLibError("Pool is closed") + logger.debug("Creating connection from pool ID: %d", self.oid) + # Call the Go library function to create a connection. + with self.spannerlib.create_connection(self.oid) as msg: + msg.raise_if_error() + + logger.debug( + "Connection created with ID: %d from pool ID: %d", + msg.object_id, + self.oid, + ) + return Connection(msg.object_id, self) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/rows.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/rows.py new file mode 100644 index 00000000..d88f52ee --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/rows.py @@ -0,0 +1,176 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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 ctypes +import logging +from typing import TYPE_CHECKING + +from google.cloud.spanner_v1 import ResultSetMetadata, ResultSetStats +from google.protobuf.struct_pb2 import ListValue + +from .abstract_library_object import AbstractLibraryObject +from .internal.errors import SpannerLibError + +if TYPE_CHECKING: + from .connection import Connection + from .pool import Pool + +logger = logging.getLogger(__name__) + + +class Rows(AbstractLibraryObject): + """Represents a result set from the Spanner database.""" + + def __init__(self, oid: int, conn: "Connection") -> None: + """Initializes a Rows object. + + Args: + oid (int): The object ID (handle) of the row in the Go library. + conn (Connection): The Connection object from which this row was + created. + """ + super().__init__(conn.pool.spannerlib, oid) + self._conn = conn + + @property + def pool(self) -> "Pool": + """Returns the pool associated with this rows.""" + return self._conn.pool + + @property + def conn(self) -> "Connection": + """Returns the connection associated with this rows.""" + return self._conn + + def _close_lib_object(self) -> None: + """Internal method to close the rows in the Go library.""" + try: + logger.debug("Closing rows ID: %d", self.oid) + # Call the Go library function to close the rows. + with self.spannerlib.close_rows( + self.pool.oid, self.conn.oid, self.oid + ) as msg: + msg.raise_if_error() + logger.debug("Rows ID: %d closed", self.oid) + except SpannerLibError: + logger.exception("SpannerLib error closing rows ID: %d", self.oid) + raise + except Exception as e: + logger.exception("Unexpected error closing rows ID: %d", self.oid) + raise SpannerLibError(f"Unexpected error during close: {e}") from e + + def next(self) -> ListValue: + """Fetches the next row(s) from the result set. + + Returns: + A protobuf `ListValue` object representing the next row. + The values within the row are also protobuf `Value` objects. + Returns None if no more rows are available. + + Raises: + SpannerLibError: If the Rows object is closed or if parsing fails. + SpannerLibraryError: If the Go library call fails. + """ + if self.closed: + raise SpannerLibError("Rows object is closed.") + + logger.debug("Fetching next row for Rows ID: %d", self.oid) + with self.spannerlib.next( + self.pool.oid, + self.conn.oid, + self.oid, + 1, + 1, + ) as msg: + msg.raise_if_error() + if msg.msg_len > 0 and msg.msg: + try: + proto_bytes = ctypes.string_at(msg.msg, msg.msg_len) + next_row = ListValue() + next_row.ParseFromString(proto_bytes) + return next_row + except Exception as e: + logger.error( + "Failed to decode/parse row data protobuf: %s", e + ) + raise SpannerLibError(f"Failed to get next row(s): {e}") + else: + # Assuming no message means no more rows + logger.debug("No more rows...") + return None + + def metadata(self) -> ResultSetMetadata: + """Retrieves the metadata for the result set. + + Returns: + ResultSetMetadata object containing the metadata. + """ + if self.closed: + raise SpannerLibError("Rows object is closed.") + + logger.debug("Getting metadata for Rows ID: %d", self.oid) + with self.spannerlib.metadata( + self.pool.oid, self.conn.oid, self.oid + ) as msg: + msg.raise_if_error() + if msg.msg_len > 0 and msg.msg: + try: + proto_bytes = ctypes.string_at(msg.msg, msg.msg_len) + return ResultSetMetadata.deserialize(proto_bytes) + except Exception as e: + logger.error( + "Failed to decode/parse metadata protobuf: %s", e + ) + raise SpannerLibError(f"Failed to get metadata: {e}") + return ResultSetMetadata() + + def result_set_stats(self) -> ResultSetStats: + """Retrieves the result set statistics. + + Returns: + ResultSetStats object containing the statistics. + """ + if self.closed: + raise SpannerLibError("Rows object is closed.") + + logger.debug("Getting ResultSetStats for Rows ID: %d", self.oid) + with self.spannerlib.result_set_stats( + self.pool.oid, self.conn.oid, self.oid + ) as msg: + msg.raise_if_error() + if msg.msg_len > 0 and msg.msg: + try: + proto_bytes = ctypes.string_at(msg.msg, msg.msg_len) + return ResultSetStats.deserialize(proto_bytes) + except Exception as e: + logger.error( + "Failed to decode/parse ResultSetStats protobuf: %s", e + ) + raise SpannerLibError(f"Failed to get ResultSetStats: {e}") + return ResultSetStats() + + def update_count(self) -> int: + """Retrieves the update count. + + Returns: + int representing the update count. + """ + stats = self.result_set_stats() + + if stats._pb.WhichOneof("row_count") == "row_count_exact": + return stats.row_count_exact + if stats._pb.WhichOneof("row_count") == "row_count_lower_bound": + return stats.row_count_lower_bound + + return -1 diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index e3e9bb37..f3abfca9 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -48,6 +48,7 @@ STANDARD_DEPENDENCIES = [ "google-cloud-spanner", + "importlib_resources", ] UNIT_TEST_STANDARD_DEPENDENCIES = [ @@ -146,6 +147,8 @@ def system(session): "Credentials or emulator host must be set via environment variable" ) + copy_artifacts(session) + session.install(*STANDARD_DEPENDENCIES, *SYSTEM_TEST_STANDARD_DEPENDENCIES) session.install("-e", ".") diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml index fcad4576..422fa96f 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml @@ -30,6 +30,7 @@ classifiers = [ ] dependencies = [ "google-cloud-spanner", + "importlib_resources; python_version < '3.9'", ] [project.optional-dependencies] diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt b/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt index d08e05bd..c599ee7b 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt @@ -1,2 +1,3 @@ nox==2025.11.12 setuptools>=68.0 +importlib_resources diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/README.md new file mode 100644 index 00000000..3b812c47 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/README.md @@ -0,0 +1,31 @@ +# Spannerlib-Python System Tests + +## Setup + +## Emulator +To run these E2E tests against a Cloud Spanner Emulator: + +1. Start the emulator: gcloud emulators spanner start +```shell +docker pull gcr.io/cloud-spanner-emulator/emulator + +docker run -p 9010:9010 -p 9020:9020 -d gcr.io/cloud-spanner-emulator/emulator +``` +2. Set the environment variable: +```shell +export SPANNER_EMULATOR_HOST=localhost:9010 +``` +3. Create a test instance and database in the emulator: +```shell +gcloud spanner instances create test-instance --config=emulator-config --description="Test Instance" --nodes=1 + +gcloud spanner databases create test-db --instance=test-instance +``` +4. Run the tests: +```shell +python3 -m unittest tests/system/test_pool.py +``` +-or- +```shell +nox -s system +``` diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_helper.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_helper.py new file mode 100644 index 00000000..2f0046cb --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_helper.py @@ -0,0 +1,63 @@ +# Copyright 2025 Google LLC +# +# 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 system tests.""" + +import os + +SPANNER_EMULATOR_HOST = os.environ.get("SPANNER_EMULATOR_HOST") +TEST_ON_PROD = not bool(SPANNER_EMULATOR_HOST) + +if TEST_ON_PROD: + PROJECT_ID = os.environ.get("SPANNER_PROJECT_ID") + INSTANCE_ID = os.environ.get("SPANNER_INSTANCE_ID") + DATABASE_ID = os.environ.get("SPANNER_DATABASE_ID") + + if not PROJECT_ID or not INSTANCE_ID or not DATABASE_ID: + raise ValueError( + "SPANNER_PROJECT_ID, SPANNER_INSTANCE_ID, and SPANNER_DATABASE_ID " + "must be set when running tests on production." + ) +else: + PROJECT_ID = "test-project" + INSTANCE_ID = "test-instance" + DATABASE_ID = "test-db" + +PROD_TEST_CONNECTION_STRING = ( + f"projects/{PROJECT_ID}" + f"/instances/{INSTANCE_ID}" + f"/databases/{DATABASE_ID}" +) + +EMULATOR_TEST_CONNECTION_STRING = ( + f"{SPANNER_EMULATOR_HOST}" + f"projects/{PROJECT_ID}" + f"/instances/{INSTANCE_ID}" + f"/databases/{DATABASE_ID}" + "?autoConfigEmulator=true" +) + + +def setup_test_env() -> None: + if not TEST_ON_PROD: + print( + f"Set SPANNER_EMULATOR_HOST to " + f"{os.environ['SPANNER_EMULATOR_HOST']}" + ) + print(f"Using Connection String: {get_test_connection_string()}") + + +def get_test_connection_string() -> str: + if TEST_ON_PROD: + return PROD_TEST_CONNECTION_STRING + return EMULATOR_TEST_CONNECTION_STRING diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_setup_env.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_setup_env.py new file mode 100644 index 00000000..18a07501 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/_setup_env.py @@ -0,0 +1,79 @@ +# Copyright 2025 Google LLC +# +# 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 script to setup Spanner Emulator schema.""" + +from _helper import DATABASE_ID, INSTANCE_ID, PROJECT_ID, TEST_ON_PROD + +from google.cloud import spanner + + +def setup_env(): + if TEST_ON_PROD: + return + client = spanner.Client(project=PROJECT_ID) + instance = client.instance(INSTANCE_ID) + if not instance.exists(): + instance.create(configuration_name="emulator-config") + + database = instance.database(DATABASE_ID) + if not database.exists(): + database.create() + + # Create table + try: + op = database.update_ddl( + [ + """CREATE TABLE IF NOT EXISTS Singers ( + SingerId INT64 NOT NULL, + FirstName STRING(1024), + LastName STRING(1024), + ) PRIMARY KEY (SingerId)""" + ] + ) + op.result() + except Exception: + raise + print("Schema setup complete.") + + +def teardown(): + if TEST_ON_PROD: + return + + client = spanner.Client(project=PROJECT_ID) + instance = client.instance(INSTANCE_ID) + + if not instance.exists(): + return + + database = instance.database(DATABASE_ID) + if not database.exists(): + return + + # Drop table + try: + op = database.update_ddl(["DROP TABLE IF EXISTS Singers"]) + op.result() + except Exception: + raise + print("Schema teardown complete.") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "teardown": + teardown() + else: + setup_env() diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_connection.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_connection.py new file mode 100644 index 00000000..2bec4c7c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_connection.py @@ -0,0 +1,253 @@ +# Copyright 2025 Google LLC +# +# 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 os +import subprocess +import sys + +from google.cloud.spanner_v1 import ( + BatchWriteRequest, + ExecuteBatchDmlRequest, + ExecuteSqlRequest, + Mutation, +) +from google.protobuf.struct_pb2 import ListValue, Value +import pytest + +from google.cloud.spannerlib import Pool + +from ._helper import get_test_connection_string, setup_test_env + + +@pytest.fixture(scope="module", autouse=True) +def test_env(): + """Sets up the test environment for the module.""" + setup_test_env() + + +@pytest.fixture(scope="module") +def pool(): + """Creates a connection pool for the test module.""" + pool = Pool.create_pool(get_test_connection_string()) + yield pool + if pool: + pool.close() + + +@pytest.fixture() +def connection(pool): + """Creates a connection from the pool for each test.""" + conn = pool.create_connection() + yield conn + if conn: + conn.close() + + +@pytest.fixture(scope="module") +def setup_env(): + """Creates the enviroment for testing using a separate process.""" + if os.environ.get("SPANNERLIB_TEST_ON_PROD"): + return + + # Run setup script in a separate process to avoid gRPC conflicts + setup_script = os.path.join(os.path.dirname(__file__), "_setup_env.py") + subprocess.check_call([sys.executable, setup_script, "teardown"]) + subprocess.check_call([sys.executable, setup_script]) + yield + + +class TestConnectionE2E: + """End-to-end tests for the Connection class.""" + + def test_connection_creation(self, connection): + """Tests that a connection can be created.""" + assert connection is not None + assert connection.oid > 0 + + def test_execute_query(self, connection): + """Tests executing a simple SQL query.""" + sql = "SELECT 1" + request = ExecuteSqlRequest(sql=sql) + rows = connection.execute(request) + assert rows is not None + assert rows.oid > 0 + rows.close() + + def test_execute_batch_dml(self, connection, setup_env): + """Tests executing a batch of DML statements.""" + # Insert data using DML + insert_sql = ( + "INSERT INTO Singers (SingerId, FirstName, LastName) " + "VALUES (1, 'John', 'Doe')" + ) + # Update data + update_sql = "UPDATE Singers SET FirstName = 'Jane' WHERE SingerId = 1" + + request = ExecuteBatchDmlRequest( + statements=[ + ExecuteBatchDmlRequest.Statement(sql=insert_sql), + ExecuteBatchDmlRequest.Statement(sql=update_sql), + ] + ) + + response = connection.execute_batch(request) + assert response is not None + assert len(response.result_sets) == 2 + assert response.status.code == 0 + + def test_write_mutations(self, connection, setup_env): + """Tests writing mutations.""" + mutation = Mutation( + insert=Mutation.Write( + table="Singers", + columns=["SingerId", "FirstName", "LastName"], + values=[ + ListValue( + values=[ + Value(string_value="2"), + Value(string_value="Alice"), + Value(string_value="Smith"), + ] + ) + ], + ) + ) + + mutation_group = BatchWriteRequest.MutationGroup(mutations=[mutation]) + + response = connection.write_mutations(mutation_group) + assert response is not None + assert response.commit_timestamp is not None + + def test_transaction_commit(self, connection, setup_env): + """Tests a successful transaction commit.""" + connection.begin_transaction() + + # Insert data within transaction + sql = ( + "INSERT INTO Singers (SingerId, FirstName, LastName) " + "VALUES (10, 'Transaction', 'Commit')" + ) + request = ExecuteSqlRequest(sql=sql) + rows = connection.execute(request) + rows.close() + + commit_resp = connection.commit() + assert commit_resp is not None + assert commit_resp.commit_timestamp is not None + + # Verify data was committed + verify_sql = "SELECT FirstName FROM Singers WHERE SingerId = 10" + verify_req = ExecuteSqlRequest(sql=verify_sql) + verify_rows = connection.execute(verify_req) + row = verify_rows.next() + assert row is not None + assert row.values[0].string_value == "Transaction" + verify_rows.close() + + def test_transaction_rollback(self, connection, setup_env): + """Tests a successful transaction rollback.""" + connection.begin_transaction() + + # Insert data within transaction + sql = ( + "INSERT INTO Singers (SingerId, FirstName, LastName) " + "VALUES (20, 'Transaction', 'Rollback')" + ) + request = ExecuteSqlRequest(sql=sql) + rows = connection.execute(request) + rows.close() + + connection.rollback() + + # Verify data was NOT committed + verify_sql = "SELECT FirstName FROM Singers WHERE SingerId = 20" + verify_req = ExecuteSqlRequest(sql=verify_sql) + verify_rows = connection.execute(verify_req) + row = verify_rows.next() + assert row is None + verify_rows.close() + + def test_transaction_write_mutations(self, connection, setup_env): + """Tests writing mutations within a transaction.""" + connection.begin_transaction() + + mutation = Mutation( + insert=Mutation.Write( + table="Singers", + columns=["SingerId", "FirstName", "LastName"], + values=[ + ListValue( + values=[ + Value(string_value="30"), + Value(string_value="Mutation"), + Value(string_value="Transaction"), + ] + ) + ], + ) + ) + + mutation_group = BatchWriteRequest.MutationGroup(mutations=[mutation]) + + response = connection.write_mutations(mutation_group) + assert response is None + + # If write_mutations commits, this commit() might fail or do nothing. + # But if it buffers, commit() is needed. + commit_resp = connection.commit() + + assert commit_resp is not None + + # Verify + verify_sql = "SELECT FirstName FROM Singers WHERE SingerId = 30" + verify_req = ExecuteSqlRequest(sql=verify_sql) + verify_rows = connection.execute(verify_req) + row = verify_rows.next() + assert row is not None + assert row.values[0].string_value == "Mutation" + verify_rows.close() + + def test_transaction_write_mutations_rollback(self, connection, setup_env): + """Tests that mutations in a transaction can be rolled back.""" + connection.begin_transaction() + + mutation = Mutation( + insert=Mutation.Write( + table="Singers", + columns=["SingerId", "FirstName", "LastName"], + values=[ + ListValue( + values=[ + Value(string_value="40"), + Value(string_value="Mutation"), + Value(string_value="Rollback"), + ] + ) + ], + ) + ) + + mutation_group = BatchWriteRequest.MutationGroup(mutations=[mutation]) + + response = connection.write_mutations(mutation_group) + assert response is None + connection.rollback() + + # Verify data was NOT committed + verify_sql = "SELECT FirstName FROM Singers WHERE SingerId = 40" + verify_req = ExecuteSqlRequest(sql=verify_sql) + verify_rows = connection.execute(verify_req) + row = verify_rows.next() + assert row is None + verify_rows.close() diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py deleted file mode 100644 index 3f4dfd11..00000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_placeholder.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2025 Google LLC -# -# 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 unittest - - -class TestPlaceholderE2E(unittest.TestCase): - """Placeholder for system tests.""" - - def test_none(self): - """Placeholder test method.""" - pass diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_pool.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_pool.py new file mode 100644 index 00000000..0d1c4cae --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_pool.py @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""System tests for the Pool class.""" +import pytest + +from google.cloud.spannerlib import Pool + +from ._helper import get_test_connection_string, setup_test_env + +# To run these E2E tests against a Cloud Spanner Emulator: +# 1. Start the emulator: gcloud emulators spanner start +# 2. Set the environment variable: export SPANNER_EMULATOR_HOST=localhost:9010 +# 3. Create a test instance and database in the emulator. +# 4. Run the tests: nox -s system-3.13 + + +@pytest.fixture(scope="module", autouse=True) +def test_env(): + """Sets up the test environment for the module.""" + setup_test_env() + + +class TestPoolE2E: + """End-to-end tests for the Pool class.""" + + def test_pool_creation_and_close(self) -> None: + """Test basic pool creation and explicit close.""" + pool = Pool.create_pool(get_test_connection_string()) + assert pool.oid is not None, "Pool ID should not be None" + assert not pool.closed, "Pool should not be closed initially" + pool.close() + assert pool.closed, "Pool should be closed" + # Test closing again is safe + pool.close() + assert pool.closed, "Pool should remain closed" + + def test_pool_context_manager(self) -> None: + """Test pool creation and closure using a context manager.""" + with Pool.create_pool(get_test_connection_string()) as pool: + assert pool.oid is not None + assert not pool.closed + assert pool.closed, "Pool should be closed after exiting with block" diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_rows.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_rows.py new file mode 100644 index 00000000..1cf02d1b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/system/test_rows.py @@ -0,0 +1,126 @@ +# Copyright 2025 Google LLC +# +# 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 os +import subprocess +import sys + +from google.cloud.spanner_v1 import ExecuteSqlRequest, TypeCode +import pytest + +from google.cloud.spannerlib import Pool + +from ._helper import get_test_connection_string, setup_test_env + + +@pytest.fixture(scope="module", autouse=True) +def test_env(): + """Sets up the test environment for the module.""" + setup_test_env() + + +@pytest.fixture(scope="module") +def pool(): + """Creates a connection pool for the test module.""" + pool = Pool.create_pool(get_test_connection_string()) + yield pool + if pool: + pool.close() + + +@pytest.fixture() +def connection(pool): + """Creates a connection from the pool for each test.""" + conn = pool.create_connection() + yield conn + if conn: + conn.close() + + +@pytest.fixture(scope="module") +def setup_env(): + """Creates the enviroment for testing using a separate process.""" + if os.environ.get("SPANNERLIB_TEST_ON_PROD"): + return + + # Run setup script in a separate process to avoid gRPC conflicts + setup_script = os.path.join(os.path.dirname(__file__), "_setup_env.py") + subprocess.check_call([sys.executable, setup_script, "teardown"]) + subprocess.check_call([sys.executable, setup_script]) + yield + + +class TestRowsE2E: + """End-to-end tests for the Rows class.""" + + def test_metadata(self, connection): + """Tests retrieving metadata from a result set.""" + sql = "SELECT 1 AS num" + request = ExecuteSqlRequest(sql=sql) + rows = connection.execute(request) + + try: + metadata = rows.metadata() + assert metadata is not None + assert len(metadata.row_type.fields) == 1 + field = metadata.row_type.fields[0] + assert field.name == "num" + assert field.type.code == TypeCode.INT64 + finally: + rows.close() + + def test_stats_and_update_count(self, connection): + """Tests retrieving result set stats and update count + from a DML statement.""" + import random + + singer_id = random.randint(1000, 100000) + sql = ( + "INSERT INTO Singers (SingerId, FirstName, LastName) " + + f"VALUES ({singer_id}, 'Stats', 'Test')" + ) + request = ExecuteSqlRequest(sql=sql) + rows = connection.execute(request) + + try: + stats = rows.result_set_stats() + assert stats is not None + assert stats.row_count_exact == 1 + + assert rows.update_count() == 1 + finally: + rows.close() + + def test_next(self, connection): + """Tests fetching rows using next().""" + sql = "SELECT 1 AS num UNION ALL SELECT 2 AS num ORDER BY num" + request = ExecuteSqlRequest(sql=sql) + rows = connection.execute(request) + + try: + # Fetch first row + row1 = rows.next() + assert row1 is not None + assert len(row1.values) == 1 + assert row1.values[0].string_value == "1" + + # Fetch second row + row2 = rows.next() + assert row2 is not None + assert len(row2.values) == 1 + assert row2.values[0].string_value == "2" + + # Fetch end of rows + assert rows.next() is None + finally: + rows.close() diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/__init__.py similarity index 76% rename from spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py rename to spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/__init__.py index 162b09e4..38e805ce 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_placeholder.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/__init__.py @@ -11,12 +11,3 @@ # 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 unittest - - -class TestPlaceholderUnit(unittest.TestCase): - """Placeholder for unit tests.""" - - def test_none(self): - """Placeholder test method.""" - pass diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_errors.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_errors.py new file mode 100644 index 00000000..e764fe7c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_errors.py @@ -0,0 +1,88 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Unit tests for Spanner error classes.""" +import pytest + +from google.cloud.spannerlib.internal import SpannerError # type: ignore +from google.cloud.spannerlib.internal import SpannerLibError # type: ignore + + +class TestSpannerErrors: + """Test suite for Spanner error classes.""" + + def test_spanner_error_inheritance(self) -> None: + """Test that SpannerError inherits from Exception.""" + assert issubclass(SpannerError, Exception) + + +class TestSpannerLibError: + """Test suite for SpannerLibError class.""" + + def test_spanner_lib_error_inheritance(self) -> None: + """Test that SpannerLibError inherits from SpannerError.""" + assert issubclass(SpannerLibError, SpannerError) + + def test_spanner_lib_error_init_with_code(self) -> None: + """Test SpannerLibError initialization with an error code.""" + msg = "Test error message" + code = 5 # NOT_FOUND + err = SpannerLibError(msg, code) + + assert err.message == msg + assert err.error_code == code + assert str(err) == f"[Err {code} (NOT_FOUND)] {msg}" + + def test_spanner_lib_error_init_with_unknown_code(self) -> None: + """Test SpannerLibError initialization with an unknown error code.""" + msg = "Test error message" + code = 101 + err = SpannerLibError(msg, code) + + assert err.message == msg + assert err.error_code == code + assert str(err) == f"[Err {code}] {msg}" + + def test_spanner_lib_error_init_without_code(self) -> None: + """Test SpannerLibError initialization without an error code.""" + msg = "Another test error" + err = SpannerLibError(msg) + + assert err.message == msg + assert err.error_code is None + assert str(err) == msg + + def test_spanner_lib_error_repr_with_code(self) -> None: + """Test the __repr__ method of SpannerLibError with an error code.""" + msg = "ABORTED" + code = 10 + err = SpannerLibError(msg, code) + expected_repr = f"" + assert repr(err) == expected_repr + + def test_spanner_lib_error_repr_without_code(self) -> None: + """Test the __repr__ method of SpannerLibError without an error code.""" + msg = "Repr test no code" + err = SpannerLibError(msg) + expected_repr = f"" + assert repr(err) == expected_repr + + def test_raise_spanner_error(self) -> None: + """Test that SpannerError can be raised and caught.""" + with pytest.raises(SpannerError): + raise SpannerError("Something went wrong") + + def test_raise_spanner_lib_error(self) -> None: + """Test that SpannerLibError can be raised and caught.""" + with pytest.raises(SpannerLibError): + raise SpannerLibError("Go library failed", 1) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_message.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_message.py new file mode 100644 index 00000000..fd55f921 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_message.py @@ -0,0 +1,218 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Unit tests for the Message structure and its behavior.""" +import ctypes +import gc +import logging +from unittest.mock import Mock +import warnings + +import pytest + +from google.cloud.spannerlib.internal import Message # type: ignore +from google.cloud.spannerlib.internal import SpannerLibError # type: ignore + +# --- Fixtures --- + + +@pytest.fixture +def mock_lib(): + """Creates a mock object mimicking the C-Library.""" + lib = Mock(spec=ctypes.CDLL) + # Mock the release function + lib.release = Mock() + return lib + + +@pytest.fixture +def raw_message_data(): + """Creates a raw C-string buffer for testing string decoding.""" + text = "Database connection timeout" + # We must keep this buffer alive for the duration of the test + # so the pointer remains valid. + c_buffer = ctypes.create_string_buffer(text.encode("utf-8")) + return c_buffer, len(text) + + +# --- Tests --- + + +class TestMessageLifecycle: + """Tests for the Message Lifecycle.""" + + def test_initialization(self): + """Test that the structure initializes with default zero values.""" + msg = Message() + assert msg.pinner_id == 0 + assert msg.error_code == 0 + assert msg.had_error is False + + def test_context_manager_auto_release(self, mock_lib): + """Verify the 'with' statement triggers the release function.""" + pinner_id = 12345 + + with Message() as msg: + msg.pinner_id = pinner_id + msg.bind_library(mock_lib) + # pylint: disable=protected-access + assert msg._is_released is False + + # After exit, release should have been called + assert msg._is_released is True # pylint: disable=protected-access + mock_lib.release.assert_called_once_with(pinner_id) + + def test_manual_release_idempotency(self, mock_lib): + """Verify calling release multiple times is safe (idempotent).""" + msg = Message() + msg.pinner_id = 999 + msg.bind_library(mock_lib) + + # First Call + msg.release() + assert msg._is_released is True # pylint: disable=protected-access + assert mock_lib.release.call_count == 1 + + # Second Call + msg.release() + assert mock_lib.release.call_count == 1 # Should not increase + + def test_release_without_binding_logs_critical(self, caplog): + """Verify that releasing without a library logs a CRITICAL error.""" + msg = Message() + msg.pinner_id = 555 + + # Note: We intentionally do NOT call bind_library() + + with caplog.at_level(logging.CRITICAL): + msg.release() + + assert "cannot be released" in caplog.text + assert "Library dependency was not injected" in caplog.text + # Ensure we marked it as released to prevent infinite retry loops + assert msg._is_released is True # pylint: disable=protected-access + + +class TestMessageErrorHandling: + """Tests for the Message Error Handling.""" + + def test_had_error_property(self): + """Tests the had_error property logic.""" + msg = Message() + msg.error_code = 0 + assert not msg.had_error + + msg.error_code = 1 + assert msg.had_error + + def test_raise_if_error_success(self): + """Should do nothing if error_code is 0.""" + msg = Message() + msg.error_code = 0 + # Should not raise + msg.raise_if_error() + + def test_raise_if_error_failure(self, raw_message_data): + """Should raise SpannerLibError if error_code > 0.""" + c_buffer, length = raw_message_data + + msg = Message() + msg.error_code = 500 + msg.msg = ctypes.cast(c_buffer, ctypes.c_void_p) + msg.msg_len = length + + with pytest.raises(SpannerLibError) as exc_info: + msg.raise_if_error() + + assert "Database connection timeout" in str(exc_info.value) + assert "[Err 500]" in str(exc_info.value) + + +class TestMessageStringDecoding: + """Tests for the Message String Decoding.""" + + def test_decode_valid_string(self, raw_message_data): + """Test decoding of a valid UTF-8 C-string.""" + c_buffer, length = raw_message_data + + msg = Message() + msg.msg = ctypes.cast(c_buffer, ctypes.c_void_p) + msg.msg_len = length + + assert msg.message == "Database connection timeout" + + def test_decode_empty_or_null(self): + """Test handling of empty or null message pointers.""" + msg = Message() + msg.msg = None + msg.msg_len = 0 + assert msg.message == "" + + def test_decode_invalid_utf8(self): + """Test handling of non-UTF8 bytes (garbage memory).""" + # Create a buffer with invalid UTF-8 bytes (0xFF is invalid) + bad_bytes = b"\xff\xff\xff" + c_buffer = ctypes.create_string_buffer(bad_bytes) + + msg = Message() + msg.msg = ctypes.cast(c_buffer, ctypes.c_void_p) + msg.msg_len = len(bad_bytes) + + assert msg.message == "" + + +class TestMessageSafetyNet: + """Tests for the Message Safety Net (__del__ warning).""" + + def test_del_warning_on_leak(self): + """ + Verify that __del__ emits a ResourceWarning if the object is + garbage collected without being released. + """ + # We need to suppress the actual stderr output from __del__ + # but catch the warning it emits. + + with pytest.warns(ResourceWarning, match="Unclosed SpannerLib Message"): + # 1. Create a leaky object (scope limited) + def create_leak(): + msg = Message() + msg.pinner_id = 777 + # Do NOT call release() or use context manager + return + + create_leak() + + # 2. Force Garbage Collection + # This forces __del__ to run immediately + gc.collect() + + def test_del_no_warning_if_clean(self): + """Verify no warning is issued if the object was properly released.""" + with warnings.catch_warnings(record=True) as recorded_warnings: + warnings.simplefilter("always") # Capture everything + + def create_clean(): + with Message() as msg: + msg.pinner_id = 888 + # release happens here automatically + + create_clean() + gc.collect() + + # Filter for our specific ResourceWarning + relevant = [ + w + for w in recorded_warnings + if "Unclosed SpannerLib Message" in str(w.message) + ] + assert len(relevant) == 0 diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_spannerlib.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_spannerlib.py new file mode 100644 index 00000000..d24a0fe3 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_spannerlib.py @@ -0,0 +1,413 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Unit tests for the SpannerLib class.""" +import ctypes +from pathlib import Path +from unittest import mock + +import pytest + +from google.cloud.spannerlib.internal import GoString # type: ignore +from google.cloud.spannerlib.internal import Message # type: ignore +from google.cloud.spannerlib.internal import SpannerLib # type: ignore +from google.cloud.spannerlib.internal import SpannerLibError # type: ignore + + +@pytest.fixture(autouse=True) +def reset_singleton(): + """Resets the SpannerLib singleton before and after each test.""" + # pylint: disable=protected-access + SpannerLib._instance = None + SpannerLib._lib_handle = None + yield + SpannerLib._instance = None + SpannerLib._lib_handle = None + # pylint: enable=protected-access + + +# FIX: Use 'name=' to separate the fixture name from the function name. +# This prevents 'redefined-outer-name' warnings because the function +# 'fixture_mock_cdll_cls' is different from the argument 'mock_cdll_cls'. +@pytest.fixture(name="mock_cdll_cls") +def fixture_mock_cdll_cls(): + """Mocks the ctypes.CDLL class constructor.""" + with mock.patch("ctypes.CDLL") as mock_cls: + mock_instance = mock.MagicMock() + mock_cls.return_value = mock_instance + yield mock_cls + + +@pytest.fixture(name="mock_lib_instance") +def fixture_mock_lib_instance(mock_cdll_cls): + """Returns the mocked library instance returned by CDLL().""" + return mock_cdll_cls.return_value + + +@pytest.fixture(name="mock_lib_path") +def fixture_mock_lib_path(): + """Mocks get_shared_library to yield a dummy path.""" + with mock.patch( + "google.cloud.spannerlib.internal.spannerlib.get_shared_library" + ) as mock_ctx: + mock_path = mock.MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.name = "mock_lib.so" + mock_path.__str__.return_value = "/abs/path/to/mock_lib.so" + + # Configure the context manager to yield mock_path + mock_ctx.return_value.__enter__.return_value = mock_path + yield mock_path + + +class TestSpannerlib: + """Tests for the SpannerLib class.""" + + def test_singleton_creation(self, mock_lib_path, mock_cdll_cls): + """Test that SpannerLib is a singleton.""" + lib1 = SpannerLib() + lib2 = SpannerLib() + assert lib1 is lib2 + mock_cdll_cls.assert_called_once() + + def test_initialize_success(self, mock_lib_path, mock_cdll_cls): + """Test successful initialization.""" + lib = SpannerLib() + + # Verify handle is the mock instance + # pylint: disable=protected-access + assert lib._lib_handle is mock_cdll_cls.return_value + + # Verify CDLL call + mock_cdll_cls.assert_called_once_with(str(mock_lib_path)) + + def test_initialize_load_failure(self, mock_lib_path): + """Test initialization failure when CDLL raises OSError.""" + with mock.patch("ctypes.CDLL", side_effect=OSError("Load failed")): + with pytest.raises( + SpannerLibError, match="Could not load native dependency" + ): + SpannerLib() + + def test_initialize_lib_not_found(self, mock_lib_path): + """Test initialization failure when the library file doesn't exist.""" + mock_lib_path.exists.return_value = False + with mock.patch("platform.system", return_value="Linux"): + with pytest.raises( + SpannerLibError, match="Library path does not exist:" + ): + SpannerLib() + + def test_get_lib_filename_linux(self): + """Test _get_lib_filename on Linux.""" + with mock.patch("platform.system", return_value="Linux"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "spannerlib.so" + + def test_get_lib_filename_darwin(self): + """Test _get_lib_filename on Darwin.""" + with mock.patch("platform.system", return_value="Darwin"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "spannerlib.dylib" + + def test_get_lib_filename_windows(self): + """Test _get_lib_filename on Windows.""" + with mock.patch("platform.system", return_value="Windows"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "spannerlib.dll" + + def test_get_lib_filename_unsupported_os(self): + """Test _get_lib_filename on an unsupported OS.""" + with mock.patch("platform.system", return_value="AmigaOS"): + with pytest.raises( + SpannerLibError, match="Unsupported operating system" + ): + # pylint: disable=protected-access + SpannerLib._get_lib_filename() + + def test_configure_signatures_missing_symbol( + self, mock_lib_path, mock_lib_instance + ): + """Test behavior if a required symbol is missing.""" + del mock_lib_instance.Release + + # Should not raise, but degrade gracefully + # (or crash later depending on logic) + SpannerLib() + + # Verify Release was skipped during configuration + with pytest.raises(AttributeError): + _ = mock_lib_instance.Release + + def test_lib_property_not_initialized(self): + """Test accessing lib property before initialization.""" + instance = object.__new__(SpannerLib) + with pytest.raises(SpannerLibError, match="not been initialized"): + _ = instance.lib + + def test_lib_property_initialized(self, mock_lib_path, mock_lib_instance): + """Test accessing lib property after initialization.""" + lib = SpannerLib() + assert lib.lib is mock_lib_instance + + def test_create_pool(self, mock_lib_path, mock_lib_instance): + """Test the create_pool method.""" + expected_message = Message() + mock_lib_instance.CreatePool.return_value = expected_message + + lib = SpannerLib() + config = "test_config" + result = lib.create_pool(config) + + assert result is expected_message + mock_lib_instance.CreatePool.assert_called_once() + + # Validate Argument Type + args, _ = mock_lib_instance.CreatePool.call_args + assert isinstance(args[0], GoString) + + # Validate Signature Setup + assert mock_lib_instance.CreatePool.argtypes == [GoString] + assert mock_lib_instance.CreatePool.restype == Message + + # Verify Library Binding + assert result._lib is lib + + def test_close_pool(self, mock_lib_path, mock_lib_instance): + """Test the close_pool method.""" + expected_message = Message() + mock_lib_instance.ClosePool.return_value = expected_message + + lib = SpannerLib() + lib.close_pool(456) + + # 1. Verify called exactly once + mock_lib_instance.ClosePool.assert_called_once() + + # 2. Get the arguments of that call + args, _ = mock_lib_instance.ClosePool.call_args + passed_arg = args[0] + + # 3. Assert it is the correct type and holds the correct value + assert isinstance(passed_arg, ctypes.c_longlong) + assert passed_arg.value == 456 + + # 4. Verify result + assert mock_lib_instance.ClosePool.argtypes == [ctypes.c_longlong] + assert mock_lib_instance.ClosePool.restype == Message + + # Verify Library Binding + assert lib.close_pool(456)._lib is lib + + def test_create_connection(self, mock_lib_path, mock_lib_instance): + """Test the create_connection method.""" + expected_message = Message() + mock_lib_instance.CreateConnection.return_value = expected_message + + lib = SpannerLib() + lib.create_connection(123) + + mock_lib_instance.CreateConnection.assert_called_once() + args, _ = mock_lib_instance.CreateConnection.call_args + assert args[0].value == 123 + + # Verify Library Binding + assert lib.create_connection(123)._lib is lib + + def test_close_connection(self, mock_lib_path, mock_lib_instance): + """Test the close_connection method.""" + expected_message = Message() + mock_lib_instance.CloseConnection.return_value = expected_message + + lib = SpannerLib() + lib.close_connection(123, 456) + + mock_lib_instance.CloseConnection.assert_called_once() + args, _ = mock_lib_instance.CloseConnection.call_args + assert args[0].value == 123 + assert args[1].value == 456 + + # Verify Library Binding + assert lib.close_connection(123, 456)._lib is lib + + def test_execute(self, mock_lib_path, mock_lib_instance): + """Test the execute method.""" + expected_message = Message() + mock_lib_instance.Execute.return_value = expected_message + + lib = SpannerLib() + lib.execute(123, 456, b"SQL_statement") + + mock_lib_instance.Execute.assert_called_once() + args, _ = mock_lib_instance.Execute.call_args + assert args[0].value == 123 + assert args[1].value == 456 + # However, we can check the type if we mocked GoSlice properly + # or check argtypes. + assert mock_lib_instance.Execute.argtypes[2] is type(args[2]) + + # Verify Library Binding + assert lib.execute(123, 456, b"SQL_statement")._lib is lib + + def test_execute_batch(self, mock_lib_path, mock_lib_instance): + """Test the execute_batch method.""" + expected_message = Message() + mock_lib_instance.ExecuteBatch.return_value = expected_message + + lib = SpannerLib() + lib.execute_batch(123, 456, b"serialized_statements") + + mock_lib_instance.ExecuteBatch.assert_called_once() + args, _ = mock_lib_instance.ExecuteBatch.call_args + assert args[0].value == 123 + assert args[1].value == 456 + + # Verify Library Binding + assert lib.execute_batch(123, 456, b"serialized_statements")._lib is lib + + def test_next(self, mock_lib_path, mock_lib_instance): + """Test the next method.""" + expected_message = Message() + mock_lib_instance.Next.return_value = expected_message + + lib = SpannerLib() + lib.next(1, 2, 3, 10, 1) + + mock_lib_instance.Next.assert_called_once() + args, _ = mock_lib_instance.Next.call_args + assert args[0].value == 1 + assert args[1].value == 2 + assert args[2].value == 3 + assert args[3].value == 10 + assert args[4].value == 1 + + # Verify Library Binding + assert lib.next(1, 2, 3, 10, 1)._lib is lib + + def test_close_rows(self, mock_lib_path, mock_lib_instance): + """Test the close_rows method.""" + expected_message = Message() + mock_lib_instance.CloseRows.return_value = expected_message + + lib = SpannerLib() + lib.close_rows(1, 2, 3) + + mock_lib_instance.CloseRows.assert_called_once() + args, _ = mock_lib_instance.CloseRows.call_args + assert args[0].value == 1 + assert args[1].value == 2 + assert args[2].value == 3 + + # Verify Library Binding + assert lib.close_rows(1, 2, 3)._lib is lib + + def test_metadata(self, mock_lib_path, mock_lib_instance): + """Test the metadata method.""" + expected_message = Message() + mock_lib_instance.Metadata.return_value = expected_message + + lib = SpannerLib() + lib.metadata(1, 2, 3) + + mock_lib_instance.Metadata.assert_called_once() + args, _ = mock_lib_instance.Metadata.call_args + assert args[0].value == 1 + assert args[1].value == 2 + assert args[2].value == 3 + + # Verify Library Binding + assert lib.metadata(1, 2, 3)._lib is lib + + def test_result_set_stats(self, mock_lib_path, mock_lib_instance): + """Test the result_set_stats method.""" + expected_message = Message() + mock_lib_instance.ResultSetStats.return_value = expected_message + + lib = SpannerLib() + lib.result_set_stats(1, 2, 3) + + mock_lib_instance.ResultSetStats.assert_called_once() + args, _ = mock_lib_instance.ResultSetStats.call_args + assert args[0].value == 1 + assert args[1].value == 2 + assert args[2].value == 3 + + # Verify Library Binding + assert lib.result_set_stats(1, 2, 3)._lib is lib + + def test_begin_transaction(self, mock_lib_path, mock_lib_instance): + """Test the begin_transaction method.""" + expected_message = Message() + mock_lib_instance.BeginTransaction.return_value = expected_message + + lib = SpannerLib() + lib.begin_transaction(1, 2, b"tx_opts") + + mock_lib_instance.BeginTransaction.assert_called_once() + args, _ = mock_lib_instance.BeginTransaction.call_args + assert args[0].value == 1 + assert args[1].value == 2 + + # Verify Library Binding + assert lib.begin_transaction(1, 2, b"tx_opts")._lib is lib + + def test_commit(self, mock_lib_path, mock_lib_instance): + """Test the commit method.""" + expected_message = Message() + mock_lib_instance.Commit.return_value = expected_message + + lib = SpannerLib() + lib.commit(1, 2) + + mock_lib_instance.Commit.assert_called_once() + args, _ = mock_lib_instance.Commit.call_args + assert args[0].value == 1 + assert args[1].value == 2 + + # Verify Library Binding + assert lib.commit(1, 2)._lib is lib + + def test_rollback(self, mock_lib_path, mock_lib_instance): + """Test the rollback method.""" + expected_message = Message() + mock_lib_instance.Rollback.return_value = expected_message + + lib = SpannerLib() + lib.rollback(1, 2) + + mock_lib_instance.Rollback.assert_called_once() + args, _ = mock_lib_instance.Rollback.call_args + assert args[0].value == 1 + assert args[1].value == 2 + + # Verify Library Binding + assert lib.rollback(1, 2)._lib is lib + + def test_write_mutations(self, mock_lib_path, mock_lib_instance): + """Test the write_mutations method.""" + expected_message = Message() + mock_lib_instance.WriteMutations.return_value = expected_message + + lib = SpannerLib() + lib.write_mutations(1, 2, b"mutations") + + mock_lib_instance.WriteMutations.assert_called_once() + args, _ = mock_lib_instance.WriteMutations.call_args + assert args[0].value == 1 + assert args[1].value == 2 + + # Verify Library Binding + assert lib.write_mutations(1, 2, b"mutations")._lib is lib diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_types.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_types.py new file mode 100644 index 00000000..6d391c06 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/internal/test_types.py @@ -0,0 +1,168 @@ +# Copyright 2025 Google LLC +# +# 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. +"""Unit tests for GoString type conversions.""" +import ctypes + +from google.cloud.spannerlib.internal.types import GoSlice # type: ignore +from google.cloud.spannerlib.internal.types import GoString # type: ignore +from google.cloud.spannerlib.internal.types import to_bytes # type: ignore + + +class TestGoString: + """Test suite for GoString structure and logic.""" + + def test_round_trip_conversion(self) -> None: + """Verifies that a string converts to GoString and back identically.""" + original = "Hello, World!" + go_str = GoString.from_str(original) + + # Verify internal structure + assert go_str.n == len(original.encode("utf-8")) + assert str(go_str) == original + + def test_utf8_byte_counting(self) -> None: + """Verifies that length is calculated in bytes, not characters. + + Go's 'len(string)' returns byte count. + Python's 'len(str)' returns char count. + We must match Go's behavior. + """ + # The fire emoji '🔥' is 1 character but 4 bytes in UTF-8. + text = "Hot 🔥" + go_str = GoString.from_str(text) + + # "Hot " (4 bytes) + "🔥" (4 bytes) = 8 bytes + expected_byte_len = 8 + + assert go_str.n == expected_byte_len + assert go_str.n != len(text) # length in chars is only 5 + assert str(go_str) == text + + def test_memory_safety_anchor(self) -> None: + """White-box test to ensure the keep-alive reference is attached.""" + text = "Ephemeral String" + go_str = GoString.from_str(text) + + # Check if the private attribute exists + assert hasattr(go_str, "_keep_alive_ref") + + # Ensure it holds the correct encoded bytes + assert getattr(go_str, "_keep_alive_ref") == text.encode("utf-8") + + def test_handle_none_and_empty(self) -> None: + """Ensures None and empty strings are handled gracefully.""" + # Empty string + empty = GoString.from_str("") + assert empty.n == 0 + assert str(empty) == "" + + # None input + none_str = GoString.from_str(None) + assert none_str.n == 0 + assert none_str.p is None + assert str(none_str) == "" + + +class TestGoSlice: + """Test suite for GoSlice structure and logic.""" + + def test_from_str_basic(self) -> None: + """Verifies that a string converts to GoSlice correctly.""" + s = "Hello, World!" + go_slice = GoSlice.from_str(s) + + encoded = s.encode("utf-8") + assert go_slice.len == len(encoded) + assert go_slice.cap == len(encoded) + assert go_slice.data is not None + + def test_utf8_handling(self) -> None: + """Verifies UTF-8 handling in GoSlice.""" + s = "Hot 🔥" + go_slice = GoSlice.from_str(s) + + encoded = s.encode("utf-8") + assert go_slice.len == len(encoded) + assert go_slice.cap == len(encoded) + + def test_memory_safety_anchor(self) -> None: + """White-box test to ensure the keep-alive reference is attached.""" + s = "Ephemeral String" + go_slice = GoSlice.from_str(s) + + # Check if the private attribute exists + assert hasattr(go_slice, "_keep_alive_ref") + + # Ensure it holds the correct encoded bytes + # create_string_buffer returns an object that has a .value attribute + # with the bytes + assert getattr(go_slice, "_keep_alive_ref").value == s.encode("utf-8") + + def test_from_bytes_basic(self) -> None: + """Verifies that bytes convert to GoSlice correctly.""" + b = b"Hello, Bytes!" + go_slice = GoSlice.from_bytes(b) + + assert go_slice.len == len(b) + assert go_slice.cap == len(b) + assert go_slice.data is not None + + def test_from_bytes_memory_safety(self) -> None: + """White-box test to ensure the keep-alive reference is attached + for bytes.""" + b = b"Ephemeral Bytes" + go_slice = GoSlice.from_bytes(b) + + # Check if the private attribute exists + assert hasattr(go_slice, "_keep_alive_ref") + + # Ensure it holds the correct bytes + assert getattr(go_slice, "_keep_alive_ref").value == b + + +class TestToBytes: + """Test suite for to_bytes function.""" + + def test_basic_conversion(self) -> None: + """Verifies that a c_void_p converts to bytes correctly.""" + original = b"Hello, World!" + # Create a buffer + buff = ctypes.create_string_buffer(original) + # Get pointer and length + ptr = ctypes.cast(buff, ctypes.c_void_p) + length = ctypes.c_int32(len(original)) + + result = to_bytes(ptr, length) + assert result == original + + def test_empty_conversion(self) -> None: + """Verifies that an empty buffer converts to empty bytes.""" + original = b"" + buff = ctypes.create_string_buffer(original) + ptr = ctypes.cast(buff, ctypes.c_void_p) + length = ctypes.c_int32(0) + + result = to_bytes(ptr, length) + assert result == original + + def test_partial_conversion(self) -> None: + """Verifies that we can read a subset of the buffer.""" + original = b"Hello, World!" + buff = ctypes.create_string_buffer(original) + ptr = ctypes.cast(buff, ctypes.c_void_p) + # Read only "Hello" + length = ctypes.c_int32(5) + + result = to_bytes(ptr, length) + assert result == b"Hello" diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_abstract_library_object.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_abstract_library_object.py new file mode 100644 index 00000000..a654e0c4 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_abstract_library_object.py @@ -0,0 +1,180 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Unit tests for AbstractLibraryObject behavior.""" +import gc +from typing import Generator +from unittest.mock import Mock + +import pytest + +from google.cloud.spannerlib.abstract_library_object import ( # type: ignore + AbstractLibraryObject, + ObjectClosedError, +) + +# --- 1. Concrete Implementation for Testing --- + + +class ConcreteTestObject(AbstractLibraryObject): + """ + A concrete implementation of the AbstractLibObject for testing purposes. + It spies on the _close_lib_object method to verify it was called. + """ + + def __init__(self, spannerlib, oid): + super().__init__(spannerlib, oid) + # Mocking the internal cleanup hook to track calls + self.cleanup_called_count = 0 + + def _close_lib_object(self) -> None: + """Implementation of the abstract method.""" + self.cleanup_called_count += 1 + + +# --- 2. Fixtures --- + + +@pytest.fixture +def mock_spanner_lib() -> Mock: + """Provides a mock for the SpannerLibProtocol.""" + return Mock(spec=["some_lib_method"]) + + +@pytest.fixture +def test_obj(mock_spanner_lib) -> Generator[ConcreteTestObject, None, None]: + """Creates a fresh ConcreteTestObject for each test.""" + obj = ConcreteTestObject(mock_spanner_lib, oid=123) + yield obj + # Teardown: ensure we don't leave dangling resources in tests + try: + obj.close() + except ObjectClosedError: + pass + + +# --- 3. Test Suite --- + + +class TestAbstractLibraryObject: + """Unit tests for AbstractLibraryObject behavior.""" + + def test_abc_instantiation_fails(self): + """Ensure the Abstract Base Class cannot be instantiated directly.""" + # We try to instantiate AbstractLibraryObject directly, + # # not the concrete one + with pytest.raises(TypeError) as exc: + # pylint: disable=abstract-class-instantiated + AbstractLibraryObject(Mock(), 1) # type: ignore + + assert "Can't instantiate abstract class" in str(exc.value) + + def test_initialization(self, test_obj, mock_spanner_lib): + """Test proper attribute assignment upon initialization.""" + assert test_obj.oid == 123 + assert test_obj.spannerlib == mock_spanner_lib + # pylint: disable=protected-access + assert test_obj._is_disposed is False + assert test_obj.cleanup_called_count == 0 + + def test_context_manager_lifecycle(self, mock_spanner_lib): + """Verify __enter__ and __exit__ work as expected.""" + + with ConcreteTestObject(mock_spanner_lib, 456) as obj: + assert obj.oid == 456 + # pylint: disable=protected-access + assert obj._is_disposed is False + # Ensure we can use the object inside the block + obj._check_disposed() + + # After block, object should be disposed + assert obj._is_disposed is True # pylint: disable=protected-access + assert obj.cleanup_called_count == 1 + + # Verify accessing it now raises error + with pytest.raises(ObjectClosedError): + obj._check_disposed() # pylint: disable=protected-access + + def test_manual_close(self, test_obj): + """Verify manual .close() works identical to context manager.""" + test_obj.close() + + # pylint: disable=protected-access + assert test_obj._is_disposed is True + assert test_obj.cleanup_called_count == 1 + + with pytest.raises(ObjectClosedError): + test_obj._check_disposed() + + def test_double_dispose_is_safe(self, test_obj): + """ + Verify that calling close() multiple times is idempotent + and does not trigger the underlying cleanup twice. + """ + # 1. First Close + test_obj.close() + assert test_obj.cleanup_called_count == 1 + + # 2. Second Close + test_obj.close() + # Count should STILL be 1. If it's 2, we have a double-free bug. + assert test_obj.cleanup_called_count == 1 + + def test_check_disposed_raises_error(self, test_obj): + """Test the guard clause logic.""" + # Should not raise when alive + test_obj._check_disposed() # pylint: disable=protected-access + + test_obj.close() + + # Should raise when dead + with pytest.raises(ObjectClosedError) as exc: + test_obj._check_disposed() + + assert "ConcreteTestObject has already been disposed" in str(exc.value) + + def test_dispose_handles_zero_oid(self, mock_spanner_lib): + """ + Verify that if OID is 0 (invalid/uninitialized), we skip the cleanup + logic but still mark as disposed. + """ + obj = ConcreteTestObject(mock_spanner_lib, oid=0) + obj.close() + + assert obj._is_disposed is True # pylint: disable=protected-access + # Should NOT call cleanup for OID 0 to prevent C-library errors + assert obj.cleanup_called_count == 0 + + def test_del_warning_on_leak(self, mock_spanner_lib): + """ + Verify __del__ emits ResourceWarning if object is garbage collected + without being closed. + """ + + # We create a function to limit the scope of the variable + def create_leaky_object(): + obj = ConcreteTestObject(mock_spanner_lib, oid=999) + return obj + # obj goes out of scope here, but is not closed + + # We use pytest.warns to assert the warning is caught + with pytest.warns( + ResourceWarning, match="Unclosed ConcreteTestObject" + ) as record: + create_leaky_object() + + # Force Garbage Collection to trigger __del__ + gc.collect() + + # Check stack level is correct (optional but good practice) + assert len(record) > 0 diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_connection.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_connection.py new file mode 100644 index 00000000..55e90b98 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_connection.py @@ -0,0 +1,646 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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 unittest.mock import MagicMock, Mock, patch + +import pytest + +from google.cloud.spannerlib import Connection, Rows # type: ignore +from google.cloud.spannerlib.internal.errors import SpannerLibError + + +class TestConnection: + """ + Test suite for the Connection class. + """ + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + + @pytest.fixture + def mock_msg(self): + """Mocks the message object returned by the library context manager.""" + msg = Mock() + return msg + + @pytest.fixture + def mock_spanner_lib(self, mock_msg): + """Mocks the underlying SpannerLib instance.""" + lib = Mock() + # Ensure close_connection returns a context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + lib.close_connection.return_value = ctx_manager + # Ensure close_rows returns a context manager + lib.close_rows.return_value = ctx_manager + return lib + + @pytest.fixture + def mock_pool(self, mock_spanner_lib): + """ + Mocks the Pool object. + The Connection class expects access to pool.spannerlib and pool.oid. + """ + pool = Mock() + pool.spannerlib = mock_spanner_lib + pool.oid = 999 # Mock Pool ID + return pool + + @pytest.fixture + def connection(self, mock_pool): + """Creates a Connection instance for testing.""" + conn = Connection(oid=123, pool=mock_pool) + yield conn + conn.close() + + @pytest.fixture + def setup_close_context(self, mock_spanner_lib, mock_msg): + """ + Helper fixture that configures the context manager for + close_connection. + + This setup is complex (Context Manager -> Returns Msg -> Checks Error), + so encapsulating it keeps the test methods clean. + """ + + def _setup(): + # Create a MagicMock to handle the 'with' statement + ctx_manager = MagicMock() + # When entering 'with', return the message mock + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.close_connection.return_value = ctx_manager + return ctx_manager + + return _setup + + # ------------------------------------------------------------------------- + # Test Methods: Initialization & State + # ------------------------------------------------------------------------- + + def test_initialization(self, mock_pool, mock_spanner_lib): + """Test that Connection initializes correctly from the Pool.""" + conn = Connection(oid=50, pool=mock_pool) + + # Verify inheritance (AbstractLibraryObject) setup + assert conn.spannerlib == mock_spanner_lib + assert conn.oid == 50 + + # Verify specific connection attributes + assert conn.pool.oid == 999 + + def test_pool_property_is_read_only(self, connection): + """Ensure pool cannot be overwritten accidentally.""" + assert connection.pool.oid == 999 + + with pytest.raises(AttributeError): + connection.pool = None + + # ------------------------------------------------------------------------- + # Test Methods: Lifecycle & Cleanup + # ------------------------------------------------------------------------- + + def test_close_connection_success( + self, connection, mock_spanner_lib, mock_msg, setup_close_context + ): + """Test the successful closure of a connection via the public + .close() method.""" + # 1. Setup the mocks + setup_close_context() + + # 2. Execute + # calling the public .close() (from parent) which triggers + # _close_lib_object() + connection.close() + + # 3. Assertions + # Verify correct arguments passed to Go Lib: (PoolID, ConnectionID) + mock_spanner_lib.close_connection.assert_called_once_with(999, 123) + + # Verify context manager lifecycle + mock_msg.raise_if_error.assert_called_once() + + # Verify object is marked disposed + assert connection._is_disposed is True + + def test_close_connection_propagates_error( + self, connection, mock_msg, setup_close_context + ): + """Test that exceptions from the Go library are logged + and re-raised.""" + # 1. Setup + setup_close_context() + + # Simulate a failure in the underlying library (e.g. C++ error) + error = RuntimeError("Go Library Error") + mock_msg.raise_if_error.side_effect = error + + # 2. Execute & Assert + # We patch the logger to ensure the error is logged before crashing + with patch("google.cloud.spannerlib.connection.logger") as mock_logger: + with pytest.raises(SpannerLibError): + connection.close() + + # Verify logging + mock_logger.exception.assert_called_once_with( + "Unexpected error closing connection ID: %d", 123 + ) + + def test_close_connection_unexpected_error( + self, connection, mock_spanner_lib + ): + """Test handling when the context manager setup itself fails + (e.g. memory error).""" + # 1. Setup + # Simulate a crash before yielding the message + mock_spanner_lib.close_connection.side_effect = ValueError( + "Invalid Arguments" + ) + + # 2. Execute & Assert + with patch("google.cloud.spannerlib.connection.logger") as mock_logger: + with pytest.raises(SpannerLibError): + connection.close() + + mock_logger.exception.assert_called_once() + + # ------------------------------------------------------------------------- + # Test Methods: Execution + # ------------------------------------------------------------------------- + + def test_execute_success(self, connection, mock_spanner_lib, mock_msg): + """Test successful SQL execution.""" + # 1. Setup + mock_request = Mock() + serialized_request = b"serialized_request" + + with patch( + "google.cloud.spanner_v1.ExecuteSqlRequest.serialize", + return_value=serialized_request, + ) as mock_serialize: + # Mock spannerlib.execute context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.execute.return_value = ctx_manager + + # Set expected Rows ID + mock_msg.object_id = 456 + + # 2. Execute + rows = connection.execute(mock_request) + + # 3. Assertions + mock_serialize.assert_called_once_with(mock_request) + mock_spanner_lib.execute.assert_called_once_with( + 999, 123, serialized_request + ) + mock_msg.raise_if_error.assert_called_once() + + assert isinstance(rows, Rows) + assert rows.oid == 456 + assert rows.pool == connection.pool + assert rows.conn == connection + + # Clean up + rows.close() + + def test_execute_closed_connection(self, connection): + """Test execute raises error if connection is closed.""" + connection._mark_disposed() + mock_request = Mock() + + with pytest.raises(SpannerLibError, match="Connection is closed"): + connection.execute(mock_request) + + def test_execute_propagates_error(self, connection, mock_spanner_lib): + """Test that execute propagates errors from the library.""" + # 1. Setup + mock_request = Mock() + serialized_request = b"serialized_request" + + with patch( + "google.cloud.spanner_v1.ExecuteSqlRequest.serialize", + return_value=serialized_request, + ): + # Mock spannerlib.execute context manager with a NEW message object + ctx_manager = MagicMock() + exec_msg = Mock() + ctx_manager.__enter__.return_value = exec_msg + mock_spanner_lib.execute.return_value = ctx_manager + + # Simulate error + exec_msg.raise_if_error.side_effect = SpannerLibError( + "Execution failed" + ) + + # 2. Execute & Assert + with pytest.raises(SpannerLibError, match="Execution failed"): + connection.execute(mock_request) + + def test_execute_batch_success( + self, connection, mock_spanner_lib, mock_msg + ): + """Test successful batch DML execution.""" + # 1. Setup + mock_request = Mock() + serialized_request = b"serialized_request" + mock_response = Mock() + serialized_response = b"serialized_response" + + with patch( + "google.cloud.spanner_v1.ExecuteBatchDmlRequest.serialize", + return_value=serialized_request, + ) as mock_serialize, patch( + "google.cloud.spannerlib.connection.to_bytes", + return_value=serialized_response, + ) as mock_to_bytes, patch( + "google.cloud.spanner_v1.ExecuteBatchDmlResponse.deserialize", + return_value=mock_response, + ) as mock_deserialize: + # Mock spannerlib.execute_batch context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.execute_batch.return_value = ctx_manager + + # Mock message attributes + mock_msg.msg = Mock() + mock_msg.msg_len = 123 + + # 2. Execute + response = connection.execute_batch(mock_request) + + # 3. Assertions + mock_serialize.assert_called_once_with(mock_request) + mock_spanner_lib.execute_batch.assert_called_once_with( + 999, 123, serialized_request + ) + mock_msg.raise_if_error.assert_called_once() + mock_to_bytes.assert_called_once_with( + mock_msg.msg, mock_msg.msg_len + ) + mock_deserialize.assert_called_once_with(serialized_response) + assert response == mock_response + + def test_execute_batch_closed_connection(self, connection): + """Test execute_batch raises error if connection is closed.""" + connection._mark_disposed() + mock_request = Mock() + + with pytest.raises(SpannerLibError, match="Connection is closed"): + connection.execute_batch(mock_request) + + def test_execute_batch_propagates_error(self, connection, mock_spanner_lib): + """Test that execute_batch propagates errors from the library.""" + # 1. Setup + mock_request = Mock() + serialized_request = b"serialized_request" + + with patch( + "google.cloud.spanner_v1.ExecuteBatchDmlRequest.serialize", + return_value=serialized_request, + ): + # Mock spannerlib.execute_batch context manager + ctx_manager = MagicMock() + exec_msg = Mock() + ctx_manager.__enter__.return_value = exec_msg + mock_spanner_lib.execute_batch.return_value = ctx_manager + + # Simulate error + exec_msg.raise_if_error.side_effect = SpannerLibError( + "Batch Execution failed" + ) + + # 2. Execute & Assert + with pytest.raises(SpannerLibError, match="Batch Execution failed"): + connection.execute_batch(mock_request) + + def test_write_mutations_success( + self, connection, mock_spanner_lib, mock_msg + ): + """Test successful mutation write.""" + # 1. Setup + mock_request = Mock() + serialized_request = b"serialized_request" + mock_response = Mock() + serialized_response = b"serialized_response" + + with patch( + "google.cloud.spanner_v1.BatchWriteRequest.MutationGroup.serialize", + return_value=serialized_request, + ) as mock_serialize, patch( + "google.cloud.spannerlib.connection.to_bytes", + return_value=serialized_response, + ) as mock_to_bytes, patch( + "google.cloud.spanner_v1.CommitResponse.deserialize", + return_value=mock_response, + ) as mock_deserialize: + # Mock spannerlib.write_mutations context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.write_mutations.return_value = ctx_manager + + # Mock message attributes + mock_msg.msg = Mock() + mock_msg.msg_len = 123 + + # 2. Execute + response = connection.write_mutations(mock_request) + + # 3. Assertions + mock_serialize.assert_called_once_with(mock_request) + mock_spanner_lib.write_mutations.assert_called_once_with( + 999, 123, serialized_request + ) + mock_msg.raise_if_error.assert_called_once() + mock_to_bytes.assert_called_once_with( + mock_msg.msg, mock_msg.msg_len + ) + mock_deserialize.assert_called_once_with(serialized_response) + assert response == mock_response + + def test_write_mutations_closed_connection(self, connection): + """Test write_mutations raises error if connection is closed.""" + connection._mark_disposed() + mock_request = Mock() + + with pytest.raises(SpannerLibError, match="Connection is closed"): + connection.write_mutations(mock_request) + + def test_write_mutations_propagates_error( + self, connection, mock_spanner_lib + ): + """Test that write_mutations propagates errors from the library.""" + # 1. Setup + mock_request = Mock() + serialized_request = b"serialized_request" + + with patch( + "google.cloud.spanner_v1.BatchWriteRequest.MutationGroup.serialize", + return_value=serialized_request, + ): + # Mock spannerlib.write_mutations context manager + ctx_manager = MagicMock() + exec_msg = Mock() + ctx_manager.__enter__.return_value = exec_msg + mock_spanner_lib.write_mutations.return_value = ctx_manager + + # Simulate error + exec_msg.raise_if_error.side_effect = SpannerLibError( + "Mutation Write failed" + ) + + with pytest.raises(SpannerLibError, match="Mutation Write failed"): + connection.write_mutations(mock_request) + + def test_begin_transaction_success( + self, connection, mock_spanner_lib, mock_msg + ): + """Test successful transaction start.""" + # 1. Setup + mock_options = Mock() + serialized_options = b"serialized_options" + + with patch( + "google.cloud.spanner_v1.TransactionOptions.serialize", + return_value=serialized_options, + ) as mock_serialize: + # Mock spannerlib.begin_transaction context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.begin_transaction.return_value = ctx_manager + + # 2. Execute + connection.begin_transaction(mock_options) + + # 3. Assertions + mock_serialize.assert_called_once_with(mock_options) + mock_spanner_lib.begin_transaction.assert_called_once_with( + 999, 123, serialized_options + ) + mock_msg.raise_if_error.assert_called_once() + + def test_begin_transaction_default_options( + self, connection, mock_spanner_lib, mock_msg + ): + """Test begin_transaction with default options.""" + # 1. Setup + serialized_options = b"default_options" + + with patch( + "google.cloud.spannerlib.connection.TransactionOptions" + ) as mock_options_cls: + mock_options_cls.serialize.return_value = serialized_options + + # Mock spannerlib.begin_transaction context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.begin_transaction.return_value = ctx_manager + + # 2. Execute + connection.begin_transaction() + + # 3. Assertions + mock_options_cls.assert_called_once() + mock_options_cls.serialize.assert_called_once() + mock_spanner_lib.begin_transaction.assert_called_once_with( + 999, 123, serialized_options + ) + mock_msg.raise_if_error.assert_called_once() + + def test_begin_transaction_closed_connection(self, connection): + """Test begin_transaction raises error if connection is closed.""" + connection._mark_disposed() + + with pytest.raises(SpannerLibError, match="Connection is closed"): + connection.begin_transaction() + + def test_begin_transaction_propagates_error( + self, connection, mock_spanner_lib + ): + """Test that begin_transaction propagates errors from the library.""" + # 1. Setup + serialized_options = b"serialized_options" + + with patch( + "google.cloud.spanner_v1.TransactionOptions.serialize", + return_value=serialized_options, + ): + # Mock spannerlib.begin_transaction context manager + ctx_manager = MagicMock() + exec_msg = Mock() + ctx_manager.__enter__.return_value = exec_msg + mock_spanner_lib.begin_transaction.return_value = ctx_manager + + # Simulate error + exec_msg.raise_if_error.side_effect = SpannerLibError( + "Transaction Start failed" + ) + + # 2. Execute & Assert + with pytest.raises( + SpannerLibError, match="Transaction Start failed" + ): + connection.begin_transaction(Mock()) + + def test_commit_success(self, connection, mock_spanner_lib, mock_msg): + """Test successful commit.""" + # 1. Setup + mock_response = Mock() + serialized_response = b"serialized_response" + + with patch( + "google.cloud.spannerlib.connection.to_bytes", + return_value=serialized_response, + ) as mock_to_bytes, patch( + "google.cloud.spanner_v1.CommitResponse.deserialize", + return_value=mock_response, + ) as mock_deserialize: + # Mock spannerlib.commit context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.commit.return_value = ctx_manager + + # Mock message attributes + mock_msg.msg = Mock() + mock_msg.msg_len = 123 + + # 2. Execute + response = connection.commit() + + # 3. Assertions + mock_spanner_lib.commit.assert_called_once_with(999, 123) + mock_msg.raise_if_error.assert_called_once() + mock_to_bytes.assert_called_once_with( + mock_msg.msg, mock_msg.msg_len + ) + mock_deserialize.assert_called_once_with(serialized_response) + assert response == mock_response + + def test_commit_closed_connection(self, connection): + """Test commit raises error if connection is closed.""" + connection._mark_disposed() + + with pytest.raises(SpannerLibError, match="Connection is closed"): + connection.commit() + + def test_commit_propagates_error(self, connection, mock_spanner_lib): + """Test that commit propagates errors from the library.""" + # 1. Setup + # Mock spannerlib.commit context manager + ctx_manager = MagicMock() + exec_msg = Mock() + ctx_manager.__enter__.return_value = exec_msg + mock_spanner_lib.commit.return_value = ctx_manager + + # Simulate error + exec_msg.raise_if_error.side_effect = SpannerLibError("Commit failed") + + # 2. Execute & Assert + with pytest.raises(SpannerLibError, match="Commit failed"): + connection.commit() + + def test_rollback_success(self, connection, mock_spanner_lib, mock_msg): + """Test successful rollback.""" + # 1. Setup + # Mock spannerlib.rollback context manager + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + mock_spanner_lib.rollback.return_value = ctx_manager + + # 2. Execute + connection.rollback() + + # 3. Assertions + mock_spanner_lib.rollback.assert_called_once_with(999, 123) + mock_msg.raise_if_error.assert_called_once() + + def test_rollback_closed_connection(self, connection): + """Test rollback raises error if connection is closed.""" + connection._mark_disposed() + + with pytest.raises(SpannerLibError, match="Connection is closed"): + connection.rollback() + + def test_rollback_propagates_error(self, connection, mock_spanner_lib): + """Test that rollback propagates errors from the library.""" + # 1. Setup + # Mock spannerlib.rollback context manager + ctx_manager = MagicMock() + exec_msg = Mock() + ctx_manager.__enter__.return_value = exec_msg + mock_spanner_lib.rollback.return_value = ctx_manager + + # Simulate error + exec_msg.raise_if_error.side_effect = SpannerLibError("Rollback failed") + + # 2. Execute & Assert + with pytest.raises(SpannerLibError, match="Rollback failed"): + connection.rollback() + + def test_transaction_write_mutations_success( + self, connection, mock_spanner_lib, mock_msg + ): + """Test writing mutations within a transaction.""" + # 1. Setup + mock_mutation = Mock() + serialized_mutation = b"serialized_mutation" + mock_response = Mock() + serialized_response = b"serialized_response" + + with patch( + "google.cloud.spanner_v1.BatchWriteRequest.MutationGroup.serialize", + return_value=serialized_mutation, + ) as mock_serialize, patch( + "google.cloud.spannerlib.connection.to_bytes", + return_value=serialized_response, + ) as mock_to_bytes, patch( + "google.cloud.spanner_v1.CommitResponse.deserialize", + return_value=mock_response, + ) as mock_deserialize: + # Mock spannerlib methods + ctx_manager = MagicMock() + ctx_manager.__enter__.return_value = mock_msg + + mock_spanner_lib.begin_transaction.return_value = ctx_manager + mock_spanner_lib.write_mutations.return_value = ctx_manager + mock_spanner_lib.commit.return_value = ctx_manager + + mock_msg.msg = Mock() + mock_msg.msg_len = 123 + + # 2. Execute + connection.begin_transaction() + + # Simulate buffered mutation (no response) + mock_msg.msg_len = 0 + mock_msg.msg = None + response = connection.write_mutations(mock_mutation) + + connection.commit() + + # 3. Assertions + mock_spanner_lib.begin_transaction.assert_called_once() + mock_serialize.assert_called_once_with(mock_mutation) + mock_spanner_lib.write_mutations.assert_called_once_with( + 999, 123, serialized_mutation + ) + mock_spanner_lib.commit.assert_called_once_with(999, 123) + + # Verify response is None for buffered mutation + assert response is None + + # to_bytes and deserialize should NOT be called for write_mutations + # but ARE called for commit. + # commit calls to_bytes and deserialize once. + assert mock_to_bytes.call_count == 1 + assert mock_deserialize.call_count == 1 diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_pool.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_pool.py new file mode 100644 index 00000000..c8641543 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_pool.py @@ -0,0 +1,244 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Unit tests for Pool behavior.""" +import ctypes # Add this import +from unittest.mock import patch + +import pytest + +from google.cloud.spannerlib import Connection, Pool # type: ignore +from google.cloud.spannerlib.internal import SpannerLibError # type: ignore +from google.cloud.spannerlib.internal.message import Message # type: ignore + +# ----------------------------------------------------------------------------- +# Fixtures & Helpers +# ----------------------------------------------------------------------------- + + +@pytest.fixture +def mock_spanner_lib_class(): + """ + Patches the SpannerLib class in the 'pool' module namespace. + Returns the Mock CLASS, not an instance. + """ + with patch("google.cloud.spannerlib.pool.SpannerLib") as MockClass: + yield MockClass + + +@pytest.fixture +def mock_lib_instance(mock_spanner_lib_class): + """ + Returns the specific instance of SpannerLib that the Pool will use. + """ + instance = mock_spanner_lib_class.return_value + return instance + + +@pytest.fixture +def mock_msg_data(): + """ + Provides raw data for a Message object. + """ + return { + "pinner_id": 0, + "error_code": 0, + "object_id": 555, # Default test ID + "msg_len": 0, + "msg": None, + } + + +@pytest.fixture +def setup_spannerlib_method(mock_lib_instance, mock_msg_data): + """ + Helper to setup the mock response for SpannerLib methods + like create_pool and close_pool. + """ + + def _configure_method(method_name, return_msg_data=None): + if return_msg_data is None: + return_msg_data = mock_msg_data + + # Convert msg to C char pointer if present + if return_msg_data["msg"]: + c_msg = ctypes.c_char_p(return_msg_data["msg"]) + return_msg_data["msg"] = ctypes.cast(c_msg, ctypes.c_void_p) + + msg = Message(**return_msg_data) + getattr(mock_lib_instance, method_name).return_value = msg + return msg + + return _configure_method + + +class TestPool: + """Tests for the Pool class method.""" + + def test_create_pool_success( + self, + mock_spanner_lib_class, + mock_lib_instance, + setup_spannerlib_method, + mock_msg_data, + ): # pylint: disable=redefined-outer-name + """Test successful creation of a Pool.""" + # 1. Setup + setup_spannerlib_method("create_pool") + conn_string = "projects/test/instances/test/databases/test" + + # 2. Execute + pool = Pool.create_pool(conn_string) + + # 3. Assertions + mock_spanner_lib_class.assert_called_once() + mock_lib_instance.create_pool.assert_called_once_with(conn_string) + + assert isinstance(pool, Pool) + assert pool.oid == mock_msg_data["object_id"] + assert pool.spannerlib == mock_lib_instance + + def test_create_pool_lib_error( + self, + mock_spanner_lib_class, + mock_lib_instance, + setup_spannerlib_method, + mock_msg_data, + ): # pylint: disable=redefined-outer-name + """Test that SpannerLibError from the underlying lib is propagated.""" + # 1. Setup + mock_msg_data["error_code"] = 1 + mock_msg_data["msg"] = b"Connection failed" + mock_msg_data["msg_len"] = len(b"Connection failed") + setup_spannerlib_method("create_pool") + + # 2. Execute & Assert + with pytest.raises(SpannerLibError) as exc_info: + Pool.create_pool("bad_connection") + + assert "Connection failed" in str(exc_info.value) + + def test_create_pool_unexpected_error( + self, mock_spanner_lib_class, mock_lib_instance, setup_spannerlib_method + ): # pylint: disable=redefined-outer-name + """Test that unexpected generic exceptions are wrapped + in SpannerLibError.""" + # 1. Setup + mock_lib_instance.create_pool.side_effect = ValueError( + "Something weird happened" + ) + + # 2. Execute & Assert + with pytest.raises(SpannerLibError) as exc_info: + Pool.create_pool("conn_str") + + # Ensure the original error is chained or mentioned + msg = str(exc_info.value) + assert "Unexpected error: Something weird happened" in msg + + def test_close_pool_success( + self, mock_lib_instance, setup_spannerlib_method, mock_msg_data + ): # pylint: disable=redefined-outer-name + """Test successful closing of the Pool.""" + # 1. Setup + pool = Pool(spannerlib=mock_lib_instance, oid=100) + setup_spannerlib_method("close_pool") + + # 2. Execute + pool.close() + + # 3. Assertions + mock_lib_instance.close_pool.assert_called_once_with(100) + assert pool._is_disposed is True # pylint: disable=protected-access + + def test_close_pool_propagates_lib_error( + self, mock_lib_instance, setup_spannerlib_method, mock_msg_data + ): # pylint: disable=redefined-outer-name + """Test handling of SpannerLibError during close.""" + pool = Pool(spannerlib=mock_lib_instance, oid=100) + mock_msg_data["error_code"] = 1 + mock_msg_data["msg"] = b"Failed to release" + mock_msg_data["msg_len"] = len(b"Failed to release") + setup_spannerlib_method("close_pool") + + with pytest.raises(SpannerLibError) as exc_info: + pool.close() + + assert "Failed to release" in str(exc_info.value) + + def test_close_pool_wraps_unexpected_error( + self, mock_lib_instance, setup_spannerlib_method + ): # pylint: disable=redefined-outer-name + """Test wrapping of generic errors during close.""" + pool = Pool(spannerlib=mock_lib_instance, oid=100) + mock_lib_instance.close_pool.side_effect = TypeError( + "Internal type error" + ) + + with pytest.raises(SpannerLibError) as exc_info: + pool.close() + + assert "Unexpected error during close: Internal type error" in str( + exc_info.value + ) + + def test_create_connection_success( + self, mock_lib_instance, setup_spannerlib_method, mock_msg_data + ): # pylint: disable=redefined-outer-name + """Test successful creation of a Connection from the Pool.""" + # 1. Setup + pool = Pool(spannerlib=mock_lib_instance, oid=100) + # Mock the create_connection call to return a valid message + # with a new OID + mock_msg_data["object_id"] = 200 + setup_spannerlib_method("create_connection") + + # 2. Execute + conn = pool.create_connection() + + # 3. Assertions + mock_lib_instance.create_connection.assert_called_once_with(100) + assert isinstance(conn, Connection) + assert conn.oid == 200 + assert conn.pool.oid == 100 + assert conn.spannerlib == mock_lib_instance + + def test_create_connection_pool_closed(self, mock_lib_instance): + """Test that creating a connection from a closed pool + raises an error.""" + # 1. Setup + pool = Pool(spannerlib=mock_lib_instance, oid=100) + pool._is_disposed = True + + # 2. Execute & Assert + with pytest.raises(SpannerLibError) as exc_info: + pool.create_connection() + + assert "Pool is closed" in str(exc_info.value) + + def test_create_connection_lib_error( + self, mock_lib_instance, setup_spannerlib_method, mock_msg_data + ): # pylint: disable=redefined-outer-name + """Test that SpannerLibError from the underlying lib is propagated.""" + # 1. Setup + pool = Pool(spannerlib=mock_lib_instance, oid=100) + mock_msg_data["error_code"] = 1 + mock_msg_data["msg"] = b"Failed to create connection" + mock_msg_data["msg_len"] = len(b"Failed to create connection") + setup_spannerlib_method("create_connection") + + # 2. Execute & Assert + with pytest.raises(SpannerLibError) as exc_info: + pool.create_connection() + + assert "Failed to create connection" in str(exc_info.value) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_rows.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_rows.py new file mode 100644 index 00000000..03f1eb6c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/tests/unit/test_rows.py @@ -0,0 +1,441 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. +"""Unit tests for Rows.""" +from unittest.mock import MagicMock, patch + +import pytest + +from google.cloud.spannerlib.internal.errors import SpannerLibError +from google.cloud.spannerlib.internal.spannerlib_protocol import ( + SpannerLibProtocol, +) +from google.cloud.spannerlib.rows import Rows + + +class TestRows: + """Test suite for Rows class.""" + + def test_init(self) -> None: + """Verifies correct initialization of Rows.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + oid = 123 + rows = Rows(oid, mock_conn) + + assert rows.oid == oid + assert rows.pool == mock_pool + assert rows.conn == mock_conn + assert rows.spannerlib == mock_spannerlib + assert not rows.closed + + def test_close_calls_lib(self) -> None: + """Verifies that close() calls the underlying library function.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Set OIDs + mock_pool.oid = 10 + mock_conn.oid = 20 + rows_oid = 30 + + rows = Rows(rows_oid, mock_conn) + + # Setup mock context manager for close_rows + mock_msg = MagicMock() + mock_spannerlib.close_rows.return_value.__enter__.return_value = ( + mock_msg + ) + + rows.close() + + # Verify call arguments + mock_spannerlib.close_rows.assert_called_once_with(10, 20, 30) + mock_msg.raise_if_error.assert_called_once() + assert rows.closed + + def test_close_handles_error(self) -> None: + """Verifies that exceptions during close are propagated + but object is still disposed.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + rows = Rows(30, mock_conn) + + # Setup mock to raise exception + mock_spannerlib.close_rows.side_effect = Exception("Close failed") + + with pytest.raises(SpannerLibError, match="Close failed"): + rows.close() + + # Verify object is marked closed despite error + assert rows.closed + + def test_context_manager(self) -> None: + """Verifies that Rows works as a context manager.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Setup mock context manager for close_rows + mock_msg = MagicMock() + mock_spannerlib.close_rows.return_value.__enter__.return_value = ( + mock_msg + ) + + rows = Rows(123, mock_conn) + + with rows as r: + assert r is rows + assert not rows.closed + + assert rows.closed + mock_spannerlib.close_rows.assert_called_once() + + def test_metadata_returns_metadata(self) -> None: + """Verifies that metadata() retrieves and deserializes metadata.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Set OIDs + mock_pool.oid = 10 + mock_conn.oid = 20 + rows_oid = 30 + + rows = Rows(rows_oid, mock_conn) + + # Mock the context manager and message + mock_msg = MagicMock() + mock_msg.msg_len = 10 + mock_msg.msg = 12345 # Fake pointer + mock_spannerlib.metadata.return_value.__enter__.return_value = mock_msg + + # Patch ctypes and ResultSetMetadata + with patch("google.cloud.spannerlib.rows.ctypes") as mock_ctypes, patch( + "google.cloud.spannerlib.rows.ResultSetMetadata" + ) as mock_metadata_cls: + + mock_ctypes.string_at.return_value = b"serialized_proto" + expected_metadata = MagicMock() + mock_metadata_cls.deserialize.return_value = expected_metadata + + result = rows.metadata() + + assert result == expected_metadata + mock_spannerlib.metadata.assert_called_once_with(10, 20, 30) + mock_msg.raise_if_error.assert_called_once() + mock_ctypes.string_at.assert_called_once_with(12345, 10) + mock_metadata_cls.deserialize.assert_called_once_with( + b"serialized_proto" + ) + + def test_metadata_raises_if_closed(self) -> None: + """Verifies that metadata() raises SpannerLibError, + if rows are closed.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Setup mock context manager for close_rows to avoid errors + # during close() + mock_spannerlib.close_rows.return_value.__enter__.return_value = ( + MagicMock() + ) + + rows = Rows(1, mock_conn) + rows.close() + + with pytest.raises(SpannerLibError, match="Rows object is closed"): + rows.metadata() + + def test_metadata_handles_deserialization_error(self) -> None: + """Verifies that metadata() handles deserialization errors.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + rows = Rows(1, mock_conn) + + mock_msg = MagicMock() + mock_msg.msg_len = 10 + mock_msg.msg = 12345 + mock_spannerlib.metadata.return_value.__enter__.return_value = mock_msg + + with patch("google.cloud.spannerlib.rows.ctypes") as mock_ctypes, patch( + "google.cloud.spannerlib.rows.ResultSetMetadata" + ) as mock_metadata_cls: + + mock_ctypes.string_at.return_value = b"data" + mock_metadata_cls.deserialize.side_effect = Exception("Parse error") + + with pytest.raises( + SpannerLibError, match="Failed to get metadata: Parse error" + ): + rows.metadata() + + def test_result_set_stats_returns_stats(self) -> None: + """Verifies that result_set_stats() retrieves and deserializes stats.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Set OIDs + mock_pool.oid = 10 + mock_conn.oid = 20 + rows_oid = 30 + + rows = Rows(rows_oid, mock_conn) + + # Mock the context manager and message + mock_msg = MagicMock() + mock_msg.msg_len = 10 + mock_msg.msg = 12345 + mock_spannerlib.result_set_stats.return_value.__enter__.return_value = ( + mock_msg + ) + + # Patch ctypes and ResultSetStats + with patch("google.cloud.spannerlib.rows.ctypes") as mock_ctypes, patch( + "google.cloud.spannerlib.rows.ResultSetStats" + ) as mock_stats_cls: + + mock_ctypes.string_at.return_value = b"stats_proto" + expected_stats = MagicMock() + mock_stats_cls.deserialize.return_value = expected_stats + + result = rows.result_set_stats() + + assert result == expected_stats + mock_spannerlib.result_set_stats.assert_called_once_with(10, 20, 30) + mock_msg.raise_if_error.assert_called_once() + mock_ctypes.string_at.assert_called_once_with(12345, 10) + mock_stats_cls.deserialize.assert_called_once_with(b"stats_proto") + + def test_result_set_stats_raises_if_closed(self) -> None: + """Verifies that result_set_stats() raises SpannerLibError + if rows are closed.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Setup mock context manager for close_rows to avoid + # errors during close() + mock_spannerlib.close_rows.return_value.__enter__.return_value = ( + MagicMock() + ) + + rows = Rows(1, mock_conn) + rows.close() + + with pytest.raises(SpannerLibError, match="Rows object is closed"): + rows.result_set_stats() + + def test_result_set_stats_handles_deserialization_error(self) -> None: + """Verifies that result_set_stats() handles deserialization errors.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + rows = Rows(1, mock_conn) + + mock_msg = MagicMock() + mock_msg.msg_len = 10 + mock_msg.msg = 12345 + mock_spannerlib.result_set_stats.return_value.__enter__.return_value = ( + mock_msg + ) + + with patch("google.cloud.spannerlib.rows.ctypes") as mock_ctypes, patch( + "google.cloud.spannerlib.rows.ResultSetStats" + ) as mock_stats_cls: + + mock_ctypes.string_at.return_value = b"data" + mock_stats_cls.deserialize.side_effect = Exception( + "Stats parse error" + ) + + with pytest.raises( + SpannerLibError, + match="Failed to get ResultSetStats: Stats parse error", + ): + rows.result_set_stats() + + def test_update_count_exact(self) -> None: + """Verifies update_count returns row_count_exact if present.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + rows = Rows(1, mock_conn) + + mock_stats = MagicMock() + mock_stats.row_count_exact = 42 + mock_stats.row_count_lower_bound = 0 + mock_stats._pb.WhichOneof.return_value = "row_count_exact" + + with patch.object(rows, "result_set_stats", return_value=mock_stats): + assert rows.update_count() == 42 + + def test_update_count_lower_bound(self) -> None: + """Verifies update_count returns row_count_lower_bound + if exact is missing.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + rows = Rows(1, mock_conn) + + mock_stats = MagicMock() + mock_stats.row_count_exact = 0 + mock_stats.row_count_lower_bound = 15 + mock_stats._pb.WhichOneof.return_value = "row_count_lower_bound" + + with patch.object(rows, "result_set_stats", return_value=mock_stats): + assert rows.update_count() == 15 + + def test_update_count_not_available(self) -> None: + """Verifies update_count returns -1 if no stats available.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + rows = Rows(1, mock_conn) + + mock_stats = MagicMock() + mock_stats.row_count_exact = 0 + mock_stats.row_count_lower_bound = 0 + mock_stats._pb.WhichOneof.return_value = None + + with patch.object(rows, "result_set_stats", return_value=mock_stats): + assert rows.update_count() == -1 + + def test_next_returns_row(self) -> None: + """Verifies that next() retrieves and deserializes a row.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + rows = Rows(1, mock_conn) + + mock_msg = MagicMock() + mock_msg.msg_len = 10 + mock_msg.msg = 12345 + mock_spannerlib.next.return_value.__enter__.return_value = mock_msg + + with patch("google.cloud.spannerlib.rows.ctypes") as mock_ctypes, patch( + "google.cloud.spannerlib.rows.ListValue" + ) as mock_list_value_cls: + + mock_ctypes.string_at.return_value = b"row_proto" + expected_row = MagicMock() + mock_list_value_cls.return_value = expected_row + + result = rows.next() + + assert result == expected_row + mock_spannerlib.next.assert_called_once_with( + mock_pool.oid, mock_conn.oid, rows.oid, 1, 1 + ) + mock_msg.raise_if_error.assert_called_once() + mock_ctypes.string_at.assert_called_once_with(12345, 10) + expected_row.ParseFromString.assert_called_once_with(b"row_proto") + + def test_next_returns_none_when_no_more_rows(self) -> None: + """Verifies that next() returns None when no data is returned.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + rows = Rows(1, mock_conn) + + mock_msg = MagicMock() + mock_msg.msg_len = 0 + mock_msg.msg = None + mock_spannerlib.next.return_value.__enter__.return_value = mock_msg + + result = rows.next() + + assert result is None + mock_spannerlib.next.assert_called_once() + + def test_next_raises_if_closed(self) -> None: + """Verifies that next() raises SpannerLibError if rows are closed.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + # Setup mock context manager for close_rows + mock_spannerlib.close_rows.return_value.__enter__.return_value = ( + MagicMock() + ) + + rows = Rows(1, mock_conn) + rows.close() + + with pytest.raises(SpannerLibError, match="Rows object is closed"): + rows.next() + + def test_next_handles_parse_error(self) -> None: + """Verifies that next() handles parsing errors.""" + mock_pool = MagicMock() + mock_conn = MagicMock() + mock_conn.pool = mock_pool + mock_spannerlib = MagicMock(spec=SpannerLibProtocol) + mock_pool.spannerlib = mock_spannerlib + + rows = Rows(1, mock_conn) + + mock_msg = MagicMock() + mock_msg.msg_len = 10 + mock_msg.msg = 12345 + mock_spannerlib.next.return_value.__enter__.return_value = mock_msg + + with patch("google.cloud.spannerlib.rows.ctypes") as mock_ctypes, patch( + "google.cloud.spannerlib.rows.ListValue" + ) as mock_list_value_cls: + + mock_ctypes.string_at.return_value = b"data" + mock_row = MagicMock() + mock_list_value_cls.return_value = mock_row + mock_row.ParseFromString.side_effect = Exception("Parse error") + + with pytest.raises(SpannerLibError, match="Failed to get next row"): + rows.next() From 632bac144a44990e7303fd9e5b56b17afb171299 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Thu, 4 Dec 2025 14:50:15 +0000 Subject: [PATCH 20/23] feat: Add project README, update Python test matrix to include 3.14, and remove `importlib_resources` dependency. --- spannerlib/wrappers/spannerlib-python/README.md | 8 ++++++++ .../spannerlib-python/spannerlib-python/noxfile.py | 8 ++------ .../spannerlib-python/pyproject.toml | 11 ++++------- .../spannerlib-python/requirements.txt | 1 - 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/README.md index e69de29b..88297dee 100644 --- a/spannerlib/wrappers/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/README.md @@ -0,0 +1,8 @@ +# SpannerLib Python Projects + +This directory contains Python projects related to SpannerLib. + +## Directory Structure + +* **`spannerlib-python/`**: Contains the Python wrapper for the Spanner library. +* **`spannermockserver/`** (Future): Will contain the Spanner mock server and other related projects. diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index f3abfca9..abc20676 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -21,23 +21,20 @@ import nox -DEFAULT_PYTHON_VERSION = "3.13" +DEFAULT_PYTHON_VERSION = "3.11" UNIT_TEST_PYTHON_VERSIONS: List[str] = [ - "3.8", - "3.9", "3.10", "3.11", "3.12", "3.13", ] SYSTEM_TEST_PYTHON_VERSIONS: List[str] = [ - "3.8", - "3.9", "3.10", "3.11", "3.12", "3.13", + "3.14", ] @@ -48,7 +45,6 @@ STANDARD_DEPENDENCIES = [ "google-cloud-spanner", - "importlib_resources", ] UNIT_TEST_STANDARD_DEPENDENCIES = [ diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml index 422fa96f..b0fb646c 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml @@ -8,20 +8,18 @@ dynamic = ["version"] authors = [ { name="Google LLC", email="googleapis-packages@google.com" }, ] -description = "A Python wrapper for the Go spannerlib" +description = "A Python wrapper for the Go spannerlib. This is an internal library that can make breaking changes without prior notice." readme = "README.md" license = "Apache-2.0" license-files = [ "LICENSE", ] -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ "Development Status :: 1 - Planning", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -30,7 +28,6 @@ classifiers = [ ] dependencies = [ "google-cloud-spanner", - "importlib_resources; python_version < '3.9'", ] [project.optional-dependencies] @@ -47,5 +44,5 @@ where = ["."] include = ["google*"] [project.urls] -Homepage = "https://github.com/googleapis/go-sql-spanner" -Repository = "https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python" +Homepage = "https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md" +Repository = "https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python/spannerlib-python" diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt b/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt index c599ee7b..d08e05bd 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/requirements.txt @@ -1,3 +1,2 @@ nox==2025.11.12 setuptools>=68.0 -importlib_resources From adad7dd57adaaf5709f205f66d879ae8d8c253e2 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Thu, 4 Dec 2025 14:55:11 +0000 Subject: [PATCH 21/23] chore: Remove sample requirements.txt. --- .../spannerlib-python/spannerlib-python/samples/requirements.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt b/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt deleted file mode 100644 index a40ec5c3..00000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/samples/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -google-cloud-spanner==3.59.0 From 23da14ac9bb97cb2a0d402e8aabfe3ae6d7032ea Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Thu, 4 Dec 2025 15:03:22 +0000 Subject: [PATCH 22/23] docs: add notice about internal library status and potential for breaking changes. --- .../wrappers/spannerlib-python/spannerlib-python/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md index c2036ce0..084fcf23 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md @@ -1,5 +1,7 @@ # SPANNERLIB-PYTHON: A High-Performance Python Wrapper for the Go Spanner Client Shared lib +> **NOTICE:** This is an internal library that can make breaking changes without prior notice. + ## Introduction The `spannerlib-python` wrapper provides a high-performance, idiomatic Python interface for Google Cloud Spanner by wrapping the official Go Client Shared library. From 6a3785c4f76679e33fb31d1f811393d17c28fce0 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Thu, 4 Dec 2025 15:10:38 +0000 Subject: [PATCH 23/23] Remove redundant dependencies - `google-cloud-spanner` from `STANDARD_DEPENDENCIES` and `mock`/`asyncmock` from `UNIT_TEST_STANDARD_DEPENDENCIES`. --- .../wrappers/spannerlib-python/spannerlib-python/noxfile.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index abc20676..46d74210 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -43,13 +43,9 @@ ISORT_VERSION = "isort>=5.11.0,<7.0.0" LINT_PATHS = ["google", "tests", "samples", "noxfile.py"] -STANDARD_DEPENDENCIES = [ - "google-cloud-spanner", -] +STANDARD_DEPENDENCIES = [] UNIT_TEST_STANDARD_DEPENDENCIES = [ - "mock", - "asyncmock", "pytest", "pytest-cov", "pytest-asyncio",