cloud-sql-python-connector 1.20.4__py3-none-any.whl → 1.22.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {cloud_sql_python_connector-1.20.4.dist-info → cloud_sql_python_connector-1.22.0.dist-info}/METADATA +25 -4
  2. cloud_sql_python_connector-1.22.0.dist-info/RECORD +26 -0
  3. {cloud_sql_python_connector-1.20.4.dist-info → cloud_sql_python_connector-1.22.0.dist-info}/WHEEL +1 -1
  4. google/cloud/sql/connector/__init__.py +2 -2
  5. google/cloud/sql/connector/asyncpg.py +3 -1
  6. google/cloud/sql/connector/client.py +61 -19
  7. google/cloud/sql/connector/connection_info.py +15 -5
  8. google/cloud/sql/connector/connection_name.py +2 -4
  9. google/cloud/sql/connector/connector.py +95 -62
  10. google/cloud/sql/connector/enums.py +2 -1
  11. google/cloud/sql/connector/exceptions.py +6 -6
  12. google/cloud/sql/connector/instance.py +1 -1
  13. google/cloud/sql/connector/lazy.py +5 -5
  14. google/cloud/sql/connector/monitored_cache.py +42 -11
  15. google/cloud/sql/connector/pg8000.py +3 -1
  16. google/cloud/sql/connector/psycopg.py +235 -0
  17. google/cloud/sql/connector/pymysql.py +8 -1
  18. google/cloud/sql/connector/pytds.py +1 -1
  19. google/cloud/sql/connector/rate_limiter.py +3 -3
  20. google/cloud/sql/connector/refresh_utils.py +4 -2
  21. google/cloud/sql/connector/resolver.py +118 -36
  22. google/cloud/sql/connector/utils.py +40 -8
  23. google/cloud/sql/connector/version.py +1 -1
  24. cloud_sql_python_connector-1.20.4.dist-info/RECORD +0 -25
  25. {cloud_sql_python_connector-1.20.4.dist-info → cloud_sql_python_connector-1.22.0.dist-info}/licenses/LICENSE +0 -0
  26. {cloud_sql_python_connector-1.20.4.dist-info → cloud_sql_python_connector-1.22.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloud-sql-python-connector
3
- Version: 1.20.4
3
+ Version: 1.22.0
4
4
  Summary: Google Cloud SQL Python Connector library
5
5
  Author-email: Google LLC <googleapis-packages@google.com>
6
6
  License-Expression: Apache-2.0
@@ -21,12 +21,14 @@ Classifier: Operating System :: OS Independent
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
- Requires-Dist: aiofiles
25
24
  Requires-Dist: aiohttp
26
25
  Requires-Dist: cryptography>=42.0.0
27
26
  Requires-Dist: dnspython>=2.0.0
28
27
  Requires-Dist: Requests
29
28
  Requires-Dist: google-auth>=2.28.0
29
+ Requires-Dist: grpcio
30
+ Requires-Dist: protobuf
31
+ Requires-Dist: googleapis-common-protos
30
32
  Provides-Extra: pymysql
31
33
  Requires-Dist: PyMySQL>=1.1.0; extra == "pymysql"
32
34
  Provides-Extra: pg8000
@@ -35,6 +37,8 @@ Provides-Extra: pytds
35
37
  Requires-Dist: python-tds>=1.15.0; extra == "pytds"
36
38
  Provides-Extra: asyncpg
37
39
  Requires-Dist: asyncpg>=0.30.0; extra == "asyncpg"
40
+ Provides-Extra: psycopg
41
+ Requires-Dist: psycopg>=3.1.0; extra == "psycopg"
38
42
  Dynamic: license-file
39
43
 
40
44
  <p align="center">
@@ -80,7 +84,8 @@ providing the following benefits:
80
84
  The Cloud SQL Python Connector is a package to be used alongside a database driver.
81
85
  Currently supported drivers are:
82
86
  - [`pymysql`](https://github.com/PyMySQL/PyMySQL) (MySQL)
83
- - [`pg8000`](https://github.com/tlocke/pg8000) (PostgreSQL)
87
+ - [`pg8000`](https://codeberg.org/tlocke/pg8000) (PostgreSQL)
88
+ - [`psycopg`](https://github.com/psycopg/psycopg) (PostgreSQL)
84
89
  - [`asyncpg`](https://github.com/MagicStack/asyncpg) (PostgreSQL)
85
90
  - [`pytds`](https://github.com/denisenkom/pytds) (SQL Server)
86
91
 
@@ -95,12 +100,16 @@ based on your database dialect.
95
100
  pip install "cloud-sql-python-connector[pymysql]"
96
101
  ```
97
102
  ### Postgres
98
- There are two different database drivers that are supported for the Postgres dialect:
103
+ There are three different database drivers that are supported for the Postgres dialect:
99
104
 
100
105
  #### pg8000
101
106
  ```
102
107
  pip install "cloud-sql-python-connector[pg8000]"
103
108
  ```
109
+ #### psycopg
110
+ ```
111
+ pip install "cloud-sql-python-connector[psycopg]"
112
+ ```
104
113
  #### asyncpg
105
114
  ```
106
115
  pip install "cloud-sql-python-connector[asyncpg]"
@@ -176,6 +185,18 @@ pool = sqlalchemy.create_engine(
176
185
  db="my-db-name"
177
186
  ),
178
187
  )
188
+
189
+ # Or with Postgres (psycopg):
190
+ pool = sqlalchemy.create_engine(
191
+ "postgresql+psycopg://",
192
+ creator=lambda: connector.connect(
193
+ "project:region:instance",
194
+ "psycopg",
195
+ user="my-user",
196
+ password="my-password",
197
+ db="my-db-name"
198
+ ),
199
+ )
179
200
  ```
180
201
 
181
202
  The returned connection pool engine can then be used to query and modify the database.
@@ -0,0 +1,26 @@
1
+ cloud_sql_python_connector-1.22.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
2
+ google/cloud/sql/connector/__init__.py,sha256=pfa-FD-VRpYI49OUul9p6UFDp7G_yT2f6WS9rZMn8CM,1148
3
+ google/cloud/sql/connector/asyncpg.py,sha256=MpZhwFke0WXvYD3OcEF1Ok0qHl-CU7ZIPy85PCog5QY,2002
4
+ google/cloud/sql/connector/client.py,sha256=he2qm0cUQD4FGzMfT6eBye-KLEQtFdQsedsawNUhm7c,14598
5
+ google/cloud/sql/connector/connection_info.py,sha256=INQMBXGxXSth2MsAnqe9e9pCOiqMj55V3fzwxCmoAZc,5399
6
+ google/cloud/sql/connector/connection_name.py,sha256=SCCVeWb8YN0f9My09_l9FGTr6iRbi1b_52TJQaS6xzM,2574
7
+ google/cloud/sql/connector/connector.py,sha256=dEn9rN5fzRx4BE891f-lBeEpRNi7MhnkTvkuq5TLoMY,28040
8
+ google/cloud/sql/connector/enums.py,sha256=wESKVNRFNwKk-Bs093zM6YSCGEgaDHu3yLBscWuKpn8,2994
9
+ google/cloud/sql/connector/exceptions.py,sha256=hFdDZRR48qLYSFI_CRxbftrKkt77Jl5ci6SEHcAGMok,2209
10
+ google/cloud/sql/connector/instance.py,sha256=xz-TLSaYOyUQdbKsv2m-MDvA2kjJCkcaj3GXfsYIw2g,8691
11
+ google/cloud/sql/connector/lazy.py,sha256=N3Ud8KobhASnsH0pACG8oEHjmS7Z7MVzkHJ9skDLxMo,4932
12
+ google/cloud/sql/connector/monitored_cache.py,sha256=n-nG19VZ7hfzz0Dbg245mrkhRga1f-bP56aRku212mk,6656
13
+ google/cloud/sql/connector/pg8000.py,sha256=tbqFSIoSguMX1hTZ4kWeBOKLhLHKX1Vq5SespfA0Y0w,1801
14
+ google/cloud/sql/connector/psycopg.py,sha256=LcGencGQlQPDKWTeQrk1SiLMtVB09uZw6RdPQAosC8k,8038
15
+ google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ google/cloud/sql/connector/pymysql.py,sha256=MOau37nTOTesUR5Hor7lLtEckvvGThzjECEWQYHcM8U,2166
17
+ google/cloud/sql/connector/pytds.py,sha256=e-KBCqv0m3a7cRi9YmFHNuYqClhlJbf-KoF611w-1Ck,2304
18
+ google/cloud/sql/connector/rate_limiter.py,sha256=60k-r9h-nUBqovVIHnIGbW0NMgUGD4exH_rZ02ukNa0,2924
19
+ google/cloud/sql/connector/refresh_utils.py,sha256=kouF2OJc-NEu7MQS94GVJ7jN04-7a_wLKL-46kAVQuM,5219
20
+ google/cloud/sql/connector/resolver.py,sha256=4U8G0Pxwmy64qoRmP5shLnmIykxcGcGCoFSH_7tr120,6353
21
+ google/cloud/sql/connector/utils.py,sha256=ApLG4z4H0S4lWXQ9PyclXbrEoY5VrshqN9QB6BG8lyg,4132
22
+ google/cloud/sql/connector/version.py,sha256=0JV131RUM4deaBtRNiyn9A-CSQMDjxlhfH0wdHi3fGQ,598
23
+ cloud_sql_python_connector-1.22.0.dist-info/METADATA,sha256=Sn1H3KwvLEFOSGWPJY5APuFykTx73RLrk5DjCCP_t50,31731
24
+ cloud_sql_python_connector-1.22.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ cloud_sql_python_connector-1.22.0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
26
+ cloud_sql_python_connector-1.22.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -23,11 +23,11 @@ from google.cloud.sql.connector.resolver import DnsResolver
23
23
  from google.cloud.sql.connector.version import __version__
24
24
 
25
25
  __all__ = [
26
- "__version__",
27
- "create_async_connector",
28
26
  "Connector",
29
27
  "DefaultResolver",
30
28
  "DnsResolver",
31
29
  "IPTypes",
32
30
  "RefreshStrategy",
31
+ "__version__",
32
+ "create_async_connector",
33
33
  ]
@@ -51,7 +51,9 @@ async def connect(
51
51
  'Unable to import module "asyncpg." Please install and try again.'
52
52
  )
53
53
  user = kwargs.pop("user")
54
- db = kwargs.pop("db")
54
+ db = kwargs.pop("database", kwargs.pop("db", None))
55
+ if db is None:
56
+ raise KeyError("database")
55
57
  passwd = kwargs.pop("password", None)
56
58
 
57
59
  return await asyncpg.connect(
@@ -17,7 +17,7 @@ from __future__ import annotations
17
17
  import asyncio
18
18
  import datetime
19
19
  import logging
20
- from typing import Any, Optional, TYPE_CHECKING
20
+ from typing import Any, TYPE_CHECKING
21
21
 
22
22
  import aiohttp
23
23
  from cryptography.hazmat.backends import default_backend
@@ -42,7 +42,7 @@ DEFAULT_SERVICE_ENDPOINT: str = "https://sqladmin.googleapis.com"
42
42
  logger = logging.getLogger(name=__name__)
43
43
 
44
44
 
45
- def _format_user_agent(driver: Optional[str], custom: Optional[str]) -> str:
45
+ def _format_user_agent(driver: str | None, custom: str | None) -> str:
46
46
  agent = f"{USER_AGENT}+{driver}" if driver else USER_AGENT
47
47
  if custom and isinstance(custom, str):
48
48
  agent = f"{agent} {custom}"
@@ -52,12 +52,12 @@ def _format_user_agent(driver: Optional[str], custom: Optional[str]) -> str:
52
52
  class CloudSQLClient:
53
53
  def __init__(
54
54
  self,
55
- sqladmin_api_endpoint: Optional[str],
56
- quota_project: Optional[str],
55
+ sqladmin_api_endpoint: str | None,
56
+ quota_project: str | None,
57
57
  credentials: Credentials,
58
- client: Optional[aiohttp.ClientSession] = None,
59
- driver: Optional[str] = None,
60
- user_agent: Optional[str] = None,
58
+ client: aiohttp.ClientSession | None = None,
59
+ driver: str | None = None,
60
+ user_agent: str | None = None,
61
61
  ) -> None:
62
62
  """Establishes the client to be used for Cloud SQL Admin API requests.
63
63
 
@@ -135,7 +135,7 @@ class CloudSQLClient:
135
135
  if message:
136
136
  resp.reason = message
137
137
  # skip, raise_for_status will catch all errors in finally block
138
- except Exception:
138
+ except Exception: # noqa: BLE001, S110
139
139
  pass
140
140
  finally:
141
141
  resp.raise_for_status()
@@ -146,7 +146,7 @@ class CloudSQLClient:
146
146
  )
147
147
 
148
148
  ip_addresses = (
149
- {ip["type"]: ip["ipAddress"] for ip in ret_dict["ipAddresses"]}
149
+ {ip["type"]: [ip["ipAddress"]] for ip in ret_dict["ipAddresses"]}
150
150
  if "ipAddresses" in ret_dict
151
151
  else {}
152
152
  )
@@ -156,20 +156,22 @@ class CloudSQLClient:
156
156
  if ret_dict.get("pscEnabled"):
157
157
  # Find PSC instance DNS name in the dns_names field
158
158
  psc_dns_names = [
159
- d["name"]
159
+ d["name"].rstrip(".")
160
160
  for d in ret_dict.get("dnsNames", [])
161
161
  if d["connectionType"] == "PRIVATE_SERVICE_CONNECT"
162
162
  and d["dnsScope"] == "INSTANCE"
163
163
  ]
164
- dns_name = psc_dns_names[0] if psc_dns_names else None
164
+ # Sort: .sql-psc.goog first
165
+ psc_dns_names.sort(key=lambda x: x.endswith(".sql-psc.goog"), reverse=True)
165
166
 
166
167
  # Fall back do dns_name field if dns_names is not set
167
- if dns_name is None:
168
+ if not psc_dns_names:
168
169
  dns_name = ret_dict.get("dnsName", None)
170
+ if dns_name:
171
+ psc_dns_names = [dns_name.rstrip(".")]
169
172
 
170
- # Remove trailing period from DNS name. Required for SSL in Python
171
- if dns_name:
172
- ip_addresses["PSC"] = dns_name.rstrip(".")
173
+ if psc_dns_names:
174
+ ip_addresses["PSC"] = psc_dns_names
173
175
 
174
176
  return {
175
177
  "ip_addresses": ip_addresses,
@@ -177,6 +179,47 @@ class CloudSQLClient:
177
179
  "database_version": ret_dict["databaseVersion"],
178
180
  }
179
181
 
182
+ async def resolve_connect_settings(
183
+ self,
184
+ dns_name: str,
185
+ location: str,
186
+ ) -> dict[str, Any]:
187
+ """Asynchronously calls the resolveConnectSettings endpoint to resolve a
188
+ PSC DNS name to a connection name.
189
+
190
+ Args:
191
+ dns_name (str): The DNS name of the Cloud SQL instance.
192
+ location (str): The region/location of the instance.
193
+
194
+ Returns:
195
+ A dictionary containing the resolve response (e.g. connectionName).
196
+ """
197
+ # before making Cloud SQL Admin API calls, refresh creds if required
198
+ if self._credentials.token_state != TokenState.FRESH:
199
+ self._credentials.refresh(requests.Request())
200
+
201
+ headers = {
202
+ "Authorization": f"Bearer {self._credentials.token}",
203
+ }
204
+
205
+ url = f"{self._sqladmin_api_endpoint}/sql/{API_VERSION}/locations/{location}/dns/{dns_name}:resolveConnectSettings"
206
+
207
+ resp = await self._client.get(url, headers=headers)
208
+ if resp.status >= 500:
209
+ resp = await retry_50x(self._client.get, url, headers=headers)
210
+ try:
211
+ ret_dict = await resp.json()
212
+ if resp.status >= 400:
213
+ message = ret_dict.get("error", {}).get("message")
214
+ if message:
215
+ resp.reason = message
216
+ except Exception: # noqa: BLE001, S110
217
+ pass
218
+ finally:
219
+ resp.raise_for_status()
220
+
221
+ return ret_dict
222
+
180
223
  async def _get_ephemeral(
181
224
  self,
182
225
  project: str,
@@ -223,7 +266,7 @@ class CloudSQLClient:
223
266
  if message:
224
267
  resp.reason = message
225
268
  # skip, raise_for_status will catch all errors in finally block
226
- except Exception:
269
+ except Exception: # noqa: BLE001, S110
227
270
  pass
228
271
  finally:
229
272
  resp.raise_for_status()
@@ -244,8 +287,7 @@ class CloudSQLClient:
244
287
  # Ref: https://github.com/googleapis/google-auth-library-python/blob/49a5ff7411a2ae4d32a7d11700f9f961c55406a9/google/auth/_helpers.py#L93-L99
245
288
  token_expiration = token_expiration.replace(tzinfo=datetime.timezone.utc)
246
289
 
247
- if expiration > token_expiration:
248
- expiration = token_expiration
290
+ expiration = min(expiration, token_expiration)
249
291
  return ephemeral_cert, expiration
250
292
 
251
293
  async def get_connection_info(
@@ -274,7 +316,7 @@ class CloudSQLClient:
274
316
  """
275
317
  priv_key, pub_key = await keys
276
318
  # before making Cloud SQL Admin API calls, refresh creds if required
277
- if not self._credentials.token_state == TokenState.FRESH:
319
+ if self._credentials.token_state != TokenState.FRESH:
278
320
  self._credentials.refresh(requests.Request())
279
321
 
280
322
  metadata_task = asyncio.create_task(
@@ -18,13 +18,12 @@ import abc
18
18
  from dataclasses import dataclass
19
19
  import logging
20
20
  import ssl
21
- from typing import Any, Optional, TYPE_CHECKING
22
-
23
- from aiofiles.tempfile import TemporaryDirectory
21
+ from typing import Any, TYPE_CHECKING
24
22
 
25
23
  from google.cloud.sql.connector.connection_name import ConnectionName
26
24
  from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
27
25
  from google.cloud.sql.connector.exceptions import TLSVersionError
26
+ from google.cloud.sql.connector.utils import AsyncTemporaryDirectory
28
27
  from google.cloud.sql.connector.utils import write_to_file
29
28
 
30
29
  if TYPE_CHECKING:
@@ -68,7 +67,7 @@ class ConnectionInfo:
68
67
  ip_addrs: dict[str, Any]
69
68
  database_version: str
70
69
  expiration: datetime.datetime
71
- context: Optional[ssl.SSLContext] = None
70
+ context: ssl.SSLContext | None = None
72
71
 
73
72
  async def create_ssl_context(self, enable_iam_auth: bool = False) -> ssl.SSLContext:
74
73
  """Constructs a SSL/TLS context for the given connection info.
@@ -108,7 +107,7 @@ class ConnectionInfo:
108
107
  # tmpdir and its contents are automatically deleted after the CA cert
109
108
  # and ephemeral cert are loaded into the SSLcontext. The values
110
109
  # need to be written to files in order to be loaded by the SSLContext
111
- async with TemporaryDirectory() as tmpdir:
110
+ async with AsyncTemporaryDirectory() as tmpdir:
112
111
  ca_filename, cert_filename, key_filename = await write_to_file(
113
112
  tmpdir, self.server_ca_cert, self.client_cert, self.private_key
114
113
  )
@@ -122,6 +121,17 @@ class ConnectionInfo:
122
121
  """Returns the first IP address for the instance, according to the preference
123
122
  supplied by ip_type. If no IP addressess with the given preference are found,
124
123
  an error is raised."""
124
+ if ip_type.value in self.ip_addrs:
125
+ return self.ip_addrs[ip_type.value][0]
126
+ raise CloudSQLIPTypeError(
127
+ "Cloud SQL instance does not have any IP addresses matching "
128
+ f"preference: {ip_type.value}"
129
+ )
130
+
131
+ def get_preferred_ips(self, ip_type: IPTypes) -> list[str]:
132
+ """Returns all IP addresses for the instance, according to the preference
133
+ supplied by ip_type. If no IP addressess with the given preference are found,
134
+ an error is raised."""
125
135
  if ip_type.value in self.ip_addrs:
126
136
  return self.ip_addrs[ip_type.value]
127
137
  raise CloudSQLIPTypeError(
@@ -18,7 +18,7 @@ import re
18
18
  # Instance connection name is the format <PROJECT>:<REGION>:<INSTANCE_NAME>
19
19
  # Additionally, we have to support legacy "domain-scoped" projects
20
20
  # (e.g. "google.com:PROJECT")
21
- CONN_NAME_REGEX = re.compile(("([^:]+(:[^:]+)?):([^:]+):([^:]+)"))
21
+ CONN_NAME_REGEX = re.compile("([^:]+(:[^:]+)?):([^:]+):([^:]+)")
22
22
  # The domain name pattern in accordance with RFC 1035, RFC 1123 and RFC 2181.
23
23
  DOMAIN_NAME_REGEX = re.compile(
24
24
  r"^(?:[_a-z0-9](?:[_a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)?$"
@@ -48,9 +48,7 @@ class ConnectionName:
48
48
 
49
49
 
50
50
  def _is_valid_domain(domain_name: str) -> bool:
51
- if DOMAIN_NAME_REGEX.fullmatch(domain_name) is None:
52
- return False
53
- return True
51
+ return DOMAIN_NAME_REGEX.fullmatch(domain_name) is not None
54
52
 
55
53
 
56
54
  def _parse_connection_name(connection_name: str) -> ConnectionName:
@@ -23,13 +23,17 @@ import os
23
23
  import socket
24
24
  from threading import Thread
25
25
  from types import TracebackType
26
- from typing import Any, Callable, Optional, Union
26
+ from typing import Any, Callable
27
27
 
28
28
  import google.auth
29
29
  from google.auth.credentials import Credentials
30
30
  from google.auth.credentials import with_scopes_if_required
31
31
 
32
- import google.cloud.sql.connector.asyncpg as asyncpg
32
+ from google.cloud.sql.connector import asyncpg
33
+ from google.cloud.sql.connector import pg8000
34
+ from google.cloud.sql.connector import psycopg
35
+ from google.cloud.sql.connector import pymysql
36
+ from google.cloud.sql.connector import pytds
33
37
  from google.cloud.sql.connector.client import CloudSQLClient
34
38
  from google.cloud.sql.connector.enums import DriverMapping
35
39
  from google.cloud.sql.connector.enums import IPTypes
@@ -39,9 +43,6 @@ from google.cloud.sql.connector.exceptions import ConnectorLoopError
39
43
  from google.cloud.sql.connector.instance import RefreshAheadCache
40
44
  from google.cloud.sql.connector.lazy import LazyRefreshCache
41
45
  from google.cloud.sql.connector.monitored_cache import MonitoredCache
42
- import google.cloud.sql.connector.pg8000 as pg8000
43
- import google.cloud.sql.connector.pymysql as pymysql
44
- import google.cloud.sql.connector.pytds as pytds
45
46
  from google.cloud.sql.connector.resolver import DefaultResolver
46
47
  from google.cloud.sql.connector.resolver import DnsResolver
47
48
  from google.cloud.sql.connector.utils import format_database_user
@@ -64,14 +65,14 @@ class Connector:
64
65
  ip_type: str | IPTypes = IPTypes.PUBLIC,
65
66
  enable_iam_auth: bool = False,
66
67
  timeout: int = 30,
67
- credentials: Optional[Credentials] = None,
68
- loop: Optional[asyncio.AbstractEventLoop] = None,
69
- quota_project: Optional[str] = None,
70
- sqladmin_api_endpoint: Optional[str] = None,
71
- user_agent: Optional[str] = None,
72
- universe_domain: Optional[str] = None,
68
+ credentials: Credentials | None = None,
69
+ loop: asyncio.AbstractEventLoop | None = None,
70
+ quota_project: str | None = None,
71
+ sqladmin_api_endpoint: str | None = None,
72
+ user_agent: str | None = None,
73
+ universe_domain: str | None = None,
73
74
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
74
- resolver: type[DefaultResolver] | type[DnsResolver] = DefaultResolver,
75
+ resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
75
76
  failover_period: int = 30,
76
77
  ) -> None:
77
78
  """Initializes a Connector instance.
@@ -133,10 +134,10 @@ class Connector:
133
134
  # if event loop is given, use for background tasks
134
135
  if loop:
135
136
  self._loop: asyncio.AbstractEventLoop = loop
136
- self._thread: Optional[Thread] = None
137
+ self._thread: Thread | None = None
137
138
  # if lazy refresh is specified we should lazy init keys
138
139
  if self._refresh_strategy == RefreshStrategy.LAZY:
139
- self._keys: Optional[asyncio.Future] = None
140
+ self._keys: asyncio.Future | None = None
140
141
  else:
141
142
  self._keys = loop.create_task(generate_keys())
142
143
  # if no event loop is given, spin up new loop in background thread
@@ -155,7 +156,7 @@ class Connector:
155
156
  # initialize dict to store caches, key is a tuple consisting of instance
156
157
  # connection name string and enable_iam_auth boolean flag
157
158
  self._cache: dict[tuple[str, bool], MonitoredCache] = {}
158
- self._client: Optional[CloudSQLClient] = None
159
+ self._client: CloudSQLClient | None = None
159
160
  self._closed: bool = False
160
161
 
161
162
  # initialize credentials
@@ -176,7 +177,8 @@ class Connector:
176
177
  self._timeout = timeout
177
178
  self._enable_iam_auth = enable_iam_auth
178
179
  self._user_agent = user_agent
179
- self._resolver = resolver()
180
+ self._resolver_cls = resolver
181
+ self._resolver: DefaultResolver | DnsResolver | None = None
180
182
  self._failover_period = failover_period
181
183
  # if ip_type is str, convert to IPTypes enum
182
184
  if isinstance(ip_type, str):
@@ -316,6 +318,8 @@ class Connector:
316
318
  user_agent=self._user_agent,
317
319
  driver=driver,
318
320
  )
321
+ if self._resolver is None:
322
+ self._resolver = self._resolver_cls(client=self._client)
319
323
  enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
320
324
 
321
325
  conn_name = await self._resolver.resolve(instance_connection_string)
@@ -329,7 +333,7 @@ class Connector:
329
333
  logger.debug(
330
334
  f"['{conn_name}']: Refresh strategy is set to lazy refresh"
331
335
  )
332
- cache: Union[LazyRefreshCache, RefreshAheadCache] = LazyRefreshCache(
336
+ cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
333
337
  conn_name,
334
338
  self._client,
335
339
  self._keys,
@@ -359,6 +363,7 @@ class Connector:
359
363
  "pg8000": pg8000.connect,
360
364
  "asyncpg": asyncpg.connect,
361
365
  "pytds": pytds.connect,
366
+ "psycopg": psycopg.connect,
362
367
  }
363
368
 
364
369
  # only accept supported database drivers
@@ -384,13 +389,14 @@ class Connector:
384
389
  conn_info = await monitored_cache.connect_info()
385
390
  # validate driver matches intended database engine
386
391
  DriverMapping.validate_engine(driver, conn_info.database_version)
387
- ip_address = conn_info.get_preferred_ip(ip_type)
392
+ preferred_ips = conn_info.get_preferred_ips(ip_type)
388
393
  except Exception:
389
394
  # with an error from Cloud SQL Admin API call or IP type, invalidate
390
395
  # the cache and re-raise the error
391
396
  await self._remove_cached(str(conn_name), enable_iam_auth)
392
397
  raise
393
398
 
399
+ targets = []
394
400
  # If the connector is configured with a custom DNS name, attempt to use
395
401
  # that DNS name to connect to the instance. Fall back to the metadata IP
396
402
  # address if the DNS name does not resolve to an IP address.
@@ -398,26 +404,29 @@ class Connector:
398
404
  try:
399
405
  ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
400
406
  if ips:
401
- ip_address = ips[0]
407
+ targets.extend(ips)
402
408
  logger.debug(
403
409
  f"['{instance_connection_string}']: Custom DNS name "
404
- f"'{conn_info.conn_name.domain_name}' resolved to '{ip_address}', "
410
+ f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
405
411
  "using it to connect"
406
412
  )
407
413
  else:
408
414
  logger.debug(
409
415
  f"['{instance_connection_string}']: Custom DNS name "
410
416
  f"'{conn_info.conn_name.domain_name}' resolved but returned no "
411
- f"entries, using '{ip_address}' from instance metadata"
417
+ f"entries, using '{preferred_ips}' from instance metadata"
412
418
  )
413
- except Exception as e:
419
+ targets.extend(preferred_ips)
420
+ except Exception as e: # noqa: BLE001
414
421
  logger.debug(
415
422
  f"['{instance_connection_string}']: Custom DNS name "
416
423
  f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
417
- f"address: {e}, using '{ip_address}' from instance metadata"
424
+ f"address: {e}, using '{preferred_ips}' from instance metadata"
418
425
  )
426
+ targets.extend(preferred_ips)
427
+ else:
428
+ targets.extend(preferred_ips)
419
429
 
420
- logger.debug(f"['{conn_info.conn_name}']: Connecting to {ip_address}:3307")
421
430
  # format `user` param for automatic IAM database authn
422
431
  if enable_iam_auth:
423
432
  formatted_user = format_database_user(
@@ -428,32 +437,56 @@ class Connector:
428
437
  f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
429
438
  )
430
439
  kwargs["user"] = formatted_user
440
+
431
441
  try:
432
- # async drivers are unblocking and can be awaited directly
433
- if driver in ASYNC_DRIVERS:
434
- return await connector(
435
- ip_address,
436
- await conn_info.create_ssl_context(enable_iam_auth),
437
- **kwargs,
438
- )
439
- # Create socket with SSLContext for sync drivers
440
- ctx = await conn_info.create_ssl_context(enable_iam_auth)
441
- sock = ctx.wrap_socket(
442
- socket.create_connection((ip_address, SERVER_PROXY_PORT)),
443
- server_hostname=ip_address,
444
- )
445
- # If this connection was opened using a domain name, then store it
446
- # for later in case we need to forcibly close it on failover.
447
- if conn_info.conn_name.domain_name:
448
- monitored_cache.sockets.append(sock)
449
- # Synchronous drivers are blocking and run using executor
450
- connect_partial = partial(
451
- connector,
452
- ip_address,
453
- sock,
454
- **kwargs,
455
- )
456
- return await self._loop.run_in_executor(None, connect_partial)
442
+ last_ex = None
443
+ for target_ip in targets:
444
+ logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
445
+ try:
446
+ # async drivers are unblocking and can be awaited directly
447
+ if driver in ASYNC_DRIVERS:
448
+ conn = await connector(
449
+ target_ip,
450
+ await conn_info.create_ssl_context(enable_iam_auth),
451
+ **kwargs,
452
+ )
453
+ last_ex = None
454
+ return conn
455
+
456
+ # Create socket with SSLContext for sync drivers
457
+ ctx = await conn_info.create_ssl_context(enable_iam_auth)
458
+ raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
459
+ try:
460
+ sock = ctx.wrap_socket(
461
+ raw_sock,
462
+ server_hostname=target_ip,
463
+ )
464
+ except Exception:
465
+ raw_sock.close()
466
+ raise
467
+
468
+ # If this connection was opened using a domain name, then store it
469
+ # for later in case we need to forcibly close it on failover.
470
+ if conn_info.conn_name.domain_name:
471
+ monitored_cache.sockets.append(sock)
472
+ # Synchronous drivers are blocking and run using executor
473
+ connect_partial = partial(
474
+ connector,
475
+ target_ip,
476
+ sock,
477
+ **kwargs,
478
+ )
479
+ conn = await self._loop.run_in_executor(None, connect_partial)
480
+ last_ex = None
481
+ return conn
482
+ except Exception as e: # noqa: BLE001
483
+ logger.debug(
484
+ f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
485
+ )
486
+ last_ex = e
487
+
488
+ if last_ex:
489
+ raise last_ex
457
490
 
458
491
  except Exception:
459
492
  # with any exception, we attempt a force refresh, then throw the error
@@ -479,9 +512,9 @@ class Connector:
479
512
 
480
513
  def __exit__(
481
514
  self,
482
- exc_type: Optional[type[BaseException]],
483
- exc_val: Optional[BaseException],
484
- exc_tb: Optional[TracebackType],
515
+ exc_type: type[BaseException] | None,
516
+ exc_val: BaseException | None,
517
+ exc_tb: TracebackType | None,
485
518
  ) -> None:
486
519
  """Exit context manager by closing Connector"""
487
520
  self.close()
@@ -492,9 +525,9 @@ class Connector:
492
525
 
493
526
  async def __aexit__(
494
527
  self,
495
- exc_type: Optional[type[BaseException]],
496
- exc_val: Optional[BaseException],
497
- exc_tb: Optional[TracebackType],
528
+ exc_type: type[BaseException] | None,
529
+ exc_val: BaseException | None,
530
+ exc_tb: TracebackType | None,
498
531
  ) -> None:
499
532
  """Exit async context manager by closing Connector"""
500
533
  await self.close_async()
@@ -528,14 +561,14 @@ async def create_async_connector(
528
561
  ip_type: str | IPTypes = IPTypes.PUBLIC,
529
562
  enable_iam_auth: bool = False,
530
563
  timeout: int = 30,
531
- credentials: Optional[Credentials] = None,
532
- loop: Optional[asyncio.AbstractEventLoop] = None,
533
- quota_project: Optional[str] = None,
534
- sqladmin_api_endpoint: Optional[str] = None,
535
- user_agent: Optional[str] = None,
536
- universe_domain: Optional[str] = None,
564
+ credentials: Credentials | None = None,
565
+ loop: asyncio.AbstractEventLoop | None = None,
566
+ quota_project: str | None = None,
567
+ sqladmin_api_endpoint: str | None = None,
568
+ user_agent: str | None = None,
569
+ universe_domain: str | None = None,
537
570
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
538
- resolver: type[DefaultResolver] | type[DnsResolver] = DefaultResolver,
571
+ resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
539
572
  failover_period: int = 30,
540
573
  ) -> Connector:
541
574
  """Helper function to create Connector object for asyncio connections.