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"