From b6f9be1bc8dfe927fb0514944bdbb479caa7ff3d Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 8 Dec 2025 08:09:25 +0000 Subject: [PATCH 01/10] feat: Refactor shared library build process to use platform-specific directories and update artifact naming conventions. --- .../spannerlib-python/build-shared-lib.sh | 44 +++++---- .../cloud/spannerlib/internal/spannerlib.py | 25 +++-- .../spannerlib-python/noxfile.py | 98 ++++++++----------- .../spannerlib-artifacts/.gitkeep | 1 - .../tests/unit/internal/test_spannerlib.py | 45 ++++++--- 5 files changed, 113 insertions(+), 100 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep 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 eeef89569..19ac0dfc4 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -40,7 +40,7 @@ fi SHARED_LIB_DIR="../../../shared" TARGET_WRAPPER_DIR="../wrappers/spannerlib-python/spannerlib-python" -ARTIFACTS_DIR="spannerlib-artifacts" +# We are not using ARTIFACTS_DIR anymore for the final destination. cd "$SHARED_LIB_DIR" || exit 1 @@ -52,30 +52,32 @@ 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" -fi +# However, the user requested to copy to internal/lib. + +TARGET_LIB_DIR="google/cloud/spannerlib/internal/lib" +mkdir -p "$TARGET_LIB_DIR" -mkdir -p "$ARTIFACTS_DIR" +echo "Copying all binaries to $TARGET_LIB_DIR..." +cp -r "$SHARED_LIB_DIR/binaries/"* "$TARGET_LIB_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" +# Rename directories to match spannerlib.py expectations +# linux-x64 -> linux-amd64 +if [ -d "$TARGET_LIB_DIR/linux-x64" ]; then + echo "Renaming linux-x64 to linux-amd64" + rm -rf "$TARGET_LIB_DIR/linux-amd64" + mv "$TARGET_LIB_DIR/linux-x64" "$TARGET_LIB_DIR/linux-amd64" 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" +# win-x64 -> windows-amd64 +if [ -d "$TARGET_LIB_DIR/win-x64" ]; then + echo "Renaming win-x64 to windows-amd64" + rm -rf "$TARGET_LIB_DIR/windows-amd64" + mv "$TARGET_LIB_DIR/win-x64" "$TARGET_LIB_DIR/windows-amd64" 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" +# osx-arm64 -> macos-arm64 +if [ -d "$TARGET_LIB_DIR/osx-arm64" ]; then + echo "Renaming osx-arm64 to macos-arm64" + rm -rf "$TARGET_LIB_DIR/macos-arm64" + mv "$TARGET_LIB_DIR/osx-arm64" "$TARGET_LIB_DIR/macos-arm64" fi 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 index 75f426263..26cc0c954 100644 --- 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 @@ -113,23 +113,34 @@ def _load_library(self) -> None: @staticmethod def _get_lib_filename() -> str: """ - Returns the filename of the shared library based on the OS. + Returns the filename of the shared library based on the OS + and architecture. """ - filename: str = "" - system_name = platform.system() + machine_name = platform.machine().lower() if system_name == "Windows": - filename = "spannerlib.dll" + os_part = "windows" + ext = "dll" elif system_name == "Darwin": - filename = "spannerlib.dylib" + os_part = "macos" + ext = "dylib" elif system_name == "Linux": - filename = "spannerlib.so" + os_part = "linux" + ext = "so" else: raise SpannerLibError( f"Unsupported operating system: {system_name}" ) - return filename + + if machine_name in ("amd64", "x86_64"): + arch_part = "amd64" + elif machine_name in ("arm64", "aarch64"): + arch_part = "arm64" + else: + raise SpannerLibError(f"Unsupported architecture: {machine_name}") + + return f"{os_part}-{arch_part}/spannerlib.{ext}" def _configure_signatures(self) -> None: """ diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index 46d742102..ffb6d1cc1 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -60,7 +60,6 @@ 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 @@ -127,6 +126,41 @@ def unit(session): ) +def run_bash_script(session, script_path): + """Runs a bash script, handling Windows specifics.""" + cmd = ["bash", script_path] + + if platform.system() == "Windows": + bash_path = shutil.which("bash") + if not bash_path: + # Try some common locations for Git Bash + possible_paths = [ + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files (x86)\Git\bin\bash.exe", + ] + for p in possible_paths: + if os.path.exists(p): + bash_path = p + break + + if bash_path: + cmd[0] = bash_path + else: + session.error( + "Bash not found. Please ensure 'bash' is in your PATH " + "or Git Bash is installed." + ) + + session.run(*cmd, external=True) + + +def _build_artifacts(session): + """Helper to build spannerlib artifacts.""" + session.log("Building spannerlib artifacts...") + session.env["RUNNER_OS"] = platform.system() + run_bash_script(session, "./build-shared-lib.sh") + + @nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS) def system(session): """Run system tests.""" @@ -139,7 +173,8 @@ def system(session): "Credentials or emulator host must be set via environment variable" ) - copy_artifacts(session) + # Build/Copy artifacts using the script + _build_artifacts(session) session.install(*STANDARD_DEPENDENCIES, *SYSTEM_TEST_STANDARD_DEPENDENCIES) session.install("-e", ".") @@ -158,68 +193,13 @@ def system(session): ) -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. 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 - 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), - ) + _build_artifacts(session) @nox.session @@ -234,7 +214,7 @@ def build(session): session.install("build", "twine") # Run the preparation step - copy_artifacts(session) + _build_artifacts(session) # Build the wheel session.log("Building...") diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep deleted file mode 100644 index 8b1378917..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib-artifacts/.gitkeep +++ /dev/null @@ -1 +0,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 index d24a0fe36..812c1cc34 100644 --- 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 @@ -109,25 +109,46 @@ def test_initialize_lib_not_found(self, mock_lib_path): SpannerLib() def test_get_lib_filename_linux(self): - """Test _get_lib_filename on Linux.""" + """Test _get_lib_filename on Linux AMD64.""" with mock.patch("platform.system", return_value="Linux"): - # pylint: disable=protected-access - filename = SpannerLib._get_lib_filename() - assert filename == "spannerlib.so" + with mock.patch("platform.machine", return_value="x86_64"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "linux-amd64/spannerlib.so" + + def test_get_lib_filename_linux_arm64(self): + """Test _get_lib_filename on Linux ARM64.""" + with mock.patch("platform.system", return_value="Linux"): + with mock.patch("platform.machine", return_value="aarch64"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "linux-arm64/spannerlib.so" def test_get_lib_filename_darwin(self): - """Test _get_lib_filename on Darwin.""" + """Test _get_lib_filename on Darwin ARM64.""" with mock.patch("platform.system", return_value="Darwin"): - # pylint: disable=protected-access - filename = SpannerLib._get_lib_filename() - assert filename == "spannerlib.dylib" + with mock.patch("platform.machine", return_value="arm64"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "macos-arm64/spannerlib.dylib" def test_get_lib_filename_windows(self): - """Test _get_lib_filename on Windows.""" + """Test _get_lib_filename on Windows AMD64.""" with mock.patch("platform.system", return_value="Windows"): - # pylint: disable=protected-access - filename = SpannerLib._get_lib_filename() - assert filename == "spannerlib.dll" + with mock.patch("platform.machine", return_value="AMD64"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "windows-amd64/spannerlib.dll" + + def test_get_lib_filename_unsupported_arch(self): + """Test _get_lib_filename on an unsupported architecture.""" + with mock.patch("platform.system", return_value="Linux"): + with mock.patch("platform.machine", return_value="riscv64"): + with pytest.raises( + SpannerLibError, match="Unsupported architecture" + ): + # pylint: disable=protected-access + SpannerLib._get_lib_filename() def test_get_lib_filename_unsupported_os(self): """Test _get_lib_filename on an unsupported OS.""" From 3cd0db0bbf569eab2c6dc91cdba6860b2266aacc Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 8 Dec 2025 08:17:44 +0000 Subject: [PATCH 02/10] refactor: Standardize shared library platform and architecture naming conventions. --- .../spannerlib-python/build-shared-lib.sh | 22 ------------------- .../cloud/spannerlib/internal/spannerlib.py | 6 ++--- .../tests/unit/internal/test_spannerlib.py | 6 ++--- 3 files changed, 6 insertions(+), 28 deletions(-) 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 19ac0dfc4..131a18fa9 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -59,25 +59,3 @@ mkdir -p "$TARGET_LIB_DIR" echo "Copying all binaries to $TARGET_LIB_DIR..." cp -r "$SHARED_LIB_DIR/binaries/"* "$TARGET_LIB_DIR/" - -# Rename directories to match spannerlib.py expectations -# linux-x64 -> linux-amd64 -if [ -d "$TARGET_LIB_DIR/linux-x64" ]; then - echo "Renaming linux-x64 to linux-amd64" - rm -rf "$TARGET_LIB_DIR/linux-amd64" - mv "$TARGET_LIB_DIR/linux-x64" "$TARGET_LIB_DIR/linux-amd64" -fi - -# win-x64 -> windows-amd64 -if [ -d "$TARGET_LIB_DIR/win-x64" ]; then - echo "Renaming win-x64 to windows-amd64" - rm -rf "$TARGET_LIB_DIR/windows-amd64" - mv "$TARGET_LIB_DIR/win-x64" "$TARGET_LIB_DIR/windows-amd64" -fi - -# osx-arm64 -> macos-arm64 -if [ -d "$TARGET_LIB_DIR/osx-arm64" ]; then - echo "Renaming osx-arm64 to macos-arm64" - rm -rf "$TARGET_LIB_DIR/macos-arm64" - mv "$TARGET_LIB_DIR/osx-arm64" "$TARGET_LIB_DIR/macos-arm64" -fi 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 index 26cc0c954..2a99d509e 100644 --- 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 @@ -120,10 +120,10 @@ def _get_lib_filename() -> str: machine_name = platform.machine().lower() if system_name == "Windows": - os_part = "windows" + os_part = "win" ext = "dll" elif system_name == "Darwin": - os_part = "macos" + os_part = "osx" ext = "dylib" elif system_name == "Linux": os_part = "linux" @@ -134,7 +134,7 @@ def _get_lib_filename() -> str: ) if machine_name in ("amd64", "x86_64"): - arch_part = "amd64" + arch_part = "x64" elif machine_name in ("arm64", "aarch64"): arch_part = "arm64" else: 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 index 812c1cc34..f6d899f2a 100644 --- 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 @@ -114,7 +114,7 @@ def test_get_lib_filename_linux(self): with mock.patch("platform.machine", return_value="x86_64"): # pylint: disable=protected-access filename = SpannerLib._get_lib_filename() - assert filename == "linux-amd64/spannerlib.so" + assert filename == "linux-x64/spannerlib.so" def test_get_lib_filename_linux_arm64(self): """Test _get_lib_filename on Linux ARM64.""" @@ -130,7 +130,7 @@ def test_get_lib_filename_darwin(self): with mock.patch("platform.machine", return_value="arm64"): # pylint: disable=protected-access filename = SpannerLib._get_lib_filename() - assert filename == "macos-arm64/spannerlib.dylib" + assert filename == "osx-arm64/spannerlib.dylib" def test_get_lib_filename_windows(self): """Test _get_lib_filename on Windows AMD64.""" @@ -138,7 +138,7 @@ def test_get_lib_filename_windows(self): with mock.patch("platform.machine", return_value="AMD64"): # pylint: disable=protected-access filename = SpannerLib._get_lib_filename() - assert filename == "windows-amd64/spannerlib.dll" + assert filename == "win-x64/spannerlib.dll" def test_get_lib_filename_unsupported_arch(self): """Test _get_lib_filename on an unsupported architecture.""" From d0cef3beac3636fa203eb6cfe5345d60d76a4fa1 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 8 Dec 2025 10:04:47 +0000 Subject: [PATCH 03/10] feat: Clean target library directory before build and robustly locate Git Bash on Windows. --- .../spannerlib-python/build-shared-lib.sh | 1 + .../spannerlib-python/noxfile.py | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) 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 131a18fa9..d6c08f1ba 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -55,6 +55,7 @@ echo -e "PREPARING ARTIFACTS IN: $(pwd)" # However, the user requested to copy to internal/lib. TARGET_LIB_DIR="google/cloud/spannerlib/internal/lib" +rm -rf "$TARGET_LIB_DIR" mkdir -p "$TARGET_LIB_DIR" echo "Copying all binaries to $TARGET_LIB_DIR..." diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py index ffb6d1cc1..b66b9ed6b 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/noxfile.py @@ -133,10 +133,21 @@ def run_bash_script(session, script_path): if platform.system() == "Windows": bash_path = shutil.which("bash") if not bash_path: - # Try some common locations for Git Bash possible_paths = [ - r"C:\Program Files\Git\bin\bash.exe", - r"C:\Program Files (x86)\Git\bin\bash.exe", + os.path.join( + os.environ.get("ProgramFiles", r"C:\Program Files"), + "Git", + "bin", + "bash.exe", + ), + os.path.join( + os.environ.get( + "ProgramFiles(x86)", r"C:\Program Files (x86)" + ), + "Git", + "bin", + "bash.exe", + ), ] for p in possible_paths: if os.path.exists(p): From 54973f6b62497e2f6e2af1aa50a74e0850e19a14 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 8 Dec 2025 20:05:03 +0530 Subject: [PATCH 04/10] Chore: Spannerlib Python wrapper - Add workflows to build and release (#675) * feat: Add GitHub Actions workflows for building shared SpannerLib binaries and a Python wrapper, including support for Linux ARM64 and macOS AMD64. * feat: Add GitHub Actions workflows for Python Spanner library wrapper linting, unit, and system tests. * feat: Refactor shared library build process to support additional architectures, improve cross-compilation, and centralize build logic. (#677) --- .../workflows/build-shared-spanner-lib.yml | 67 ++++++++ .../python-spanner-lib-wrapper-lint.yml | 34 ++++ ...ython-spanner-lib-wrapper-system-tests.yml | 45 ++++++ .../python-spanner-lib-wrapper-unit-tests.yml | 35 +++++ .../release-python-spanner-lib-wrapper.yml | 66 ++++++++ spannerlib/shared/build-binaries.sh | 146 +++++++++++++----- .../spannerlib-python/build-shared-lib.sh | 121 +++++++++------ 7 files changed, 428 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/build-shared-spanner-lib.yml create mode 100644 .github/workflows/python-spanner-lib-wrapper-lint.yml create mode 100644 .github/workflows/python-spanner-lib-wrapper-system-tests.yml create mode 100644 .github/workflows/python-spanner-lib-wrapper-unit-tests.yml create mode 100644 .github/workflows/release-python-spanner-lib-wrapper.yml diff --git a/.github/workflows/build-shared-spanner-lib.yml b/.github/workflows/build-shared-spanner-lib.yml new file mode 100644 index 000000000..a129747ae --- /dev/null +++ b/.github/workflows/build-shared-spanner-lib.yml @@ -0,0 +1,67 @@ +name: Build Shared SpannerLib + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + matrix: + go-version: ['1.25.x'] + os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + target: linux + - os: macos-latest + target: macos + - os: windows-latest + target: windows + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Install Dependencies (Linux) + if: matrix.target == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build Binaries + shell: bash + run: | + cd spannerlib/shared + if [ "${{ matrix.target }}" == "linux" ]; then + export SKIP_MACOS=true + export SKIP_WINDOWS=true + # Enable ARM64 build + export BUILD_LINUX_ARM64=true + export CC_LINUX_ARM64=aarch64-linux-gnu-gcc + elif [ "${{ matrix.target }}" == "macos" ]; then + export SKIP_LINUX=true + export SKIP_WINDOWS=true + # Enable AMD64 build (if possible) + export BUILD_MACOS_AMD64=true + elif [ "${{ matrix.target }}" == "windows" ]; then + export SKIP_LINUX=true + export SKIP_MACOS=true + fi + ./build-binaries.sh + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: spannerlib-binaries-${{ matrix.target }} + path: spannerlib/shared/binaries/ diff --git a/.github/workflows/python-spanner-lib-wrapper-lint.yml b/.github/workflows/python-spanner-lib-wrapper-lint.yml new file mode 100644 index 000000000..1dd2992b6 --- /dev/null +++ b/.github/workflows/python-spanner-lib-wrapper-lint.yml @@ -0,0 +1,34 @@ +name: Python Wrapper Lint + +on: + push: + branches: [ "main" ] + paths: + - 'spannerlib/wrappers/spannerlib-python/**' + pull_request: + branches: [ "main" ] + paths: + - 'spannerlib/wrappers/spannerlib-python/**' + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + defaults: + run: + working-directory: spannerlib/wrappers/spannerlib-python/spannerlib-python + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install Nox + run: pip install nox + + - name: Run Lint + run: nox -s lint diff --git a/.github/workflows/python-spanner-lib-wrapper-system-tests.yml b/.github/workflows/python-spanner-lib-wrapper-system-tests.yml new file mode 100644 index 000000000..8276680c1 --- /dev/null +++ b/.github/workflows/python-spanner-lib-wrapper-system-tests.yml @@ -0,0 +1,45 @@ +name: Python Wrapper System Tests on Emulator + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + system-tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + defaults: + run: + working-directory: spannerlib/wrappers/spannerlib-python/spannerlib-python + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install Nox + run: pip install nox + + - name: Run System Tests + env: + SPANNER_EMULATOR_HOST: localhost:9010 + shell: bash + run: | + docker run -d -p 9010:9010 -p 9020:9020 gcr.io/cloud-spanner-emulator/emulator + sleep 10 + nox -s system-${{ matrix.python-version }} diff --git a/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml b/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml new file mode 100644 index 000000000..8833ebc69 --- /dev/null +++ b/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml @@ -0,0 +1,35 @@ +name: Python Wrapper Unit Tests + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: spannerlib/wrappers/spannerlib-python/spannerlib-python + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install Nox + run: pip install nox + + - name: Run Unit Tests + run: nox -s unit-3.10 diff --git a/.github/workflows/release-python-spanner-lib-wrapper.yml b/.github/workflows/release-python-spanner-lib-wrapper.yml new file mode 100644 index 000000000..95a6cf712 --- /dev/null +++ b/.github/workflows/release-python-spanner-lib-wrapper.yml @@ -0,0 +1,66 @@ +name: Build Python Wrapper + +on: + workflow_dispatch: + +jobs: + build-shared-lib: + uses: ./.github/workflows/build-shared-spanner-lib.yml + + build-wrapper: + needs: build-shared-lib + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Download Shared Library Artifacts + uses: actions/download-artifact@v4 + with: + path: binaries + pattern: spannerlib* + merge-multiple: true + + - name: Display Downloaded Files + run: ls -R binaries + + - name: Copy Binaries to Lib + run: | + mkdir -p spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/lib + cp -r binaries/* spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/lib/ + ls -R spannerlib/wrappers/spannerlib-python/spannerlib-python/google/cloud/spannerlib/internal/lib + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install Build Dependencies + run: pip install build twine + + - name: Build Wheel + run: | + cd spannerlib/wrappers/spannerlib-python/spannerlib-python + python -m build + + - name: Check Wheels + run: twine check spannerlib/wrappers/spannerlib-python/spannerlib-python/dist/* + + - name: Verify Installation + run: | + # Create a fresh virtual environment + python -m venv test-env + source test-env/bin/activate + + # Install the built wheel + pip install spannerlib/wrappers/spannerlib-python/spannerlib-python/dist/*.whl + + # Verify import + python -c "import google.cloud.spannerlib; print('Successfully imported wrapper from:', google.cloud.spannerlib.__file__)" + + - name: Upload Wheel Artifacts + uses: actions/upload-artifact@v4 + with: + name: python-wheels + path: spannerlib/wrappers/spannerlib-python/spannerlib-python/dist/* diff --git a/spannerlib/shared/build-binaries.sh b/spannerlib/shared/build-binaries.sh index 0a36f06f6..3973f371e 100755 --- a/spannerlib/shared/build-binaries.sh +++ b/spannerlib/shared/build-binaries.sh @@ -1,48 +1,116 @@ +#!/bin/bash +set -e + # Builds the shared library binaries and copies the binaries to OS/arch specific folders. -# 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. - -# The binaries are stored in the following files: -# binaries/osx-arm64/spannerlib.dylib -# binaries/linux-x64/spannerlib.so -# binaries/win-x64/spannerlib.dll - -echo "Skip macOS: $SKIP_MACOS" -echo "Skip Linux: $SKIP_LINUX" -echo "Skip Linux cross compile: $SKIP_LINUX_CROSS_COMPILE" -echo "Skip windows: $SKIP_WINDOWS" +# Binaries can be built for linux/x64, linux/arm64, darwin/arm64, darwin/amd64, and windows/x64. +# +# Environment Variables: +# SKIP_MACOS: If set, will skip all macOS builds. +# SKIP_LINUX: If set, will skip all Linux builds. +# SKIP_WINDOWS: If set, will skip all Windows builds. +# SKIP_LINUX_CROSS_COMPILE: If set, will skip the Linux x64 cross-compile (useful when running on Linux). +# BUILD_MACOS_AMD64: If set, will build for macOS AMD64. +# BUILD_LINUX_ARM64: If set, will build for Linux ARM64. +# CC_LINUX_ARM64: Compiler for Linux ARM64 (default: aarch64-linux-gnu-gcc). + +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} + +build_artifact() { + local os=$1 + local arch=$2 + local output_dir=$3 + local output_file=$4 + local cc=$5 + + log "Building for $os/$arch..." + mkdir -p "$output_dir" + + if [ -n "$cc" ]; then + export CC="$cc" + else + unset CC + fi + + GOOS="$os" GOARCH="$arch" CGO_ENABLED=1 go build -o "$output_dir/$output_file" -buildmode=c-shared shared_lib.go + log "Successfully built $output_dir/$output_file" +} +# Detect current OS to set smart defaults +CURRENT_OS=$(uname -s) + +log "Current OS: $CURRENT_OS" +log "Skip macOS: ${SKIP_MACOS:-false}" +log "Skip Linux: ${SKIP_LINUX:-false}" +log "Skip Linux cross compile: ${SKIP_LINUX_CROSS_COMPILE:-false}" +log "Skip Windows: ${SKIP_WINDOWS:-false}" + +# --- MacOS Builds --- if [ -z "$SKIP_MACOS" ]; then - echo "Building for darwin/arm64" - mkdir -p binaries/osx-arm64 - GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 go build -o binaries/osx-arm64/spannerlib.dylib -buildmode=c-shared shared_lib.go + # MacOS ARM64 (Apple Silicon) + build_artifact "darwin" "arm64" "binaries/osx-arm64" "spannerlib.dylib" "" + + # MacOS AMD64 (Intel) + if [ -n "$BUILD_MACOS_AMD64" ]; then + build_artifact "darwin" "amd64" "binaries/osx-x64" "spannerlib.dylib" "" + fi fi -if [ -z "$SKIP_LINUX_CROSS_COMPILE" ]; then - # The following software is needed for this build, assuming that the build runs on MacOS. - #brew tap SergioBenitez/osxct - #brew install x86_64-unknown-linux-gnu - echo "Building for linux/x64 (cross-compile)" - mkdir -p binaries/linux-x64 - CC=x86_64-unknown-linux-gnu-gcc GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o binaries/linux-x64/spannerlib.so -buildmode=c-shared shared_lib.go -elif [ -z "$SKIP_LINUX" ]; then - # The following commands assume that the script is running on linux/x64, or at least on some system that is able - # to compile to linux/x64 with the default C compiler on the system. - echo "Building for linux/x64" - mkdir -p binaries/linux-x64 - GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o binaries/linux-x64/spannerlib.so -buildmode=c-shared shared_lib.go +# --- Linux Builds --- +if [ -z "$SKIP_LINUX" ]; then + # Linux x64 + # Logic: If we are on Linux, we prefer native build. + # If we are NOT on Linux (e.g. Mac), we try cross-compile unless skipped. + + if [ "$CURRENT_OS" == "Linux" ]; then + # Native Linux build + build_artifact "linux" "amd64" "binaries/linux-x64" "spannerlib.so" "" + else + # Cross-compile for Linux x64 (e.g. from Mac) + if [ -z "$SKIP_LINUX_CROSS_COMPILE" ]; then + # Check for cross-compiler + if command -v x86_64-unknown-linux-gnu-gcc >/dev/null 2>&1; then + build_artifact "linux" "amd64" "binaries/linux-x64" "spannerlib.so" "x86_64-unknown-linux-gnu-gcc" + else + log "WARNING: x86_64-unknown-linux-gnu-gcc not found. Skipping Linux x64 cross-compile." + fi + elif [ -z "$SKIP_LINUX" ]; then + # Fallback to standard build if cross-compile explicitly skipped but Linux not skipped + # This might fail if no suitable compiler is found, but we'll try. + build_artifact "linux" "amd64" "binaries/linux-x64" "spannerlib.so" "" + fi + fi + + # Linux ARM64 + if [ -n "$BUILD_LINUX_ARM64" ]; then + CC_ARM64=${CC_LINUX_ARM64:-aarch64-linux-gnu-gcc} + if command -v "$CC_ARM64" >/dev/null 2>&1; then + build_artifact "linux" "arm64" "binaries/linux-arm64" "spannerlib.so" "$CC_ARM64" + else + log "WARNING: $CC_ARM64 not found. Skipping Linux ARM64 build." + fi + fi fi +# --- Windows Builds --- if [ -z "$SKIP_WINDOWS" ]; then - # This build requires mingw-w64 or a similar Windows compatible C compiler if it is being executed on a - # non-Windows environment. - # brew install mingw-w64 - echo "Building for windows/x64" - mkdir -p binaries/win-x64 - CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build -o binaries/win-x64/spannerlib.dll -buildmode=c-shared shared_lib.go + # Windows x64 + CC_WIN="x86_64-w64-mingw32-gcc" + + # If running on Windows (Git Bash/MSYS2), we might not need the cross-compiler prefix or it might be different. + # But usually 'gcc' on Windows targets Windows. + if [[ "$CURRENT_OS" == *"MINGW"* ]] || [[ "$CURRENT_OS" == *"CYGWIN"* ]] || [[ "$CURRENT_OS" == *"MSYS"* ]]; then + # Native Windows build + build_artifact "windows" "amd64" "binaries/win-x64" "spannerlib.dll" "" + else + # Cross-compile for Windows + if command -v "$CC_WIN" >/dev/null 2>&1; then + build_artifact "windows" "amd64" "binaries/win-x64" "spannerlib.dll" "$CC_WIN" + else + log "WARNING: $CC_WIN not found. Skipping Windows x64 build." + fi + fi fi + +log "Build process completed." 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 d6c08f1ba..394696d9e 100755 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/build-shared-lib.sh @@ -1,62 +1,89 @@ #!/bin/bash +set -e # 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 -set -e +# This script delegates the actual build process to spannerlib/shared/build-binaries.sh +# and then copies the artifacts to google/cloud/spannerlib/internal/lib. -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 +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" +} -SHARED_LIB_DIR="../../../shared" -TARGET_WRAPPER_DIR="../wrappers/spannerlib-python/spannerlib-python" -# We are not using ARTIFACTS_DIR anymore for the final destination. +log "Starting Spannerlib Shared Library Build for Python Wrapper..." -cd "$SHARED_LIB_DIR" || exit 1 +# Resolve absolute paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SHARED_LIB_DIR="$(cd "$SCRIPT_DIR/../../../shared" && pwd)" +TARGET_LIB_DIR="$SCRIPT_DIR/google/cloud/spannerlib/internal/lib" -./build-binaries.sh +log "Script Directory: $SCRIPT_DIR" +log "Shared Lib Directory: $SHARED_LIB_DIR" +log "Target Lib Directory: $TARGET_LIB_DIR" +log "Runner OS: ${RUNNER_OS:-Unknown}" -echo -e "PREPARING ARTIFACTS IN: $(pwd)" -# Navigate to the correct wrapper directory -cd "$TARGET_WRAPPER_DIR" || exit 1 +# Auto-detect RUNNER_OS if not set (e.g. local run) +if [ -z "$RUNNER_OS" ]; then + case "$(uname -s)" in + Linux*) RUNNER_OS="Linux";; + Darwin*) RUNNER_OS="macOS";; + CYGWIN*|MINGW*|MSYS*) RUNNER_OS="Windows";; + *) RUNNER_OS="Unknown";; + esac + log "Auto-detected RUNNER_OS: $RUNNER_OS" +fi -echo -e "PREPARING ARTIFACTS IN: $(pwd)" +# Determine which builds to skip/enable based on the runner OS. +# This is primarily for CI (GitHub Actions). Local runs might not have RUNNER_OS set. +if [ -n "$RUNNER_OS" ]; then + if [[ "$RUNNER_OS" == "Windows" ]] || [[ "$RUNNER_OS" == "Windows_NT" ]]; then + # Windows runners usually can't cross-compile to Linux/Mac easily without extra setup + export SKIP_MACOS=true + export SKIP_LINUX=true + export SKIP_LINUX_CROSS_COMPILE=true + elif [[ "$RUNNER_OS" == "macOS" ]]; then + # macOS can cross-compile to Linux x64 (if toolchain exists) but not usually to Windows or Linux ARM64 easily + # We skip the 'native' Linux build + export SKIP_LINUX=true + # We might want to enable macOS AMD64 build if running on ARM64, or vice versa + # For now, let's assume we want both if possible, or rely on build-binaries.sh defaults + export BUILD_MACOS_AMD64=true + elif [[ "$RUNNER_OS" == "Linux" ]]; then + # Linux runners + export SKIP_MACOS=true + export SKIP_LINUX_CROSS_COMPILE=true + # Enable Linux ARM64 build if cross-compiler is available (checked in build-binaries.sh) + export BUILD_LINUX_ARM64=true + fi +fi -# However, the user requested to copy to internal/lib. +# Execute the shared library build script +log "Executing build-binaries.sh in $SHARED_LIB_DIR..." +pushd "$SHARED_LIB_DIR" > /dev/null +./build-binaries.sh +popd > /dev/null -TARGET_LIB_DIR="google/cloud/spannerlib/internal/lib" -rm -rf "$TARGET_LIB_DIR" +# Prepare target directory +if [ -d "$TARGET_LIB_DIR" ]; then + log "Cleaning up existing target directory: $TARGET_LIB_DIR" + rm -rf "$TARGET_LIB_DIR" +fi mkdir -p "$TARGET_LIB_DIR" -echo "Copying all binaries to $TARGET_LIB_DIR..." -cp -r "$SHARED_LIB_DIR/binaries/"* "$TARGET_LIB_DIR/" +# Copy artifacts +SOURCE_BINARIES="$SHARED_LIB_DIR/binaries" + +if [ -d "$SOURCE_BINARIES" ] && [ "$(ls -A $SOURCE_BINARIES)" ]; then + log "Copying binaries from $SOURCE_BINARIES to $TARGET_LIB_DIR..." + cp -r "$SOURCE_BINARIES/"* "$TARGET_LIB_DIR/" + log "Artifacts copied successfully." + ls -R "$TARGET_LIB_DIR" +else + log "WARNING: No binaries found in $SOURCE_BINARIES. The build might have skipped everything or failed silently." + # We don't exit with error here because sometimes we might run this in a context where + # we expect no binaries (e.g. linting only), but usually this is an issue. + # However, since set -e is on, build-binaries.sh should have failed if it errored. +fi + +log "Build and copy process completed." From 86ea1bb5821dfc01ca0e0a38f9cd43a2bb02c697 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Mon, 8 Dec 2025 14:38:25 +0000 Subject: [PATCH 05/10] build: Add contents: read permission to Python wrapper workflows. --- .github/workflows/python-spanner-lib-wrapper-lint.yml | 3 +++ .github/workflows/python-spanner-lib-wrapper-system-tests.yml | 3 +++ .github/workflows/python-spanner-lib-wrapper-unit-tests.yml | 3 +++ .github/workflows/release-python-spanner-lib-wrapper.yml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/.github/workflows/python-spanner-lib-wrapper-lint.yml b/.github/workflows/python-spanner-lib-wrapper-lint.yml index 1dd2992b6..c82150874 100644 --- a/.github/workflows/python-spanner-lib-wrapper-lint.yml +++ b/.github/workflows/python-spanner-lib-wrapper-lint.yml @@ -11,6 +11,9 @@ on: - 'spannerlib/wrappers/spannerlib-python/**' workflow_dispatch: +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/python-spanner-lib-wrapper-system-tests.yml b/.github/workflows/python-spanner-lib-wrapper-system-tests.yml index 8276680c1..6a6bdbdc8 100644 --- a/.github/workflows/python-spanner-lib-wrapper-system-tests.yml +++ b/.github/workflows/python-spanner-lib-wrapper-system-tests.yml @@ -7,6 +7,9 @@ on: branches: [ "main" ] workflow_dispatch: +permissions: + contents: read + jobs: system-tests: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml b/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml index 8833ebc69..07c163410 100644 --- a/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml +++ b/.github/workflows/python-spanner-lib-wrapper-unit-tests.yml @@ -7,6 +7,9 @@ on: branches: [ "main" ] workflow_dispatch: +permissions: + contents: read + jobs: unit-tests: runs-on: ubuntu-latest diff --git a/.github/workflows/release-python-spanner-lib-wrapper.yml b/.github/workflows/release-python-spanner-lib-wrapper.yml index 95a6cf712..373c20aff 100644 --- a/.github/workflows/release-python-spanner-lib-wrapper.yml +++ b/.github/workflows/release-python-spanner-lib-wrapper.yml @@ -3,6 +3,9 @@ name: Build Python Wrapper on: workflow_dispatch: +permissions: + contents: read + jobs: build-shared-lib: uses: ./.github/workflows/build-shared-spanner-lib.yml From a92e859d5afa601d311d58e5debc679fb2adf26f Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 9 Dec 2025 05:11:32 +0000 Subject: [PATCH 06/10] test: Add _get_lib_filename test for Darwin x86_64. --- .../tests/unit/internal/test_spannerlib.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index f6d899f2a..8f74ceba1 100644 --- 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 @@ -132,6 +132,14 @@ def test_get_lib_filename_darwin(self): filename = SpannerLib._get_lib_filename() assert filename == "osx-arm64/spannerlib.dylib" + def test_get_lib_filename_darwin_amd64(self): + """Test _get_lib_filename on Darwin AMD64.""" + with mock.patch("platform.system", return_value="Darwin"): + with mock.patch("platform.machine", return_value="x86_64"): + # pylint: disable=protected-access + filename = SpannerLib._get_lib_filename() + assert filename == "osx-x64/spannerlib.dylib" + def test_get_lib_filename_windows(self): """Test _get_lib_filename on Windows AMD64.""" with mock.patch("platform.system", return_value="Windows"): From faeeadcb91a4c154787f47ee53a511935e80b2e6 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 9 Dec 2025 07:07:06 +0000 Subject: [PATCH 07/10] feat: Add PyPI publishing to the Python wrapper release workflow. --- .../release-python-spanner-lib-wrapper.yml | 19 +++++++++++++++++-- .../spannerlib-python/pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-python-spanner-lib-wrapper.yml b/.github/workflows/release-python-spanner-lib-wrapper.yml index 373c20aff..0c41cd1f6 100644 --- a/.github/workflows/release-python-spanner-lib-wrapper.yml +++ b/.github/workflows/release-python-spanner-lib-wrapper.yml @@ -1,19 +1,27 @@ -name: Build Python Wrapper +name: Build and Release Python Wrapper on: workflow_dispatch: permissions: + id-token: write contents: read jobs: build-shared-lib: uses: ./.github/workflows/build-shared-spanner-lib.yml - build-wrapper: + build-and-publish-wrapper: needs: build-shared-lib runs-on: ubuntu-latest + environment: + name: pypi + url: https://test.pypi.org/p/spannerlib-python timeout-minutes: 10 + permissions: + id-token: write + contents: read + steps: - uses: actions/checkout@v4 @@ -67,3 +75,10 @@ jobs: with: name: python-wheels path: spannerlib/wrappers/spannerlib-python/spannerlib-python/dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: spannerlib/wrappers/spannerlib-python/spannerlib-python/dist/ + verbose: true + repository-url: https://test.pypi.org/legacy/ diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml index b0fb646cd..6430c7561 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=68.0"] build-backend = "setuptools.build_meta" [project] -name = "spannerlib" +name = "spannerlib-python" dynamic = ["version"] authors = [ { name="Google LLC", email="googleapis-packages@google.com" }, From 7d10ac3ff37f140be9be0e77825450157fe7e171 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 9 Dec 2025 07:24:01 +0000 Subject: [PATCH 08/10] build: Remove `has_ext_modules` parameter from setup.py. --- .../spannerlib-python/setup.py | 1 - .../spannerlib_python-0.1.0/LICENSE | 202 ++++++ .../spannerlib_python-0.1.0/MANIFEST.in | 1 + .../spannerlib_python-0.1.0/PKG-INFO | 157 +++++ .../spannerlib_python-0.1.0/README.md | 131 ++++ .../google/cloud/spannerlib/__init__.py | 32 + .../spannerlib/abstract_library_object.py | 140 ++++ .../google/cloud/spannerlib/connection.py | 255 +++++++ .../cloud/spannerlib/internal/__init__.py | 31 + .../cloud/spannerlib/internal/errors.py | 83 +++ .../cloud/spannerlib/internal/message.py | 186 ++++++ .../cloud/spannerlib/internal/spannerlib.py | 628 ++++++++++++++++++ .../internal/spannerlib_protocol.py | 112 ++++ .../google/cloud/spannerlib/internal/types.py | 169 +++++ .../google/cloud/spannerlib/pool.py | 99 +++ .../google/cloud/spannerlib/rows.py | 207 ++++++ .../spannerlib_python-0.1.0/pyproject.toml | 48 ++ .../spannerlib_python-0.1.0/setup.cfg | 4 + .../spannerlib_python-0.1.0/setup.py | 7 + 19 files changed, 2492 insertions(+), 1 deletion(-) create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/MANIFEST.in create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/pyproject.toml create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.cfg create mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py index 6d4283230..d31e7ddcc 100644 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/setup.py @@ -3,6 +3,5 @@ setup( - has_ext_modules=lambda: True, include_package_data=True, ) diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/MANIFEST.in b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/MANIFEST.in new file mode 100644 index 000000000..d7e007a8c --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/MANIFEST.in @@ -0,0 +1 @@ +recursive-include google/cloud/spannerlib/internal/lib * diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO new file mode 100644 index 000000000..392b081ad --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO @@ -0,0 +1,157 @@ +Metadata-Version: 2.4 +Name: spannerlib-python +Version: 0.1.0 +Summary: A Python wrapper for the Go spannerlib. This is an internal library that can make breaking changes without prior notice. +Author-email: Google LLC +License-Expression: Apache-2.0 +Project-URL: Homepage, https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md +Project-URL: Repository, https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python/spannerlib-python +Classifier: Development Status :: 1 - Planning +Classifier: Intended Audience :: Developers +Classifier: Topic :: Software Development :: Libraries +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: google-cloud-spanner +Provides-Extra: dev +Requires-Dist: pytest; extra == "dev" +Requires-Dist: nox; extra == "dev" +Dynamic: license-file + +# 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. + +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-*-*.whl +tar -tvzf dist/spannerlib-*.tar.gz +``` + +**Install locally** + +```bash +pip3 install -e . +``` + + diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md new file mode 100644 index 000000000..084fcf237 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md @@ -0,0 +1,131 @@ +# 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. + +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-*-*.whl +tar -tvzf dist/spannerlib-*.tar.gz +``` + +**Install locally** + +```bash +pip3 install -e . +``` + + diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py new file mode 100644 index 000000000..ffa40216d --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py @@ -0,0 +1,32 @@ +# 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.""" +import logging +from typing import Final + +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/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py new file mode 100644 index 000000000..1c11ed9a8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py new file mode 100644 index 000000000..7bb0be71a --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py new file mode 100644 index 000000000..7ba192ed8 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py @@ -0,0 +1,31 @@ +# 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 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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py new file mode 100644 index 000000000..45ee52b91 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py new file mode 100644 index 000000000..1a0360ff1 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py new file mode 100644 index 000000000..2a99d509e --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py @@ -0,0 +1,628 @@ +# 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 + and architecture. + """ + system_name = platform.system() + machine_name = platform.machine().lower() + + if system_name == "Windows": + os_part = "win" + ext = "dll" + elif system_name == "Darwin": + os_part = "osx" + ext = "dylib" + elif system_name == "Linux": + os_part = "linux" + ext = "so" + else: + raise SpannerLibError( + f"Unsupported operating system: {system_name}" + ) + + if machine_name in ("amd64", "x86_64"): + arch_part = "x64" + elif machine_name in ("arm64", "aarch64"): + arch_part = "arm64" + else: + raise SpannerLibError(f"Unsupported architecture: {machine_name}") + + return f"{os_part}-{arch_part}/spannerlib.{ext}" + + 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 + + # 16. NextResultSet + # Corresponds to: + # NextResultSet_return NextResultSet(GoInt64 poolId, + # GoInt64 connId, GoInt64 rowsId) + if hasattr(lib, "NextResultSet"): + lib.NextResultSet.argtypes = [ + ctypes.c_longlong, + ctypes.c_longlong, + ctypes.c_longlong, + ] + lib.NextResultSet.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 next_result_set( + self, + pool_handle: int, + conn_handle: int, + rows_handle: int, + ) -> Message: + """Calls the NextResultSet function from the shared library. + + Args: + pool_handle: The pool ID. + conn_handle: The connection ID. + rows_handle: The rows ID. + + Returns: + Message: Returns the ResultSetMetadata of the next result set, + or None if there are no more result sets. + """ + msg = self.lib.NextResultSet( + ctypes.c_longlong(pool_handle), + ctypes.c_longlong(conn_handle), + ctypes.c_longlong(rows_handle), + ) + 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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py new file mode 100644 index 000000000..cfb704b20 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py @@ -0,0 +1,112 @@ +# 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 next_result_set( + self, pool_handle: int, conn_handle: int, rows_handle: int + ) -> Message: + """Calls the NextResultSet 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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py new file mode 100644 index 000000000..877cf5437 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py new file mode 100644 index 000000000..39e67b1a9 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/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/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py new file mode 100644 index 000000000..ecad82d7b --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py @@ -0,0 +1,207 @@ +# 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, Optional + +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 next_result_set(self) -> Optional[ResultSetMetadata]: + """Advances to the next result set. + + Returns: + ResultSetMetadata if there is a next result set, None otherwise. + + Raises: + SpannerLibError: If the Rows object is closed. + SpannerLibraryError: If the Go library call fails. + """ + if self.closed: + raise SpannerLibError("Rows object is closed.") + + logger.debug("Advancing to next result set for Rows ID: %d", self.oid) + with self.spannerlib.next_result_set( + 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 next result set metadata: %s", e + ) + raise SpannerLibError( + f"Failed to parse next result set metadata: {e}" + ) + 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/spannerlib_python-0.1.0/pyproject.toml b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/pyproject.toml new file mode 100644 index 000000000..6430c7561 --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "spannerlib-python" +dynamic = ["version"] +authors = [ + { name="Google LLC", email="googleapis-packages@google.com" }, +] +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.10" +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "Programming Language :: Python :: 3 :: Only", + "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/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/spannerlib_python-0.1.0/setup.cfg b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.cfg new file mode 100644 index 000000000..8bfd5a12f --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.cfg @@ -0,0 +1,4 @@ +[egg_info] +tag_build = +tag_date = 0 + diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py new file mode 100644 index 000000000..d31e7ddcc --- /dev/null +++ b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py @@ -0,0 +1,7 @@ +"""Setup script for spannerlib-python package.""" +from setuptools import setup + + +setup( + include_package_data=True, +) From fc8368ff8f89d71cf03d29f5371eb4253f921b97 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 9 Dec 2025 07:26:54 +0000 Subject: [PATCH 09/10] chore: remove spannerlib-python 0.1.0 wrapper files and related configurations --- .../spannerlib_python-0.1.0/LICENSE | 202 ------ .../spannerlib_python-0.1.0/MANIFEST.in | 1 - .../spannerlib_python-0.1.0/PKG-INFO | 157 ----- .../spannerlib_python-0.1.0/README.md | 131 ---- .../google/cloud/spannerlib/__init__.py | 32 - .../spannerlib/abstract_library_object.py | 140 ---- .../google/cloud/spannerlib/connection.py | 255 ------- .../cloud/spannerlib/internal/__init__.py | 31 - .../cloud/spannerlib/internal/errors.py | 83 --- .../cloud/spannerlib/internal/message.py | 186 ------ .../cloud/spannerlib/internal/spannerlib.py | 628 ------------------ .../internal/spannerlib_protocol.py | 112 ---- .../google/cloud/spannerlib/internal/types.py | 169 ----- .../google/cloud/spannerlib/pool.py | 99 --- .../google/cloud/spannerlib/rows.py | 207 ------ .../spannerlib_python-0.1.0/pyproject.toml | 48 -- .../spannerlib_python-0.1.0/setup.cfg | 4 - .../spannerlib_python-0.1.0/setup.py | 7 - 18 files changed, 2492 deletions(-) delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/MANIFEST.in delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/pyproject.toml delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.cfg delete mode 100644 spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE deleted file mode 100644 index d64569567..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - 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/spannerlib_python-0.1.0/MANIFEST.in b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/MANIFEST.in deleted file mode 100644 index d7e007a8c..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -recursive-include google/cloud/spannerlib/internal/lib * diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO deleted file mode 100644 index 392b081ad..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/PKG-INFO +++ /dev/null @@ -1,157 +0,0 @@ -Metadata-Version: 2.4 -Name: spannerlib-python -Version: 0.1.0 -Summary: A Python wrapper for the Go spannerlib. This is an internal library that can make breaking changes without prior notice. -Author-email: Google LLC -License-Expression: Apache-2.0 -Project-URL: Homepage, https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python/spannerlib-python/README.md -Project-URL: Repository, https://github.com/googleapis/go-sql-spanner/tree/main/spannerlib/wrappers/spannerlib-python/spannerlib-python -Classifier: Development Status :: 1 - Planning -Classifier: Intended Audience :: Developers -Classifier: Topic :: Software Development :: Libraries -Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Programming Language :: Python :: 3.10 -Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: 3.12 -Classifier: Programming Language :: Python :: 3.13 -Classifier: Programming Language :: Python :: 3.14 -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -License-File: LICENSE -Requires-Dist: google-cloud-spanner -Provides-Extra: dev -Requires-Dist: pytest; extra == "dev" -Requires-Dist: nox; extra == "dev" -Dynamic: license-file - -# 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. - -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-*-*.whl -tar -tvzf dist/spannerlib-*.tar.gz -``` - -**Install locally** - -```bash -pip3 install -e . -``` - - diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md deleted file mode 100644 index 084fcf237..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# 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. - -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-*-*.whl -tar -tvzf dist/spannerlib-*.tar.gz -``` - -**Install locally** - -```bash -pip3 install -e . -``` - - diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py deleted file mode 100644 index ffa40216d..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/__init__.py +++ /dev/null @@ -1,32 +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. - -"""Python wrapper for the Spanner Go library.""" -import logging -from typing import Final - -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/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py deleted file mode 100644 index 1c11ed9a8..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/abstract_library_object.py +++ /dev/null @@ -1,140 +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 -# -# 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/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py deleted file mode 100644 index 7bb0be71a..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/connection.py +++ /dev/null @@ -1,255 +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 -# -# 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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py deleted file mode 100644 index 7ba192ed8..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/__init__.py +++ /dev/null @@ -1,31 +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. - -"""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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py deleted file mode 100644 index 45ee52b91..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/errors.py +++ /dev/null @@ -1,83 +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. -"""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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py deleted file mode 100644 index 1a0360ff1..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/message.py +++ /dev/null @@ -1,186 +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. -"""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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py deleted file mode 100644 index 2a99d509e..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib.py +++ /dev/null @@ -1,628 +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. -"""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 - and architecture. - """ - system_name = platform.system() - machine_name = platform.machine().lower() - - if system_name == "Windows": - os_part = "win" - ext = "dll" - elif system_name == "Darwin": - os_part = "osx" - ext = "dylib" - elif system_name == "Linux": - os_part = "linux" - ext = "so" - else: - raise SpannerLibError( - f"Unsupported operating system: {system_name}" - ) - - if machine_name in ("amd64", "x86_64"): - arch_part = "x64" - elif machine_name in ("arm64", "aarch64"): - arch_part = "arm64" - else: - raise SpannerLibError(f"Unsupported architecture: {machine_name}") - - return f"{os_part}-{arch_part}/spannerlib.{ext}" - - 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 - - # 16. NextResultSet - # Corresponds to: - # NextResultSet_return NextResultSet(GoInt64 poolId, - # GoInt64 connId, GoInt64 rowsId) - if hasattr(lib, "NextResultSet"): - lib.NextResultSet.argtypes = [ - ctypes.c_longlong, - ctypes.c_longlong, - ctypes.c_longlong, - ] - lib.NextResultSet.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 next_result_set( - self, - pool_handle: int, - conn_handle: int, - rows_handle: int, - ) -> Message: - """Calls the NextResultSet function from the shared library. - - Args: - pool_handle: The pool ID. - conn_handle: The connection ID. - rows_handle: The rows ID. - - Returns: - Message: Returns the ResultSetMetadata of the next result set, - or None if there are no more result sets. - """ - msg = self.lib.NextResultSet( - ctypes.c_longlong(pool_handle), - ctypes.c_longlong(conn_handle), - ctypes.c_longlong(rows_handle), - ) - 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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py deleted file mode 100644 index cfb704b20..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/spannerlib_protocol.py +++ /dev/null @@ -1,112 +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 -# -# 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 next_result_set( - self, pool_handle: int, conn_handle: int, rows_handle: int - ) -> Message: - """Calls the NextResultSet 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/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py deleted file mode 100644 index 877cf5437..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/internal/types.py +++ /dev/null @@ -1,169 +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. -"""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/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py deleted file mode 100644 index 39e67b1a9..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/pool.py +++ /dev/null @@ -1,99 +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 -# -# 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/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py deleted file mode 100644 index ecad82d7b..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/google/cloud/spannerlib/rows.py +++ /dev/null @@ -1,207 +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 -# -# 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, Optional - -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 next_result_set(self) -> Optional[ResultSetMetadata]: - """Advances to the next result set. - - Returns: - ResultSetMetadata if there is a next result set, None otherwise. - - Raises: - SpannerLibError: If the Rows object is closed. - SpannerLibraryError: If the Go library call fails. - """ - if self.closed: - raise SpannerLibError("Rows object is closed.") - - logger.debug("Advancing to next result set for Rows ID: %d", self.oid) - with self.spannerlib.next_result_set( - 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 next result set metadata: %s", e - ) - raise SpannerLibError( - f"Failed to parse next result set metadata: {e}" - ) - 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/spannerlib_python-0.1.0/pyproject.toml b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/pyproject.toml deleted file mode 100644 index 6430c7561..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/pyproject.toml +++ /dev/null @@ -1,48 +0,0 @@ -[build-system] -requires = ["setuptools>=68.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "spannerlib-python" -dynamic = ["version"] -authors = [ - { name="Google LLC", email="googleapis-packages@google.com" }, -] -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.10" -classifiers = [ - "Development Status :: 1 - Planning", - "Intended Audience :: Developers", - "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 3 :: Only", - "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/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/spannerlib_python-0.1.0/setup.cfg b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.cfg deleted file mode 100644 index 8bfd5a12f..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[egg_info] -tag_build = -tag_date = 0 - diff --git a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py b/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py deleted file mode 100644 index d31e7ddcc..000000000 --- a/spannerlib/wrappers/spannerlib-python/spannerlib-python/spannerlib_python-0.1.0/setup.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Setup script for spannerlib-python package.""" -from setuptools import setup - - -setup( - include_package_data=True, -) From a5cab15c6f7ace6980b0bfb7be9352b62f400fb8 Mon Sep 17 00:00:00 2001 From: Sanjeev Bhatt Date: Tue, 9 Dec 2025 07:31:21 +0000 Subject: [PATCH 10/10] ci: Update Python wrapper release workflow to publish to PyPI instead of TestPyPI. --- .github/workflows/release-python-spanner-lib-wrapper.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release-python-spanner-lib-wrapper.yml b/.github/workflows/release-python-spanner-lib-wrapper.yml index 0c41cd1f6..e905c1f52 100644 --- a/.github/workflows/release-python-spanner-lib-wrapper.yml +++ b/.github/workflows/release-python-spanner-lib-wrapper.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://test.pypi.org/p/spannerlib-python + url: https://pypi.org/p/spannerlib-python timeout-minutes: 10 permissions: id-token: write @@ -81,4 +81,3 @@ jobs: with: packages-dir: spannerlib/wrappers/spannerlib-python/spannerlib-python/dist/ verbose: true - repository-url: https://test.pypi.org/legacy/