cloud-sql-python-connector 1.20.3__tar.gz → 1.21.0__tar.gz

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 (31) hide show
  1. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/PKG-INFO +4 -2
  2. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/PKG-INFO +4 -2
  3. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/requires.txt +3 -1
  4. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/client.py +61 -19
  5. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/connection_info.py +15 -5
  6. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/connection_name.py +2 -4
  7. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/connector.py +93 -62
  8. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/enums.py +1 -1
  9. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/exceptions.py +0 -6
  10. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/instance.py +1 -1
  11. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/lazy.py +5 -5
  12. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/monitored_cache.py +7 -5
  13. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/pymysql.py +1 -1
  14. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/rate_limiter.py +3 -3
  15. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/refresh_utils.py +4 -2
  16. cloud_sql_python_connector-1.21.0/google/cloud/sql/connector/resolver.py +160 -0
  17. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/utils.py +40 -8
  18. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/version.py +1 -1
  19. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/pyproject.toml +3 -1
  20. cloud_sql_python_connector-1.20.3/google/cloud/sql/connector/resolver.py +0 -91
  21. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/LICENSE +0 -0
  22. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/README.md +0 -0
  23. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/SOURCES.txt +0 -0
  24. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/dependency_links.txt +0 -0
  25. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/cloud_sql_python_connector.egg-info/top_level.txt +0 -0
  26. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/__init__.py +2 -2
  27. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/asyncpg.py +0 -0
  28. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/pg8000.py +0 -0
  29. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/py.typed +0 -0
  30. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/google/cloud/sql/connector/pytds.py +0 -0
  31. {cloud_sql_python_connector-1.20.3 → cloud_sql_python_connector-1.21.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloud-sql-python-connector
3
- Version: 1.20.3
3
+ Version: 1.21.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloud-sql-python-connector
3
- Version: 1.20.3
3
+ Version: 1.21.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
@@ -1,9 +1,11 @@
1
- aiofiles
2
1
  aiohttp
3
2
  cryptography>=42.0.0
4
3
  dnspython>=2.0.0
5
4
  Requests
6
5
  google-auth>=2.28.0
6
+ grpcio
7
+ protobuf
8
+ googleapis-common-protos
7
9
 
8
10
  [asyncpg]
9
11
  asyncpg>=0.30.0
@@ -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,16 @@ 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 pymysql
35
+ from google.cloud.sql.connector import pytds
33
36
  from google.cloud.sql.connector.client import CloudSQLClient
34
37
  from google.cloud.sql.connector.enums import DriverMapping
35
38
  from google.cloud.sql.connector.enums import IPTypes
@@ -39,9 +42,6 @@ from google.cloud.sql.connector.exceptions import ConnectorLoopError
39
42
  from google.cloud.sql.connector.instance import RefreshAheadCache
40
43
  from google.cloud.sql.connector.lazy import LazyRefreshCache
41
44
  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
45
  from google.cloud.sql.connector.resolver import DefaultResolver
46
46
  from google.cloud.sql.connector.resolver import DnsResolver
47
47
  from google.cloud.sql.connector.utils import format_database_user
@@ -64,14 +64,14 @@ class Connector:
64
64
  ip_type: str | IPTypes = IPTypes.PUBLIC,
65
65
  enable_iam_auth: bool = False,
66
66
  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,
67
+ credentials: Credentials | None = None,
68
+ loop: asyncio.AbstractEventLoop | None = None,
69
+ quota_project: str | None = None,
70
+ sqladmin_api_endpoint: str | None = None,
71
+ user_agent: str | None = None,
72
+ universe_domain: str | None = None,
73
73
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
74
- resolver: type[DefaultResolver] | type[DnsResolver] = DefaultResolver,
74
+ resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
75
75
  failover_period: int = 30,
76
76
  ) -> None:
77
77
  """Initializes a Connector instance.
@@ -133,10 +133,10 @@ class Connector:
133
133
  # if event loop is given, use for background tasks
134
134
  if loop:
135
135
  self._loop: asyncio.AbstractEventLoop = loop
136
- self._thread: Optional[Thread] = None
136
+ self._thread: Thread | None = None
137
137
  # if lazy refresh is specified we should lazy init keys
138
138
  if self._refresh_strategy == RefreshStrategy.LAZY:
139
- self._keys: Optional[asyncio.Future] = None
139
+ self._keys: asyncio.Future | None = None
140
140
  else:
141
141
  self._keys = loop.create_task(generate_keys())
142
142
  # if no event loop is given, spin up new loop in background thread
@@ -155,7 +155,7 @@ class Connector:
155
155
  # initialize dict to store caches, key is a tuple consisting of instance
156
156
  # connection name string and enable_iam_auth boolean flag
157
157
  self._cache: dict[tuple[str, bool], MonitoredCache] = {}
158
- self._client: Optional[CloudSQLClient] = None
158
+ self._client: CloudSQLClient | None = None
159
159
  self._closed: bool = False
160
160
 
161
161
  # initialize credentials
@@ -176,7 +176,8 @@ class Connector:
176
176
  self._timeout = timeout
177
177
  self._enable_iam_auth = enable_iam_auth
178
178
  self._user_agent = user_agent
179
- self._resolver = resolver()
179
+ self._resolver_cls = resolver
180
+ self._resolver: DefaultResolver | DnsResolver | None = None
180
181
  self._failover_period = failover_period
181
182
  # if ip_type is str, convert to IPTypes enum
182
183
  if isinstance(ip_type, str):
@@ -316,6 +317,8 @@ class Connector:
316
317
  user_agent=self._user_agent,
317
318
  driver=driver,
318
319
  )
320
+ if self._resolver is None:
321
+ self._resolver = self._resolver_cls(client=self._client)
319
322
  enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
320
323
 
321
324
  conn_name = await self._resolver.resolve(instance_connection_string)
@@ -329,7 +332,7 @@ class Connector:
329
332
  logger.debug(
330
333
  f"['{conn_name}']: Refresh strategy is set to lazy refresh"
331
334
  )
332
- cache: Union[LazyRefreshCache, RefreshAheadCache] = LazyRefreshCache(
335
+ cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
333
336
  conn_name,
334
337
  self._client,
335
338
  self._keys,
@@ -384,13 +387,14 @@ class Connector:
384
387
  conn_info = await monitored_cache.connect_info()
385
388
  # validate driver matches intended database engine
386
389
  DriverMapping.validate_engine(driver, conn_info.database_version)
387
- ip_address = conn_info.get_preferred_ip(ip_type)
390
+ preferred_ips = conn_info.get_preferred_ips(ip_type)
388
391
  except Exception:
389
392
  # with an error from Cloud SQL Admin API call or IP type, invalidate
390
393
  # the cache and re-raise the error
391
394
  await self._remove_cached(str(conn_name), enable_iam_auth)
392
395
  raise
393
396
 
397
+ targets = []
394
398
  # If the connector is configured with a custom DNS name, attempt to use
395
399
  # that DNS name to connect to the instance. Fall back to the metadata IP
396
400
  # address if the DNS name does not resolve to an IP address.
@@ -398,26 +402,29 @@ class Connector:
398
402
  try:
399
403
  ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
400
404
  if ips:
401
- ip_address = ips[0]
405
+ targets.extend(ips)
402
406
  logger.debug(
403
407
  f"['{instance_connection_string}']: Custom DNS name "
404
- f"'{conn_info.conn_name.domain_name}' resolved to '{ip_address}', "
408
+ f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
405
409
  "using it to connect"
406
410
  )
407
411
  else:
408
412
  logger.debug(
409
413
  f"['{instance_connection_string}']: Custom DNS name "
410
414
  f"'{conn_info.conn_name.domain_name}' resolved but returned no "
411
- f"entries, using '{ip_address}' from instance metadata"
415
+ f"entries, using '{preferred_ips}' from instance metadata"
412
416
  )
413
- except Exception as e:
417
+ targets.extend(preferred_ips)
418
+ except Exception as e: # noqa: BLE001
414
419
  logger.debug(
415
420
  f"['{instance_connection_string}']: Custom DNS name "
416
421
  f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
417
- f"address: {e}, using '{ip_address}' from instance metadata"
422
+ f"address: {e}, using '{preferred_ips}' from instance metadata"
418
423
  )
424
+ targets.extend(preferred_ips)
425
+ else:
426
+ targets.extend(preferred_ips)
419
427
 
420
- logger.debug(f"['{conn_info.conn_name}']: Connecting to {ip_address}:3307")
421
428
  # format `user` param for automatic IAM database authn
422
429
  if enable_iam_auth:
423
430
  formatted_user = format_database_user(
@@ -428,32 +435,56 @@ class Connector:
428
435
  f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
429
436
  )
430
437
  kwargs["user"] = formatted_user
438
+
431
439
  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)
440
+ last_ex = None
441
+ for target_ip in targets:
442
+ logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
443
+ try:
444
+ # async drivers are unblocking and can be awaited directly
445
+ if driver in ASYNC_DRIVERS:
446
+ conn = await connector(
447
+ target_ip,
448
+ await conn_info.create_ssl_context(enable_iam_auth),
449
+ **kwargs,
450
+ )
451
+ last_ex = None
452
+ return conn
453
+
454
+ # Create socket with SSLContext for sync drivers
455
+ ctx = await conn_info.create_ssl_context(enable_iam_auth)
456
+ raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
457
+ try:
458
+ sock = ctx.wrap_socket(
459
+ raw_sock,
460
+ server_hostname=target_ip,
461
+ )
462
+ except Exception:
463
+ raw_sock.close()
464
+ raise
465
+
466
+ # If this connection was opened using a domain name, then store it
467
+ # for later in case we need to forcibly close it on failover.
468
+ if conn_info.conn_name.domain_name:
469
+ monitored_cache.sockets.append(sock)
470
+ # Synchronous drivers are blocking and run using executor
471
+ connect_partial = partial(
472
+ connector,
473
+ target_ip,
474
+ sock,
475
+ **kwargs,
476
+ )
477
+ conn = await self._loop.run_in_executor(None, connect_partial)
478
+ last_ex = None
479
+ return conn
480
+ except Exception as e: # noqa: BLE001
481
+ logger.debug(
482
+ f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
483
+ )
484
+ last_ex = e
485
+
486
+ if last_ex:
487
+ raise last_ex
457
488
 
458
489
  except Exception:
459
490
  # with any exception, we attempt a force refresh, then throw the error
@@ -479,9 +510,9 @@ class Connector:
479
510
 
480
511
  def __exit__(
481
512
  self,
482
- exc_type: Optional[type[BaseException]],
483
- exc_val: Optional[BaseException],
484
- exc_tb: Optional[TracebackType],
513
+ exc_type: type[BaseException] | None,
514
+ exc_val: BaseException | None,
515
+ exc_tb: TracebackType | None,
485
516
  ) -> None:
486
517
  """Exit context manager by closing Connector"""
487
518
  self.close()
@@ -492,9 +523,9 @@ class Connector:
492
523
 
493
524
  async def __aexit__(
494
525
  self,
495
- exc_type: Optional[type[BaseException]],
496
- exc_val: Optional[BaseException],
497
- exc_tb: Optional[TracebackType],
526
+ exc_type: type[BaseException] | None,
527
+ exc_val: BaseException | None,
528
+ exc_tb: TracebackType | None,
498
529
  ) -> None:
499
530
  """Exit async context manager by closing Connector"""
500
531
  await self.close_async()
@@ -528,14 +559,14 @@ async def create_async_connector(
528
559
  ip_type: str | IPTypes = IPTypes.PUBLIC,
529
560
  enable_iam_auth: bool = False,
530
561
  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,
562
+ credentials: Credentials | None = None,
563
+ loop: asyncio.AbstractEventLoop | None = None,
564
+ quota_project: str | None = None,
565
+ sqladmin_api_endpoint: str | None = None,
566
+ user_agent: str | None = None,
567
+ universe_domain: str | None = None,
537
568
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
538
- resolver: type[DefaultResolver] | type[DnsResolver] = DefaultResolver,
569
+ resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
539
570
  failover_period: int = 30,
540
571
  ) -> Connector:
541
572
  """Helper function to create Connector object for asyncio connections.
@@ -61,7 +61,7 @@ class DriverMapping(Enum):
61
61
  """Maps a given database driver to it's corresponding database engine."""
62
62
 
63
63
  ASYNCPG = "POSTGRES"
64
- PG8000 = "POSTGRES"
64
+ PG8000 = "POSTGRES" # noqa: PIE796
65
65
  PYMYSQL = "MYSQL"
66
66
  PYTDS = "SQLSERVER"
67
67
 
@@ -21,7 +21,6 @@ class ConnectorLoopError(Exception):
21
21
  in an invalid state (event loop in current thread).
22
22
  """
23
23
 
24
- pass
25
24
 
26
25
 
27
26
  class TLSVersionError(Exception):
@@ -29,7 +28,6 @@ class TLSVersionError(Exception):
29
28
  Raised when the required TLS protocol version is not supported.
30
29
  """
31
30
 
32
- pass
33
31
 
34
32
 
35
33
  class CloudSQLIPTypeError(Exception):
@@ -37,7 +35,6 @@ class CloudSQLIPTypeError(Exception):
37
35
  Raised when IP address for the preferred IP type is not found.
38
36
  """
39
37
 
40
- pass
41
38
 
42
39
 
43
40
  class PlatformNotSupportedError(Exception):
@@ -45,7 +42,6 @@ class PlatformNotSupportedError(Exception):
45
42
  Raised when a feature is not supported on the current platform.
46
43
  """
47
44
 
48
- pass
49
45
 
50
46
 
51
47
  class AutoIAMAuthNotSupported(Exception):
@@ -54,7 +50,6 @@ class AutoIAMAuthNotSupported(Exception):
54
50
  supported with database engine version.
55
51
  """
56
52
 
57
- pass
58
53
 
59
54
 
60
55
  class RefreshNotValidError(Exception):
@@ -62,7 +57,6 @@ class RefreshNotValidError(Exception):
62
57
  Exception to be raised when the task returned from refresh is not valid.
63
58
  """
64
59
 
65
- pass
66
60
 
67
61
 
68
62
  class IncompatibleDriverError(Exception):
@@ -130,7 +130,7 @@ class RefreshAheadCache(ConnectionInfoCache):
130
130
  except Exception as e:
131
131
  logger.debug(
132
132
  f"['{self._conn_name}']: Connection info "
133
- f"refresh operation failed: {str(e)}"
133
+ f"refresh operation failed: {e!s}"
134
134
  )
135
135
  raise
136
136
 
@@ -12,12 +12,13 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ from __future__ import annotations
16
+
15
17
  import asyncio
16
18
  from datetime import datetime
17
19
  from datetime import timedelta
18
20
  from datetime import timezone
19
21
  import logging
20
- from typing import Optional
21
22
 
22
23
  from google.cloud.sql.connector.client import CloudSQLClient
23
24
  from google.cloud.sql.connector.connection_info import ConnectionInfo
@@ -61,7 +62,7 @@ class LazyRefreshCache(ConnectionInfoCache):
61
62
  self._keys = keys
62
63
  self._client = client
63
64
  self._lock = asyncio.Lock()
64
- self._cached: Optional[ConnectionInfo] = None
65
+ self._cached: ConnectionInfo | None = None
65
66
  self._needs_refresh = False
66
67
  self._closed = False
67
68
 
@@ -112,7 +113,7 @@ class LazyRefreshCache(ConnectionInfoCache):
112
113
  except Exception as e:
113
114
  logger.debug(
114
115
  f"['{self._conn_name}']: Connection info "
115
- f"refresh operation failed: {str(e)}"
116
+ f"refresh operation failed: {e!s}"
116
117
  )
117
118
  raise
118
119
  logger.debug(
@@ -121,7 +122,7 @@ class LazyRefreshCache(ConnectionInfoCache):
121
122
  )
122
123
  logger.debug(
123
124
  f"['{self._conn_name}']: Current certificate "
124
- f"expiration = {str(conn_info.expiration)}"
125
+ f"expiration = {conn_info.expiration!s}"
125
126
  )
126
127
  self._cached = conn_info
127
128
  self._needs_refresh = False
@@ -132,4 +133,3 @@ class LazyRefreshCache(ConnectionInfoCache):
132
133
  other cache types.
133
134
  """
134
135
  self._closed = True
135
- return
@@ -12,10 +12,12 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ from __future__ import annotations
16
+
15
17
  import asyncio
16
18
  import logging
17
19
  import ssl
18
- from typing import Any, Callable, Optional, Union
20
+ from typing import Any, Callable
19
21
 
20
22
  from google.cloud.sql.connector.connection_info import ConnectionInfo
21
23
  from google.cloud.sql.connector.connection_info import ConnectionInfoCache
@@ -31,13 +33,13 @@ logger = logging.getLogger(name=__name__)
31
33
  class MonitoredCache(ConnectionInfoCache):
32
34
  def __init__(
33
35
  self,
34
- cache: Union[RefreshAheadCache, LazyRefreshCache],
36
+ cache: RefreshAheadCache | LazyRefreshCache,
35
37
  failover_period: int,
36
- resolver: Union[DefaultResolver, DnsResolver],
38
+ resolver: DefaultResolver | DnsResolver,
37
39
  ) -> None:
38
40
  self.resolver = resolver
39
41
  self.cache = cache
40
- self.domain_name_ticker: Optional[asyncio.Task] = None
42
+ self.domain_name_ticker: asyncio.Task | None = None
41
43
  self.sockets: list[ssl.SSLSocket] = []
42
44
 
43
45
  # If domain name is configured for instance and failover period is set,
@@ -87,7 +89,7 @@ class MonitoredCache(ConnectionInfoCache):
87
89
  )
88
90
  await self.close()
89
91
 
90
- except Exception as e:
92
+ except Exception as e: # noqa: BLE001
91
93
  # Domain name checks should not be fatal, log error and continue.
92
94
  logger.debug(
93
95
  f"['{self.cache.conn_name}']: Unable to check domain name, "
@@ -47,7 +47,7 @@ def connect(
47
47
  )
48
48
 
49
49
  # allow automatic IAM database authentication to not require password
50
- kwargs["password"] = kwargs["password"] if "password" in kwargs else None
50
+ kwargs["password"] = kwargs.get("password", None)
51
51
 
52
52
  # pop timeout as timeout arg is called 'connect_timeout' for pymysql
53
53
  timeout = kwargs.pop("timeout")
@@ -13,12 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  """
16
+ from __future__ import annotations
16
17
 
17
18
  import asyncio
18
- from typing import Optional
19
19
 
20
20
 
21
- class AsyncRateLimiter(object):
21
+ class AsyncRateLimiter:
22
22
  """An asyncio-compatible rate limiter which uses the Token Bucket algorithm
23
23
  (https://en.wikipedia.org/wiki/Token_bucket) to limit the number
24
24
  of function calls over a time interval using an event queue.
@@ -35,7 +35,7 @@ class AsyncRateLimiter(object):
35
35
  self,
36
36
  max_capacity: int = 1,
37
37
  rate: float = 1 / 60,
38
- loop: Optional[asyncio.AbstractEventLoop] = None,
38
+ loop: asyncio.AbstractEventLoop | None = None,
39
39
  ) -> None:
40
40
  self.rate = rate
41
41
  self.max_capacity = max_capacity
@@ -72,7 +72,7 @@ async def _is_valid(task: asyncio.Task) -> bool:
72
72
  # only valid if now is before the cert expires
73
73
  if datetime.datetime.now(datetime.timezone.utc) < metadata.expiration:
74
74
  return True
75
- except Exception:
75
+ except Exception: # noqa: BLE001
76
76
  # supress any errors from task
77
77
  logger.debug("Current instance metadata is invalid.")
78
78
  return False
@@ -80,7 +80,7 @@ async def _is_valid(task: asyncio.Task) -> bool:
80
80
 
81
81
  def _downscope_credentials(
82
82
  credentials: Credentials,
83
- scopes: list[str] = ["https://www.googleapis.com/auth/sqlservice.login"],
83
+ scopes: list[str] | None = None,
84
84
  ) -> Credentials:
85
85
  """Generate a down-scoped credential.
86
86
 
@@ -95,6 +95,8 @@ def _downscope_credentials(
95
95
  """
96
96
  # credentials sourced from a service account or metadata are children of
97
97
  # Scoped class and are capable of being re-scoped
98
+ if scopes is None:
99
+ scopes = ["https://www.googleapis.com/auth/sqlservice.login"]
98
100
  if isinstance(credentials, Scoped):
99
101
  scoped_creds = credentials.with_scopes(scopes=scopes)
100
102
  # authenticated user credentials can not be re-scoped
@@ -0,0 +1,160 @@
1
+ # Copyright 2024 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any
19
+
20
+ import dns.asyncresolver
21
+
22
+ from google.cloud.sql.connector.connection_name import _is_valid_domain
23
+ from google.cloud.sql.connector.connection_name import _parse_connection_name
24
+ from google.cloud.sql.connector.connection_name import (
25
+ _parse_connection_name_with_domain_name,
26
+ )
27
+ from google.cloud.sql.connector.connection_name import ConnectionName
28
+ from google.cloud.sql.connector.exceptions import DnsResolutionError
29
+
30
+ PSC_DNS_PATTERN = re.compile(
31
+ r"^([a-f0-9]{12})\.([^.]+)\.([a-z0-9]+-[a-z0-9]+)\.(sql|sql-psa|sql-psc)\.goog\.?$"
32
+ )
33
+
34
+
35
+ class DefaultResolver:
36
+ """DefaultResolver simply validates and parses instance connection name."""
37
+
38
+ def __init__(self, *args: Any, client: Any | None = None, **kwargs: Any) -> None:
39
+ pass
40
+
41
+ async def resolve(self, connection_name: str) -> ConnectionName:
42
+ return _parse_connection_name(connection_name)
43
+
44
+
45
+ class DnsResolver(dns.asyncresolver.Resolver):
46
+ """
47
+ DnsResolver resolves domain names into instance connection names using
48
+ TXT records in DNS.
49
+ """
50
+
51
+ def __init__(self, *args: Any, client: Any | None = None, **kwargs: Any) -> None:
52
+ super().__init__(*args, **kwargs)
53
+ self._client = client
54
+
55
+ async def resolve(self, dns: str) -> ConnectionName: # type: ignore
56
+ current = dns
57
+ visited = {current}
58
+
59
+ # Max 10 iterations to prevent infinite CNAME loops
60
+ for _ in range(10):
61
+ try:
62
+ domain_val = dns if current != dns else ""
63
+ conn_name = _parse_connection_name_with_domain_name(
64
+ current, domain_val
65
+ )
66
+ return conn_name
67
+ except ValueError:
68
+ pass
69
+
70
+ dns_normalized = current.rstrip(".")
71
+ match = PSC_DNS_PATTERN.match(dns_normalized.lower())
72
+ if match:
73
+ region = match.group(3)
74
+ if region != "global":
75
+ if self._client is None:
76
+ raise ValueError(
77
+ "SQLAdmin client is not configured in the resolver."
78
+ )
79
+
80
+ dns_name_with_dot = dns_normalized + "."
81
+ resp = await self._client.resolve_connect_settings(
82
+ dns_name_with_dot, region
83
+ )
84
+ resolved_conn_name = resp["connectionName"]
85
+ return _parse_connection_name_with_domain_name(
86
+ resolved_conn_name, dns
87
+ )
88
+
89
+ if not _is_valid_domain(current):
90
+ raise ValueError(
91
+ "Arg `instance_connection_string` must have "
92
+ "format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
93
+ f"name, got {dns}."
94
+ )
95
+
96
+ cname_found = False
97
+ try:
98
+ cname = await self.resolve_cname(current)
99
+ cname_found = True
100
+ except DnsResolutionError:
101
+ pass
102
+
103
+ if cname_found:
104
+ if cname in visited:
105
+ raise DnsResolutionError(f"CNAME loop detected for `{dns}`")
106
+ visited.add(cname)
107
+ current = cname
108
+ continue
109
+
110
+ try:
111
+ rdata = await self.resolve_txt(current)
112
+ except DnsResolutionError as e:
113
+ raise DnsResolutionError(
114
+ f"Unable to resolve TXT record for `{dns}`"
115
+ ) from e
116
+
117
+ rdata.sort()
118
+ for record in rdata:
119
+ try:
120
+ conn_name = _parse_connection_name_with_domain_name(
121
+ record, dns
122
+ )
123
+ return conn_name
124
+ except Exception: # noqa: BLE001, S112
125
+ continue
126
+
127
+ raise DnsResolutionError(
128
+ f"Unable to parse TXT record for `{current}` -> `{rdata[0]}`"
129
+ if rdata
130
+ else f"Unable to resolve TXT record for `{current}`"
131
+ )
132
+
133
+ raise DnsResolutionError(
134
+ f"CNAME loop detected or max resolution depth reached for `{dns}`"
135
+ )
136
+
137
+ async def resolve_cname(self, dns: str) -> str:
138
+ try:
139
+ answers = await super().resolve(dns, "CNAME", raise_on_no_answer=True)
140
+ return str(answers[0].target).rstrip(".")
141
+ except Exception as e:
142
+ raise DnsResolutionError(
143
+ f"Unable to resolve CNAME record for `{dns}`"
144
+ ) from e
145
+
146
+ async def resolve_txt(self, dns: str) -> list[str]:
147
+ try:
148
+ answers = await super().resolve(dns, "TXT", raise_on_no_answer=True)
149
+ return [record.to_text().strip('"') for record in answers]
150
+ except Exception as e:
151
+ raise DnsResolutionError(
152
+ f"Unable to resolve TXT record for `{dns}`"
153
+ ) from e
154
+
155
+ async def resolve_a_record(self, dns: str) -> list[str]:
156
+ try:
157
+ records = await super().resolve(dns, "A", raise_on_no_answer=True)
158
+ return [record.to_text() for record in records]
159
+ except Exception: # noqa: BLE001
160
+ return []
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  """
16
16
 
17
- import aiofiles
17
+ import asyncio
18
+ import tempfile
19
+
18
20
  from cryptography.hazmat.backends import default_backend
19
21
  from cryptography.hazmat.primitives import serialization
20
22
  from cryptography.hazmat.primitives.asymmetric import rsa
@@ -57,6 +59,33 @@ async def generate_keys() -> tuple[bytes, str]:
57
59
  return priv_key, pub_key
58
60
 
59
61
 
62
+ class AsyncTemporaryDirectory:
63
+ """Async context manager wrapper around tempfile.TemporaryDirectory."""
64
+
65
+ async def __aenter__(self) -> str:
66
+ self.temp_dir = await asyncio.to_thread(tempfile.TemporaryDirectory)
67
+ return self.temp_dir.name
68
+
69
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
70
+ await asyncio.to_thread(self.temp_dir.cleanup)
71
+
72
+
73
+ def _write_files(
74
+ ca_filename: str,
75
+ serverCaCert: str,
76
+ cert_filename: str,
77
+ ephemeralCert: str,
78
+ key_filename: str,
79
+ priv_key: bytes,
80
+ ) -> None:
81
+ with open(ca_filename, "w+") as ca_out:
82
+ ca_out.write(serverCaCert)
83
+ with open(cert_filename, "w+") as ephemeral_out:
84
+ ephemeral_out.write(ephemeralCert)
85
+ with open(key_filename, "wb") as priv_out:
86
+ priv_out.write(priv_key)
87
+
88
+
60
89
  async def write_to_file(
61
90
  dir_path: str, serverCaCert: str, ephemeralCert: str, priv_key: bytes
62
91
  ) -> tuple[str, str, str]:
@@ -68,12 +97,15 @@ async def write_to_file(
68
97
  cert_filename = f"{dir_path}/cert.pem"
69
98
  key_filename = f"{dir_path}/priv.pem"
70
99
 
71
- async with aiofiles.open(ca_filename, "w+") as ca_out:
72
- await ca_out.write(serverCaCert)
73
- async with aiofiles.open(cert_filename, "w+") as ephemeral_out:
74
- await ephemeral_out.write(ephemeralCert)
75
- async with aiofiles.open(key_filename, "wb") as priv_out:
76
- await priv_out.write(priv_key)
100
+ await asyncio.to_thread(
101
+ _write_files,
102
+ ca_filename,
103
+ serverCaCert,
104
+ cert_filename,
105
+ ephemeralCert,
106
+ key_filename,
107
+ priv_key,
108
+ )
77
109
 
78
110
  return (ca_filename, cert_filename, key_filename)
79
111
 
@@ -91,7 +123,7 @@ def format_database_user(database_version: str, user: str) -> str:
91
123
  # remove suffix for Postgres service accounts
92
124
  if database_version.startswith("POSTGRES"):
93
125
  suffix = ".gserviceaccount.com"
94
- user = user[: -len(suffix)] if user.endswith(suffix) else user
126
+ user = user.removesuffix(suffix)
95
127
  return user
96
128
 
97
129
  # remove everything after and including the @ for MySQL
@@ -12,4 +12,4 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- __version__ = "1.20.3"
15
+ __version__ = "1.21.0"
@@ -41,12 +41,14 @@ classifiers = [
41
41
  "Operating System :: OS Independent",
42
42
  ]
43
43
  dependencies = [
44
- "aiofiles",
45
44
  "aiohttp",
46
45
  "cryptography>=42.0.0",
47
46
  "dnspython>=2.0.0",
48
47
  "Requests",
49
48
  "google-auth>=2.28.0",
49
+ "grpcio",
50
+ "protobuf",
51
+ "googleapis-common-protos",
50
52
  ]
51
53
  dynamic = ["version"]
52
54
 
@@ -1,91 +0,0 @@
1
- # Copyright 2024 Google LLC
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
-
15
- from typing import List
16
-
17
- import dns.asyncresolver
18
-
19
- from google.cloud.sql.connector.connection_name import _is_valid_domain
20
- from google.cloud.sql.connector.connection_name import _parse_connection_name
21
- from google.cloud.sql.connector.connection_name import (
22
- _parse_connection_name_with_domain_name,
23
- )
24
- from google.cloud.sql.connector.connection_name import ConnectionName
25
- from google.cloud.sql.connector.exceptions import DnsResolutionError
26
-
27
-
28
- class DefaultResolver:
29
- """DefaultResolver simply validates and parses instance connection name."""
30
-
31
- async def resolve(self, connection_name: str) -> ConnectionName:
32
- return _parse_connection_name(connection_name)
33
-
34
-
35
- class DnsResolver(dns.asyncresolver.Resolver):
36
- """
37
- DnsResolver resolves domain names into instance connection names using
38
- TXT records in DNS.
39
- """
40
-
41
- async def resolve(self, dns: str) -> ConnectionName: # type: ignore
42
- try:
43
- conn_name = _parse_connection_name(dns)
44
- except ValueError:
45
- # The connection name was not project:region:instance format.
46
- # Check if connection name is a valid DNS domain name
47
- if _is_valid_domain(dns):
48
- # Attempt to query a TXT record to get connection name.
49
- conn_name = await self.query_dns(dns)
50
- else:
51
- raise ValueError(
52
- "Arg `instance_connection_string` must have "
53
- "format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
54
- f"name, got {dns}."
55
- )
56
- return conn_name
57
-
58
- async def resolve_a_record(self, dns: str) -> List[str]:
59
- try:
60
- # Attempt to query the A records.
61
- records = await super().resolve(dns, "A", raise_on_no_answer=True)
62
- # return IP addresses as strings
63
- return [record.to_text() for record in records]
64
- except Exception:
65
- # On any error, return empty list
66
- return []
67
-
68
- async def query_dns(self, dns: str) -> ConnectionName:
69
- try:
70
- # Attempt to query the TXT records.
71
- records = await super().resolve(dns, "TXT", raise_on_no_answer=True)
72
- # Sort the TXT record values alphabetically, strip quotes as record
73
- # values can be returned as raw strings
74
- rdata = [record.to_text().strip('"') for record in records]
75
- rdata.sort()
76
- # Attempt to parse records, returning the first valid record.
77
- for record in rdata:
78
- try:
79
- conn_name = _parse_connection_name_with_domain_name(record, dns)
80
- return conn_name
81
- except Exception:
82
- continue
83
- # If all records failed to parse, throw error
84
- raise DnsResolutionError(
85
- f"Unable to parse TXT record for `{dns}` -> `{rdata[0]}`"
86
- )
87
- # Don't override above DnsResolutionError
88
- except DnsResolutionError:
89
- raise
90
- except Exception as e:
91
- raise DnsResolutionError(f"Unable to resolve TXT record for `{dns}`") from e
@@ -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
  ]