From 1f8bb31d874ec790eb595b5d7a2b65f392a77238 Mon Sep 17 00:00:00 2001 From: Eno Compton Date: Mon, 30 Mar 2026 21:17:23 -0600 Subject: [PATCH] chore: port build tooling to uv This commit replaces the existing build-tooling with uv. In addition, all CI operations are captured in scripts to make it easier to run linting, formatting, unit tests, and system tests locally without having to remember the exact set of commands. Fixes #443. --- .github/workflows/coverage.yaml | 19 +-- .github/workflows/lint.yaml | 8 +- .github/workflows/tests.yaml | 13 +- .gitignore | 4 + .../cloud/alloydbconnector/async_connector.py | 11 +- google/cloud/alloydbconnector/client.py | 10 +- noxfile.py | 135 ------------------ pyproject.toml | 26 +++- requirements-test.txt | 11 -- requirements.txt | 5 - scripts/coverage.sh | 30 ++++ scripts/format.sh | 19 +++ scripts/lint.sh | 22 +++ scripts/test_system.sh | 21 +++ scripts/test_unit.sh | 21 +++ tests/unit/mocks.py | 10 +- tests/unit/test_client.py | 7 - 17 files changed, 179 insertions(+), 193 deletions(-) delete mode 100644 noxfile.py delete mode 100644 requirements-test.txt delete mode 100644 requirements.txt create mode 100755 scripts/coverage.sh create mode 100755 scripts/format.sh create mode 100755 scripts/lint.sh create mode 100755 scripts/test_system.sh create mode 100755 scripts/test_unit.sh diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 4d711fc7..d9ea9540 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -33,7 +33,8 @@ jobs: with: python-version: "3.14" - - run: pip install nox coverage + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - name: Checkout PR branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -41,17 +42,5 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - - name: Calculate PR code coverage - run: | - nox --sessions unit-3.14 - coverage report --show-missing - export PR_COVER=$(coverage report | awk '$1 == "TOTAL" {print $NF+0}') - echo "PR_COVER=$PR_COVER" >> $GITHUB_ENV - coverage erase - - - name: Verify code coverage. If your reading this and the step has failed, please add tests to cover your changes. - run: | - echo "PULL REQUEST CODE COVERAGE is ${{ env.PR_COVER }}%" - if [ "${{ env.PR_COVER }}" -lt "90" ]; then - exit 1; - fi + - name: Verify code coverage + run: ./scripts/coverage.sh diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c7281dfb..dd64b57c 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -34,8 +34,8 @@ jobs: with: python-version: "3.14" - - name: Install nox - run: pip install nox + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -43,5 +43,5 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - - name: Run nox lint session - run: nox -s lint + - name: Run lint + run: ./scripts/lint.sh diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 30b1deb7..5bf6f167 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -52,8 +52,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install nox - run: pip install nox + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - id: 'auth' name: Authenticate to Google Cloud @@ -66,7 +66,8 @@ jobs: access_token_lifetime: 600s - name: Run tests - run: nox -s unit-${{ matrix.python-version }} + shell: bash + run: ./scripts/test_unit.sh - name: FlakyBot (Linux) # only run flakybot on periodic (schedule) and continuous (push) events @@ -118,8 +119,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install nox - run: pip install nox + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 - id: 'auth' name: 'Authenticate to Google Cloud' @@ -149,7 +150,7 @@ jobs: ALLOYDB_INSTANCE_IP: '${{ steps.secrets.outputs.ALLOYDB_INSTANCE_IP }}' ALLOYDB_INSTANCE_URI: '${{ steps.secrets.outputs.ALLOYDB_INSTANCE_URI }}' ALLOYDB_PSC_INSTANCE_URI: '${{ steps.secrets.outputs.ALLOYDB_PSC_INSTANCE_URI }}' - run: nox -s system-${{ matrix.python-version }} + run: ./scripts/test_system.sh - name: FlakyBot (Linux) # only run flakybot on periodic (schedule) and continuous (push) events diff --git a/.gitignore b/.gitignore index b4243ced..329a6630 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,10 @@ docs.metadata # Virtual environment env/ +.venv + +# uv lock file (library — consumers resolve their own deps) +uv.lock # Test logs coverage.xml diff --git a/google/cloud/alloydbconnector/async_connector.py b/google/cloud/alloydbconnector/async_connector.py index b6de4116..96c265c8 100644 --- a/google/cloud/alloydbconnector/async_connector.py +++ b/google/cloud/alloydbconnector/async_connector.py @@ -119,10 +119,17 @@ def __init__( # check if AsyncConnector is being initialized with event loop running # Otherwise we will lazy init keys + self._keys: Optional[asyncio.Task] = None try: - self._keys: Optional[asyncio.Task] = asyncio.create_task(generate_keys()) + # Try to get the running loop before creating a task. The call here + # will raise a RuntimeError if no loop is running. Without calling + # get_running_loop, a direct call to create_task would also raise + # an exception but it would leak the generate_keys coroutine. To + # avoid leaking the coroutine, we call get_running_loop first. + asyncio.get_running_loop() + self._keys = asyncio.create_task(generate_keys()) except RuntimeError: - self._keys = None + pass self._client: Optional[AlloyDBClient] = None self._closed = False diff --git a/google/cloud/alloydbconnector/client.py b/google/cloud/alloydbconnector/client.py index 88b455e9..1eaf2076 100644 --- a/google/cloud/alloydbconnector/client.py +++ b/google/cloud/alloydbconnector/client.py @@ -18,6 +18,7 @@ import logging from typing import TYPE_CHECKING from typing import Optional +from typing import Union from cryptography import x509 @@ -95,7 +96,7 @@ def __init__( # API, even from multiple threads, need to be made to a single-event # loop. See https://github.com/GoogleCloudPlatform/alloydb-python-connector/issues/435 # for more details. - self._is_sync = False + self._client: Union[v1beta.AlloyDBAdminClient, v1beta.AlloyDBAdminAsyncClient] if client: self._client = client elif driver == "pg8000" or driver == "psycopg": @@ -110,7 +111,6 @@ def __init__( user_agent=user_agent, ), ) - self._is_sync = True else: self._client = v1beta.AlloyDBAdminAsyncClient( credentials=credentials, @@ -163,7 +163,7 @@ async def _get_metadata( ) req = v1beta.GetConnectionInfoRequest(parent=parent) - if self._is_sync: + if isinstance(self._client, v1beta.AlloyDBAdminClient): resp = self._client.get_connection_info(request=req) else: resp = await self._client.get_connection_info(request=req) @@ -213,11 +213,11 @@ async def _get_client_certificate( public_key=pub_key, use_metadata_exchange=self._use_metadata, ) - if self._is_sync: + if isinstance(self._client, v1beta.AlloyDBAdminClient): resp = self._client.generate_client_certificate(request=req) else: resp = await self._client.generate_client_certificate(request=req) - return (resp.ca_cert, resp.pem_certificate_chain) + return (resp.ca_cert, list(resp.pem_certificate_chain)) async def get_connection_info( self, diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index 5eae8d07..00000000 --- a/noxfile.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import absolute_import - -import os - -import nox - -RUFF_VERSION = "ruff==0.11.2" -LINT_PATHS = ["google", "tests", "noxfile.py"] - -SYSTEM_TEST_PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"] -UNIT_TEST_PYTHON_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"] - - -@nox.session -def lint(session): - """Run linters. - - Returns a failure if the linters find linting errors or sufficiently - serious code quality issues. - """ - session.install("-r", "requirements-test.txt") - session.install("-r", "requirements.txt") - session.install( - RUFF_VERSION, - "mypy", - "build", - "twine", - ) - session.run( - "ruff", - "format", - "--check", - "--diff", - *LINT_PATHS, - ) - session.run( - "ruff", - "check", - *LINT_PATHS, - ) - session.run( - "mypy", - "-p", - "google", - "--install-types", - "--non-interactive", - "--show-traceback", - ) - # verify that pyproject.toml is valid - session.run("python", "-m", "build", "--sdist") - session.run("twine", "check", "--strict", "dist/*") - - -@nox.session -def format(session): - """Format code with ruff.""" - session.install(RUFF_VERSION) - session.run( - "ruff", - "check", - "--fix", - *LINT_PATHS, - ) - session.run( - "ruff", - "format", - *LINT_PATHS, - ) - - -@nox.session() -def cover(session): - """Run the final coverage report. - - This outputs the coverage report aggregating coverage from the unit - test runs (not system test runs), and then erases coverage data. - """ - session.install("coverage", "pytest-cov") - session.run("coverage", "report", "--show-missing", "--fail-under=0") - - session.run("coverage", "erase") - - -def default(session, path): - # Install all test dependencies, then install this package in-place. - session.install("-r", "requirements-test.txt") - session.install(".") - session.install("-r", "requirements.txt") - # Run pytest with coverage. - # Using the coverage command instead of `pytest --cov`, because - # `pytest ---cov` causes the module to be initialized twice, which returns - # this error: "ImportError: PyO3 modules compiled for CPython 3.8 or older - # may only be initialized once per interpreter process". More info about - # this is stated here: https://github.com/pytest-dev/pytest-cov/issues/614. - session.run( - "coverage", - "run", - "--include=*/google/cloud/alloydbconnector/*.py", - "-m", - "pytest", - "-v", - path, - *session.posargs, - ) - session.run( - "coverage", - "xml", - "-o", - "sponge_log.xml", - ) - - -@nox.session(python=UNIT_TEST_PYTHON_VERSIONS) -def unit(session): - """Run the unit test suite.""" - default(session, os.path.join("tests", "unit")) - - -@nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS) -def system(session): - default(session, os.path.join("tests", "system")) diff --git a/pyproject.toml b/pyproject.toml index 89387b12..bf2ede8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,31 @@ pg8000 = ["pg8000>=1.31.1"] asyncpg = ["asyncpg>=0.31.0"] psycopg = ["psycopg>=3.1.0"] +[dependency-groups] +test = [ + "asyncpg>=0.31.0", + "mock>=5.2.0", + "pg8000>=1.31.5", + "psycopg2-binary>=2.9.11", + "psycopg>=3.3.3", + "psycopg-binary>=3.3.3", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "SQLAlchemy[asyncio]>=2.0.48", + "aioresponses>=0.7.8", + "coverage", +] +lint = [ + "ruff==0.11.2", + "mypy", + "types-aiofiles", + "types-requests", + "build", + "twine", + {include-group = "test"}, +] + [tool.setuptools.dynamic] version = { attr = "google.cloud.alloydbconnector.version.__version__" } @@ -98,7 +123,6 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"noxfile.py" = ["ANN"] "tests/*" = ["ANN"] [tool.ruff.lint.isort] diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 84168893..00000000 --- a/requirements-test.txt +++ /dev/null @@ -1,11 +0,0 @@ -asyncpg==0.31.0 -mock==5.2.0 -pg8000==1.31.5 -psycopg2-binary==2.9.11 -psycopg==3.3.3 -psycopg-binary==3.3.3 -pytest==9.0.2 -pytest-asyncio==1.3.0 -pytest-cov==7.0.0 -SQLAlchemy[asyncio]==2.0.48 -aioresponses==0.7.8 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6cf86a97..00000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -aiofiles==25.1.0 -cryptography==46.0.5 -google-auth==2.49.0 -requests==2.32.5 -protobuf==7.34.0 \ No newline at end of file diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 00000000..50ee298f --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Copyright 2026 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. + +set -euo pipefail + +uv run --group test \ + coverage run \ + --include="*/google/cloud/alloydbconnector/*.py" \ + -m pytest -v tests/unit "$@" +uv run --group test coverage report --show-missing +PR_COVER=$(uv run --group test coverage report | awk '$1 == "TOTAL" {print $NF+0}') +uv run --group test coverage erase + +echo "CODE COVERAGE is ${PR_COVER}%" +if [ "${PR_COVER}" -lt "90" ]; then + echo "Coverage below 90% threshold" + exit 1 +fi diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 00000000..c4b02d66 --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Copyright 2026 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. + +set -euo pipefail + +uv run --group lint ruff check --fix google tests +uv run --group lint ruff format google tests diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 00000000..fa30073d --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Copyright 2026 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. + +set -euo pipefail + +uv run --group lint ruff format --check --diff google tests +uv run --group lint ruff check google tests +uv run --group lint mypy -p google --show-traceback +uv run --group lint python -m build --sdist +uv run --group lint twine check --strict dist/* diff --git a/scripts/test_system.sh b/scripts/test_system.sh new file mode 100755 index 00000000..ee0e0d6a --- /dev/null +++ b/scripts/test_system.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copyright 2026 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. + +set -euo pipefail + +uv run --no-editable --group test \ + coverage run \ + --include="*/google/cloud/alloydbconnector/*.py" \ + -m pytest -v tests/system "$@" diff --git a/scripts/test_unit.sh b/scripts/test_unit.sh new file mode 100755 index 00000000..5a5b9a48 --- /dev/null +++ b/scripts/test_unit.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copyright 2026 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. + +set -euo pipefail + +uv run --group test \ + coverage run \ + --include="*/google/cloud/alloydbconnector/*.py" \ + -m pytest -v tests/unit "$@" diff --git a/tests/unit/mocks.py b/tests/unit/mocks.py index 5e254b0c..43c2c8cd 100644 --- a/tests/unit/mocks.py +++ b/tests/unit/mocks.py @@ -477,7 +477,10 @@ def write_static_info(i: FakeInstance) -> io.StringIO: return io.StringIO(json.dumps(static)) -class FakeAlloyDBAdminAsyncClient: +class FakeAlloyDBAdminAsyncClient(alloydb_v1beta.AlloyDBAdminAsyncClient): + def __init__(self) -> None: + pass + async def get_connection_info( self, request: alloydb_v1beta.GetConnectionInfoRequest ) -> alloydb_v1beta.types.resources.ConnectionInfo: @@ -510,7 +513,10 @@ async def generate_client_certificate( return ccr -class FakeAlloyDBAdminClient: +class FakeAlloyDBAdminClient(alloydb_v1beta.AlloyDBAdminClient): + def __init__(self) -> None: + pass + def get_connection_info( self, request: alloydb_v1beta.GetConnectionInfoRequest ) -> alloydb_v1beta.types.resources.ConnectionInfo: diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 208ca029..f7776fa6 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -87,7 +87,6 @@ async def test__get_metadata_with_async_client(credentials: FakeCredentials) -> Test _get_metadata returns successfully for an async client. """ test_client = AlloyDBClient("", "", credentials, FakeAlloyDBAdminAsyncClient()) - test_client._is_sync = False assert ( await test_client._get_metadata( "test-project", @@ -104,7 +103,6 @@ async def test__get_metadata_with_sync_client(credentials: FakeCredentials) -> N Test _get_metadata returns successfully for a sync client. """ test_client = AlloyDBClient("", "", credentials, FakeAlloyDBAdminClient()) - test_client._is_sync = True assert ( await test_client._get_metadata( "test-project", @@ -140,7 +138,6 @@ async def test__get_client_certificate_with_async_client( Test _get_client_certificate returns successfully for an async client. """ test_client = AlloyDBClient("", "", credentials, FakeAlloyDBAdminAsyncClient()) - test_client._is_sync = False keys = await generate_keys() assert ( await test_client._get_client_certificate( @@ -157,7 +154,6 @@ async def test__get_client_certificate_with_sync_client( Test _get_client_certificate returns successfully for a sync client. """ test_client = AlloyDBClient("", "", credentials, FakeAlloyDBAdminClient()) - test_client._is_sync = True keys = await generate_keys() assert ( await test_client._get_client_certificate( @@ -211,7 +207,6 @@ async def test_AlloyDBClient_init_specified_client( credentials, FakeAlloyDBAdminAsyncClient(), ) - assert client._is_sync is False assert type(client._client) is FakeAlloyDBAdminAsyncClient @@ -223,7 +218,6 @@ async def test_AlloyDBClient_init_sync_client(credentials: FakeCredentials) -> N client = AlloyDBClient( "www.test-endpoint.com", "my-quota-project", credentials, driver="pg8000" ) - assert client._is_sync is True assert type(client._client) is v1beta.AlloyDBAdminClient assert client._client.transport.kind == "grpc" @@ -236,7 +230,6 @@ async def test_AlloyDBClient_init_async_client(credentials: FakeCredentials) -> client = AlloyDBClient( "www.test-endpoint.com", "my-quota-project", credentials, driver="" ) - assert client._is_sync is False assert type(client._client) is v1beta.AlloyDBAdminAsyncClient assert client._client.transport.kind == "grpc_asyncio"