cloud-sql-python-connector 1.17.0.dev0__py3-none-any.whl → 1.18.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.
@@ -1,16 +1,15 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: cloud-sql-python-connector
3
- Version: 1.17.0.dev0
3
+ Version: 1.18.0
4
4
  Summary: Google Cloud SQL Python Connector library
5
5
  Author-email: Google LLC <googleapis-packages@google.com>
6
- License: Apache 2.0
6
+ License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/GoogleCloudPlatform/cloud-sql-python-connector
8
8
  Project-URL: Repository, https://github.com/GoogleCloudPlatform/cloud-sql-python-connector.git
9
9
  Project-URL: Issues, https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/issues
10
10
  Project-URL: Changelog, https://github.com/GoogleCloudPlatform/cloud-sql-python-connector/blob/main/CHANGELOG.md
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: Apache Software License
14
13
  Classifier: Programming Language :: Python
15
14
  Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Programming Language :: Python :: 3.9
@@ -36,6 +35,7 @@ Provides-Extra: pytds
36
35
  Requires-Dist: python-tds>=1.15.0; extra == "pytds"
37
36
  Provides-Extra: asyncpg
38
37
  Requires-Dist: asyncpg>=0.30.0; extra == "asyncpg"
38
+ Dynamic: license-file
39
39
 
40
40
  <p align="center">
41
41
  <a href="https://cloud.google.com/blog/topics/developers-practitioners/how-connect-cloud-sql-using-python-easy-way">
@@ -467,6 +467,44 @@ with Connector(resolver=DnsResolver) as connector:
467
467
  # ... use SQLAlchemy engine normally
468
468
  ```
469
469
 
470
+ ### Automatic failover using DNS domain names
471
+
472
+ > [!NOTE]
473
+ >
474
+ > Usage of the `asyncpg` driver does not currently support automatic failover.
475
+
476
+ When the connector is configured using a domain name, the connector will
477
+ periodically check if the DNS record for an instance changes. When the connector
478
+ detects that the domain name refers to a different instance, the connector will
479
+ close all open connections to the old instance. Subsequent connection attempts
480
+ will be directed to the new instance.
481
+
482
+ For example: suppose application is configured to connect using the
483
+ domain name `prod-db.mycompany.example.com`. Initially the private DNS
484
+ zone has a TXT record with the value `my-project:region:my-instance`. The
485
+ application establishes connections to the `my-project:region:my-instance`
486
+ Cloud SQL instance.
487
+
488
+ Then, to reconfigure the application to use a different database
489
+ instance, change the value of the `prod-db.mycompany.example.com` DNS record
490
+ from `my-project:region:my-instance` to `my-project:other-region:my-instance-2`
491
+
492
+ The connector inside the application detects the change to this
493
+ DNS record. Now, when the application connects to its database using the
494
+ domain name `prod-db.mycompany.example.com`, it will connect to the
495
+ `my-project:other-region:my-instance-2` Cloud SQL instance.
496
+
497
+ The connector will automatically close all existing connections to
498
+ `my-project:region:my-instance`. This will force the connection pools to
499
+ establish new connections. Also, it may cause database queries in progress
500
+ to fail.
501
+
502
+ The connector will poll for changes to the DNS name every 30 seconds by default.
503
+ You may configure the frequency of the connections using the Connector's
504
+ `failover_period` argument (i.e. `Connector(failover_period=60`). When this is
505
+ set to 0, the connector will disable polling and only check if the DNS record
506
+ changed when it is creating a new connection.
507
+
470
508
  ### Using the Python Connector with Python Web Frameworks
471
509
 
472
510
  The Python Connector can be used alongside popular Python web frameworks such
@@ -0,0 +1,25 @@
1
+ cloud_sql_python_connector-1.18.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
2
+ google/cloud/sql/connector/__init__.py,sha256=w6w35V-m2QlpcL-ShKd2BvNF-xXOwJ2FoidE7npPE7c,1148
3
+ google/cloud/sql/connector/asyncpg.py,sha256=HUOB0rRhr2-zMbygDtdWTDNlYqEPkN0ADqCIpM3waDc,1839
4
+ google/cloud/sql/connector/client.py,sha256=ITZNJsIEAeCSKgH2B-cFklg_zVEPaskIzo7t_Q85F4M,13124
5
+ google/cloud/sql/connector/connection_info.py,sha256=BSuW-ovtEUYTIvDzQkaE3GofCs3Xoi0TtztFaAq4Kvo,4868
6
+ google/cloud/sql/connector/connection_name.py,sha256=bI4M3Qa2mtBHyD08oF41FF5W9FBOXWu4dGGg50Na_A4,2606
7
+ google/cloud/sql/connector/connector.py,sha256=jukyYD75FElLvKgSHg4o91Wug21jIn87nJWNbeoTZUc,24183
8
+ google/cloud/sql/connector/enums.py,sha256=dVBS9eEWNrHtzze0AJlmtbdUogG3EocNWOQcI_8qPd0,2937
9
+ google/cloud/sql/connector/exceptions.py,sha256=QdQBzZ3t1RE5V744wW4sGaucWD2cEedB-dWNCkh8SAA,1974
10
+ google/cloud/sql/connector/instance.py,sha256=jDEQf-OXFzqKO9a4bnzRQ0qUyO3_xKuiEIRWc_7wpyE,8694
11
+ google/cloud/sql/connector/lazy.py,sha256=oB6F-O2fk8ZguY4J7Vrts91bqbVb6P5wj72Lf6UJIQA,4948
12
+ google/cloud/sql/connector/monitored_cache.py,sha256=LFYjX8hsdN_Dt0cMwLSWgAhWWQpZ35qA0CLlpc6xhsE,5587
13
+ google/cloud/sql/connector/pg8000.py,sha256=3p4CJixaiYrjUtG3DWTQrh6qCkmUEbQyoX25SQSvyX4,1594
14
+ google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ google/cloud/sql/connector/pymysql.py,sha256=U1HEf0jBtnMiLjIuhp6ZGMJDg8IKQH24m2IRsy3k3tw,1886
16
+ google/cloud/sql/connector/pytds.py,sha256=s3_LvC-CfNwz7VKmmMVd0MwaC3vQigDLszsre3xLZC4,2233
17
+ google/cloud/sql/connector/rate_limiter.py,sha256=bPTupfM4f5QRSCmCAWMqjQylEB7bUM-50plVBU3W2CA,2993
18
+ google/cloud/sql/connector/refresh_utils.py,sha256=HfuuLdlqg2IxusB4T3BMj-BJlGsXtHsrCX_Hp-eJ_j0,5063
19
+ google/cloud/sql/connector/resolver.py,sha256=1KYN0E-oTca8wRyW2nqCw56XRHtq-QGYpPvkk-UhjnY,3290
20
+ google/cloud/sql/connector/utils.py,sha256=7g1fX-JsZc7xOgTuxrAiroFHQIQk_BEJZNYaKOinwQY,3508
21
+ google/cloud/sql/connector/version.py,sha256=t8RiJsR1VPKxNkKjjeAvtxSWJtJ2z_lMfWNUWDFzRk8,598
22
+ cloud_sql_python_connector-1.18.0.dist-info/METADATA,sha256=pFnUd-5DK_0daO_QGMMb4zWVI0EGFMGNrMoprHhVVw8,32331
23
+ cloud_sql_python_connector-1.18.0.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
24
+ cloud_sql_python_connector-1.18.0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
25
+ cloud_sql_python_connector-1.18.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.8.0)
2
+ Generator: setuptools (77.0.3)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,4 +1,4 @@
1
- """"
1
+ """
2
2
  Copyright 2019 Google LLC
3
3
 
4
4
  Licensed under the Apache License, Version 2.0 (the "License");
@@ -156,10 +156,23 @@ class CloudSQLClient:
156
156
  # resolve dnsName into IP address for PSC
157
157
  # Note that we have to check for PSC enablement also because CAS
158
158
  # instances also set the dnsName field.
159
- # Remove trailing period from DNS name. Required for SSL in Python
160
- dns_name = ret_dict.get("dnsName", "").rstrip(".")
161
- if dns_name and ret_dict.get("pscEnabled"):
162
- ip_addresses["PSC"] = dns_name
159
+ if ret_dict.get("pscEnabled"):
160
+ # Find PSC instance DNS name in the dns_names field
161
+ psc_dns_names = [
162
+ d["name"]
163
+ for d in ret_dict.get("dnsNames", [])
164
+ if d["connectionType"] == "PRIVATE_SERVICE_CONNECT"
165
+ and d["dnsScope"] == "INSTANCE"
166
+ ]
167
+ dns_name = psc_dns_names[0] if psc_dns_names else None
168
+
169
+ # Fall back do dns_name field if dns_names is not set
170
+ if dns_name is None:
171
+ dns_name = ret_dict.get("dnsName", None)
172
+
173
+ # Remove trailing period from DNS name. Required for SSL in Python
174
+ if dns_name:
175
+ ip_addresses["PSC"] = dns_name.rstrip(".")
163
176
 
164
177
  return {
165
178
  "ip_addresses": ip_addresses,
@@ -14,6 +14,7 @@
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import abc
17
18
  from dataclasses import dataclass
18
19
  import logging
19
20
  import ssl
@@ -34,6 +35,27 @@ if TYPE_CHECKING:
34
35
  logger = logging.getLogger(name=__name__)
35
36
 
36
37
 
38
+ class ConnectionInfoCache(abc.ABC):
39
+ """Abstract class for Connector connection info caches."""
40
+
41
+ @abc.abstractmethod
42
+ async def connect_info(self) -> ConnectionInfo:
43
+ pass
44
+
45
+ @abc.abstractmethod
46
+ async def force_refresh(self) -> None:
47
+ pass
48
+
49
+ @abc.abstractmethod
50
+ async def close(self) -> None:
51
+ pass
52
+
53
+ @property
54
+ @abc.abstractmethod
55
+ def closed(self) -> bool:
56
+ pass
57
+
58
+
37
59
  @dataclass
38
60
  class ConnectionInfo:
39
61
  """Contains all necessary information to connect securely to the
@@ -19,6 +19,10 @@ import re
19
19
  # Additionally, we have to support legacy "domain-scoped" projects
20
20
  # (e.g. "google.com:PROJECT")
21
21
  CONN_NAME_REGEX = re.compile(("([^:]+(:[^:]+)?):([^:]+):([^:]+)"))
22
+ # The domain name pattern in accordance with RFC 1035, RFC 1123 and RFC 2181.
23
+ DOMAIN_NAME_REGEX = re.compile(
24
+ r"^(?:[_a-z0-9](?:[_a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)?$"
25
+ )
22
26
 
23
27
 
24
28
  @dataclass
@@ -38,6 +42,16 @@ class ConnectionName:
38
42
  return f"{self.domain_name} -> {self.project}:{self.region}:{self.instance_name}"
39
43
  return f"{self.project}:{self.region}:{self.instance_name}"
40
44
 
45
+ def get_connection_string(self) -> str:
46
+ """Get the instance connection string for the Cloud SQL instance."""
47
+ return f"{self.project}:{self.region}:{self.instance_name}"
48
+
49
+
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
54
+
41
55
 
42
56
  def _parse_connection_name(connection_name: str) -> ConnectionName:
43
57
  return _parse_connection_name_with_domain_name(connection_name, "")
@@ -20,9 +20,10 @@ import asyncio
20
20
  from functools import partial
21
21
  import logging
22
22
  import os
23
+ import socket
23
24
  from threading import Thread
24
25
  from types import TracebackType
25
- from typing import Any, Optional, Union
26
+ from typing import Any, Callable, Optional, Union
26
27
 
27
28
  import google.auth
28
29
  from google.auth.credentials import Credentials
@@ -35,6 +36,7 @@ from google.cloud.sql.connector.enums import IPTypes
35
36
  from google.cloud.sql.connector.enums import RefreshStrategy
36
37
  from google.cloud.sql.connector.instance import RefreshAheadCache
37
38
  from google.cloud.sql.connector.lazy import LazyRefreshCache
39
+ from google.cloud.sql.connector.monitored_cache import MonitoredCache
38
40
  import google.cloud.sql.connector.pg8000 as pg8000
39
41
  import google.cloud.sql.connector.pymysql as pymysql
40
42
  import google.cloud.sql.connector.pytds as pytds
@@ -46,6 +48,7 @@ from google.cloud.sql.connector.utils import generate_keys
46
48
  logger = logging.getLogger(name=__name__)
47
49
 
48
50
  ASYNC_DRIVERS = ["asyncpg"]
51
+ SERVER_PROXY_PORT = 3307
49
52
  _DEFAULT_SCHEME = "https://"
50
53
  _DEFAULT_UNIVERSE_DOMAIN = "googleapis.com"
51
54
  _SQLADMIN_HOST_TEMPLATE = "sqladmin.{universe_domain}"
@@ -67,6 +70,7 @@ class Connector:
67
70
  universe_domain: Optional[str] = None,
68
71
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
69
72
  resolver: type[DefaultResolver] | type[DnsResolver] = DefaultResolver,
73
+ failover_period: int = 30,
70
74
  ) -> None:
71
75
  """Initializes a Connector instance.
72
76
 
@@ -114,6 +118,11 @@ class Connector:
114
118
  name. To resolve a DNS record to an instance connection name, use
115
119
  DnsResolver.
116
120
  Default: DefaultResolver
121
+
122
+ failover_period (int): The time interval in seconds between each
123
+ attempt to check if a failover has occured for a given instance.
124
+ Must be used with `resolver=DnsResolver` to have any effect.
125
+ Default: 30
117
126
  """
118
127
  # if refresh_strategy is str, convert to RefreshStrategy enum
119
128
  if isinstance(refresh_strategy, str):
@@ -143,9 +152,7 @@ class Connector:
143
152
  )
144
153
  # initialize dict to store caches, key is a tuple consisting of instance
145
154
  # connection name string and enable_iam_auth boolean flag
146
- self._cache: dict[
147
- tuple[str, bool], Union[RefreshAheadCache, LazyRefreshCache]
148
- ] = {}
155
+ self._cache: dict[tuple[str, bool], MonitoredCache] = {}
149
156
  self._client: Optional[CloudSQLClient] = None
150
157
 
151
158
  # initialize credentials
@@ -167,6 +174,7 @@ class Connector:
167
174
  self._enable_iam_auth = enable_iam_auth
168
175
  self._user_agent = user_agent
169
176
  self._resolver = resolver()
177
+ self._failover_period = failover_period
170
178
  # if ip_type is str, convert to IPTypes enum
171
179
  if isinstance(ip_type, str):
172
180
  ip_type = IPTypes._from_str(ip_type)
@@ -285,15 +293,19 @@ class Connector:
285
293
  driver=driver,
286
294
  )
287
295
  enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
288
- if (instance_connection_string, enable_iam_auth) in self._cache:
289
- cache = self._cache[(instance_connection_string, enable_iam_auth)]
296
+
297
+ conn_name = await self._resolver.resolve(instance_connection_string)
298
+ # Cache entry must exist and not be closed
299
+ if (str(conn_name), enable_iam_auth) in self._cache and not self._cache[
300
+ (str(conn_name), enable_iam_auth)
301
+ ].closed:
302
+ monitored_cache = self._cache[(str(conn_name), enable_iam_auth)]
290
303
  else:
291
- conn_name = await self._resolver.resolve(instance_connection_string)
292
304
  if self._refresh_strategy == RefreshStrategy.LAZY:
293
305
  logger.debug(
294
306
  f"['{conn_name}']: Refresh strategy is set to lazy refresh"
295
307
  )
296
- cache = LazyRefreshCache(
308
+ cache: Union[LazyRefreshCache, RefreshAheadCache] = LazyRefreshCache(
297
309
  conn_name,
298
310
  self._client,
299
311
  self._keys,
@@ -309,8 +321,14 @@ class Connector:
309
321
  self._keys,
310
322
  enable_iam_auth,
311
323
  )
324
+ # wrap cache as a MonitoredCache
325
+ monitored_cache = MonitoredCache(
326
+ cache,
327
+ self._failover_period,
328
+ self._resolver,
329
+ )
312
330
  logger.debug(f"['{conn_name}']: Connection info added to cache")
313
- self._cache[(instance_connection_string, enable_iam_auth)] = cache
331
+ self._cache[(str(conn_name), enable_iam_auth)] = monitored_cache
314
332
 
315
333
  connect_func = {
316
334
  "pymysql": pymysql.connect,
@@ -321,7 +339,7 @@ class Connector:
321
339
 
322
340
  # only accept supported database drivers
323
341
  try:
324
- connector = connect_func[driver]
342
+ connector: Callable = connect_func[driver] # type: ignore
325
343
  except KeyError:
326
344
  raise KeyError(f"Driver '{driver}' is not supported.")
327
345
 
@@ -339,14 +357,14 @@ class Connector:
339
357
 
340
358
  # attempt to get connection info for Cloud SQL instance
341
359
  try:
342
- conn_info = await cache.connect_info()
360
+ conn_info = await monitored_cache.connect_info()
343
361
  # validate driver matches intended database engine
344
362
  DriverMapping.validate_engine(driver, conn_info.database_version)
345
363
  ip_address = conn_info.get_preferred_ip(ip_type)
346
364
  except Exception:
347
365
  # with an error from Cloud SQL Admin API call or IP type, invalidate
348
366
  # the cache and re-raise the error
349
- await self._remove_cached(instance_connection_string, enable_iam_auth)
367
+ await self._remove_cached(str(conn_name), enable_iam_auth)
350
368
  raise
351
369
  logger.debug(f"['{conn_info.conn_name}']: Connecting to {ip_address}:3307")
352
370
  # format `user` param for automatic IAM database authn
@@ -367,18 +385,28 @@ class Connector:
367
385
  await conn_info.create_ssl_context(enable_iam_auth),
368
386
  **kwargs,
369
387
  )
370
- # synchronous drivers are blocking and run using executor
388
+ # Create socket with SSLContext for sync drivers
389
+ ctx = await conn_info.create_ssl_context(enable_iam_auth)
390
+ sock = ctx.wrap_socket(
391
+ socket.create_connection((ip_address, SERVER_PROXY_PORT)),
392
+ server_hostname=ip_address,
393
+ )
394
+ # If this connection was opened using a domain name, then store it
395
+ # for later in case we need to forcibly close it on failover.
396
+ if conn_info.conn_name.domain_name:
397
+ monitored_cache.sockets.append(sock)
398
+ # Synchronous drivers are blocking and run using executor
371
399
  connect_partial = partial(
372
400
  connector,
373
401
  ip_address,
374
- await conn_info.create_ssl_context(enable_iam_auth),
402
+ sock,
375
403
  **kwargs,
376
404
  )
377
405
  return await self._loop.run_in_executor(None, connect_partial)
378
406
 
379
407
  except Exception:
380
408
  # with any exception, we attempt a force refresh, then throw the error
381
- await cache.force_refresh()
409
+ await monitored_cache.force_refresh()
382
410
  raise
383
411
 
384
412
  async def _remove_cached(
@@ -456,6 +484,7 @@ async def create_async_connector(
456
484
  universe_domain: Optional[str] = None,
457
485
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
458
486
  resolver: type[DefaultResolver] | type[DnsResolver] = DefaultResolver,
487
+ failover_period: int = 30,
459
488
  ) -> Connector:
460
489
  """Helper function to create Connector object for asyncio connections.
461
490
 
@@ -507,6 +536,11 @@ async def create_async_connector(
507
536
  DnsResolver.
508
537
  Default: DefaultResolver
509
538
 
539
+ failover_period (int): The time interval in seconds between each
540
+ attempt to check if a failover has occured for a given instance.
541
+ Must be used with `resolver=DnsResolver` to have any effect.
542
+ Default: 30
543
+
510
544
  Returns:
511
545
  A Connector instance configured with running event loop.
512
546
  """
@@ -525,4 +559,5 @@ async def create_async_connector(
525
559
  universe_domain=universe_domain,
526
560
  refresh_strategy=refresh_strategy,
527
561
  resolver=resolver,
562
+ failover_period=failover_period,
528
563
  )
@@ -77,3 +77,10 @@ class DnsResolutionError(Exception):
77
77
  Exception to be raised when an instance connection name can not be resolved
78
78
  from a DNS record.
79
79
  """
80
+
81
+
82
+ class CacheClosedError(Exception):
83
+ """
84
+ Exception to be raised when a ConnectionInfoCache can not be accessed after
85
+ it is closed.
86
+ """
@@ -24,6 +24,7 @@ import logging
24
24
 
25
25
  from google.cloud.sql.connector.client import CloudSQLClient
26
26
  from google.cloud.sql.connector.connection_info import ConnectionInfo
27
+ from google.cloud.sql.connector.connection_info import ConnectionInfoCache
27
28
  from google.cloud.sql.connector.connection_name import ConnectionName
28
29
  from google.cloud.sql.connector.exceptions import RefreshNotValidError
29
30
  from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
@@ -35,7 +36,7 @@ logger = logging.getLogger(name=__name__)
35
36
  APPLICATION_NAME = "cloud-sql-python-connector"
36
37
 
37
38
 
38
- class RefreshAheadCache:
39
+ class RefreshAheadCache(ConnectionInfoCache):
39
40
  """Cache that refreshes connection info in the background prior to expiration.
40
41
 
41
42
  Background tasks are used to schedule refresh attempts to get a new
@@ -74,6 +75,15 @@ class RefreshAheadCache:
74
75
  self._refresh_in_progress = asyncio.locks.Event()
75
76
  self._current: asyncio.Task = self._schedule_refresh(0)
76
77
  self._next: asyncio.Task = self._current
78
+ self._closed = False
79
+
80
+ @property
81
+ def conn_name(self) -> ConnectionName:
82
+ return self._conn_name
83
+
84
+ @property
85
+ def closed(self) -> bool:
86
+ return self._closed
77
87
 
78
88
  async def force_refresh(self) -> None:
79
89
  """
@@ -212,3 +222,4 @@ class RefreshAheadCache:
212
222
  # gracefully wait for tasks to cancel
213
223
  tasks = asyncio.gather(self._current, self._next, return_exceptions=True)
214
224
  await asyncio.wait_for(tasks, timeout=2.0)
225
+ self._closed = True
@@ -21,13 +21,14 @@ from typing import Optional
21
21
 
22
22
  from google.cloud.sql.connector.client import CloudSQLClient
23
23
  from google.cloud.sql.connector.connection_info import ConnectionInfo
24
+ from google.cloud.sql.connector.connection_info import ConnectionInfoCache
24
25
  from google.cloud.sql.connector.connection_name import ConnectionName
25
26
  from google.cloud.sql.connector.refresh_utils import _refresh_buffer
26
27
 
27
28
  logger = logging.getLogger(name=__name__)
28
29
 
29
30
 
30
- class LazyRefreshCache:
31
+ class LazyRefreshCache(ConnectionInfoCache):
31
32
  """Cache that refreshes connection info when a caller requests a connection.
32
33
 
33
34
  Only refreshes the cache when a new connection is requested and the current
@@ -62,6 +63,15 @@ class LazyRefreshCache:
62
63
  self._lock = asyncio.Lock()
63
64
  self._cached: Optional[ConnectionInfo] = None
64
65
  self._needs_refresh = False
66
+ self._closed = False
67
+
68
+ @property
69
+ def conn_name(self) -> ConnectionName:
70
+ return self._conn_name
71
+
72
+ @property
73
+ def closed(self) -> bool:
74
+ return self._closed
65
75
 
66
76
  async def force_refresh(self) -> None:
67
77
  """
@@ -121,4 +131,5 @@ class LazyRefreshCache:
121
131
  """Close is a no-op and provided purely for a consistent interface with
122
132
  other cache types.
123
133
  """
124
- pass
134
+ self._closed = True
135
+ return
@@ -0,0 +1,146 @@
1
+ # Copyright 2025 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
+ import asyncio
16
+ import logging
17
+ import ssl
18
+ from typing import Any, Callable, Optional, Union
19
+
20
+ from google.cloud.sql.connector.connection_info import ConnectionInfo
21
+ from google.cloud.sql.connector.connection_info import ConnectionInfoCache
22
+ from google.cloud.sql.connector.exceptions import CacheClosedError
23
+ from google.cloud.sql.connector.instance import RefreshAheadCache
24
+ from google.cloud.sql.connector.lazy import LazyRefreshCache
25
+ from google.cloud.sql.connector.resolver import DefaultResolver
26
+ from google.cloud.sql.connector.resolver import DnsResolver
27
+
28
+ logger = logging.getLogger(name=__name__)
29
+
30
+
31
+ class MonitoredCache(ConnectionInfoCache):
32
+ def __init__(
33
+ self,
34
+ cache: Union[RefreshAheadCache, LazyRefreshCache],
35
+ failover_period: int,
36
+ resolver: Union[DefaultResolver, DnsResolver],
37
+ ) -> None:
38
+ self.resolver = resolver
39
+ self.cache = cache
40
+ self.domain_name_ticker: Optional[asyncio.Task] = None
41
+ self.sockets: list[ssl.SSLSocket] = []
42
+
43
+ # If domain name is configured for instance and failover period is set,
44
+ # poll for DNS record changes.
45
+ if self.cache.conn_name.domain_name and failover_period > 0:
46
+ self.domain_name_ticker = asyncio.create_task(
47
+ ticker(failover_period, self._check_domain_name)
48
+ )
49
+ logger.debug(
50
+ f"['{self.cache.conn_name}']: Configured polling of domain "
51
+ f"name with failover period of {failover_period} seconds."
52
+ )
53
+
54
+ @property
55
+ def closed(self) -> bool:
56
+ return self.cache.closed
57
+
58
+ def _purge_closed_sockets(self) -> None:
59
+ """Remove closed sockets from monitored cache.
60
+
61
+ If a socket is closed by the database driver we should remove it from
62
+ list of sockets.
63
+ """
64
+ open_sockets = []
65
+ for socket in self.sockets:
66
+ # Check fileno for if socket is closed. Will return
67
+ # -1 on failure, which will be used to signal socket closed.
68
+ if socket.fileno() != -1:
69
+ open_sockets.append(socket)
70
+ self.sockets = open_sockets
71
+
72
+ async def _check_domain_name(self) -> None:
73
+ # remove any closed connections from cache
74
+ self._purge_closed_sockets()
75
+ try:
76
+ # Resolve domain name and see if Cloud SQL instance connection name
77
+ # has changed. If it has, close all connections.
78
+ new_conn_name = await self.resolver.resolve(
79
+ self.cache.conn_name.domain_name
80
+ )
81
+ if new_conn_name != self.cache.conn_name:
82
+ logger.debug(
83
+ f"['{self.cache.conn_name}']: Cloud SQL instance changed "
84
+ f"from {self.cache.conn_name.get_connection_string()} to "
85
+ f"{new_conn_name.get_connection_string()}, closing all "
86
+ "connections!"
87
+ )
88
+ await self.close()
89
+
90
+ except Exception as e:
91
+ # Domain name checks should not be fatal, log error and continue.
92
+ logger.debug(
93
+ f"['{self.cache.conn_name}']: Unable to check domain name, "
94
+ f"domain name {self.cache.conn_name.domain_name} did not "
95
+ f"resolve: {e}"
96
+ )
97
+
98
+ async def connect_info(self) -> ConnectionInfo:
99
+ if self.closed:
100
+ raise CacheClosedError(
101
+ "Can not get connection info, cache has already been closed."
102
+ )
103
+ return await self.cache.connect_info()
104
+
105
+ async def force_refresh(self) -> None:
106
+ # if cache is closed do not refresh
107
+ if self.closed:
108
+ return
109
+ return await self.cache.force_refresh()
110
+
111
+ async def close(self) -> None:
112
+ # Cancel domain name ticker task.
113
+ if self.domain_name_ticker:
114
+ self.domain_name_ticker.cancel()
115
+ try:
116
+ await self.domain_name_ticker
117
+ except asyncio.CancelledError:
118
+ logger.debug(
119
+ f"['{self.cache.conn_name}']: Cancelled domain name polling task."
120
+ )
121
+ finally:
122
+ self.domain_name_ticker = None
123
+ # If cache is already closed, no further work.
124
+ if self.closed:
125
+ return
126
+
127
+ # Close underyling ConnectionInfoCache
128
+ await self.cache.close()
129
+
130
+ # Close any still open sockets
131
+ for socket in self.sockets:
132
+ # Check fileno for if socket is closed. Will return
133
+ # -1 on failure, which will be used to signal socket closed.
134
+ if socket.fileno() != -1:
135
+ socket.close()
136
+
137
+
138
+ async def ticker(interval: int, function: Callable, *args: Any, **kwargs: Any) -> None:
139
+ """
140
+ Ticker function to sleep for specified interval and then schedule call
141
+ to given function.
142
+ """
143
+ while True:
144
+ # Sleep for interval and then schedule task
145
+ await asyncio.sleep(interval)
146
+ asyncio.create_task(function(*args, **kwargs))
@@ -14,18 +14,15 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  """
16
16
 
17
- import socket
18
17
  import ssl
19
18
  from typing import Any, TYPE_CHECKING
20
19
 
21
- SERVER_PROXY_PORT = 3307
22
-
23
20
  if TYPE_CHECKING:
24
21
  import pg8000
25
22
 
26
23
 
27
24
  def connect(
28
- ip_address: str, ctx: ssl.SSLContext, **kwargs: Any
25
+ ip_address: str, sock: ssl.SSLSocket, **kwargs: Any
29
26
  ) -> "pg8000.dbapi.Connection":
30
27
  """Helper function to create a pg8000 DB-API connection object.
31
28
 
@@ -33,8 +30,8 @@ def connect(
33
30
  :param ip_address: A string containing an IP address for the Cloud SQL
34
31
  instance.
35
32
 
36
- :type ctx: ssl.SSLContext
37
- :param ctx: An SSLContext object created from the Cloud SQL server CA
33
+ :type sock: ssl.SSLSocket
34
+ :param sock: An SSLSocket object created from the Cloud SQL server CA
38
35
  cert and ephemeral cert.
39
36
 
40
37
 
@@ -48,12 +45,6 @@ def connect(
48
45
  'Unable to import module "pg8000." Please install and try again.'
49
46
  )
50
47
 
51
- # Create socket and wrap with context.
52
- sock = ctx.wrap_socket(
53
- socket.create_connection((ip_address, SERVER_PROXY_PORT)),
54
- server_hostname=ip_address,
55
- )
56
-
57
48
  user = kwargs.pop("user")
58
49
  db = kwargs.pop("db")
59
50
  passwd = kwargs.pop("password", None)
@@ -14,18 +14,15 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  """
16
16
 
17
- import socket
18
17
  import ssl
19
18
  from typing import Any, TYPE_CHECKING
20
19
 
21
- SERVER_PROXY_PORT = 3307
22
-
23
20
  if TYPE_CHECKING:
24
21
  import pymysql
25
22
 
26
23
 
27
24
  def connect(
28
- ip_address: str, ctx: ssl.SSLContext, **kwargs: Any
25
+ ip_address: str, sock: ssl.SSLSocket, **kwargs: Any
29
26
  ) -> "pymysql.connections.Connection":
30
27
  """Helper function to create a pymysql DB-API connection object.
31
28
 
@@ -33,8 +30,8 @@ def connect(
33
30
  :param ip_address: A string containing an IP address for the Cloud SQL
34
31
  instance.
35
32
 
36
- :type ctx: ssl.SSLContext
37
- :param ctx: An SSLContext object created from the Cloud SQL server CA
33
+ :type sock: ssl.SSLSocket
34
+ :param sock: An SSLSocket object created from the Cloud SQL server CA
38
35
  cert and ephemeral cert.
39
36
 
40
37
  :rtype: pymysql.Connection
@@ -50,11 +47,6 @@ def connect(
50
47
  # allow automatic IAM database authentication to not require password
51
48
  kwargs["password"] = kwargs["password"] if "password" in kwargs else None
52
49
 
53
- # Create socket and wrap with context.
54
- sock = ctx.wrap_socket(
55
- socket.create_connection((ip_address, SERVER_PROXY_PORT)),
56
- server_hostname=ip_address,
57
- )
58
50
  # pop timeout as timeout arg is called 'connect_timeout' for pymysql
59
51
  timeout = kwargs.pop("timeout")
60
52
  kwargs["connect_timeout"] = kwargs.get("connect_timeout", timeout)
@@ -15,27 +15,24 @@ limitations under the License.
15
15
  """
16
16
 
17
17
  import platform
18
- import socket
19
18
  import ssl
20
19
  from typing import Any, TYPE_CHECKING
21
20
 
22
21
  from google.cloud.sql.connector.exceptions import PlatformNotSupportedError
23
22
 
24
- SERVER_PROXY_PORT = 3307
25
-
26
23
  if TYPE_CHECKING:
27
24
  import pytds
28
25
 
29
26
 
30
- def connect(ip_address: str, ctx: ssl.SSLContext, **kwargs: Any) -> "pytds.Connection":
27
+ def connect(ip_address: str, sock: ssl.SSLSocket, **kwargs: Any) -> "pytds.Connection":
31
28
  """Helper function to create a pytds DB-API connection object.
32
29
 
33
30
  :type ip_address: str
34
31
  :param ip_address: A string containing an IP address for the Cloud SQL
35
32
  instance.
36
33
 
37
- :type ctx: ssl.SSLContext
38
- :param ctx: An SSLContext object created from the Cloud SQL server CA
34
+ :type sock: ssl.SSLSocket
35
+ :param sock: An SSLSocket object created from the Cloud SQL server CA
39
36
  cert and ephemeral cert.
40
37
 
41
38
 
@@ -51,11 +48,6 @@ def connect(ip_address: str, ctx: ssl.SSLContext, **kwargs: Any) -> "pytds.Conne
51
48
 
52
49
  db = kwargs.pop("db", None)
53
50
 
54
- # Create socket and wrap with context.
55
- sock = ctx.wrap_socket(
56
- socket.create_connection((ip_address, SERVER_PROXY_PORT)),
57
- server_hostname=ip_address,
58
- )
59
51
  if kwargs.pop("active_directory_auth", False):
60
52
  if platform.system() == "Windows":
61
53
  # Ignore username and password if using active directory auth
@@ -17,6 +17,7 @@ import dns.asyncresolver
17
17
  from google.cloud.sql.connector.connection_name import (
18
18
  _parse_connection_name_with_domain_name,
19
19
  )
20
+ from google.cloud.sql.connector.connection_name import _is_valid_domain
20
21
  from google.cloud.sql.connector.connection_name import _parse_connection_name
21
22
  from google.cloud.sql.connector.connection_name import ConnectionName
22
23
  from google.cloud.sql.connector.exceptions import DnsResolutionError
@@ -40,8 +41,16 @@ class DnsResolver(dns.asyncresolver.Resolver):
40
41
  conn_name = _parse_connection_name(dns)
41
42
  except ValueError:
42
43
  # The connection name was not project:region:instance format.
43
- # Attempt to query a TXT record to get connection name.
44
- conn_name = await self.query_dns(dns)
44
+ # Check if connection name is a valid DNS domain name
45
+ if _is_valid_domain(dns):
46
+ # Attempt to query a TXT record to get connection name.
47
+ conn_name = await self.query_dns(dns)
48
+ else:
49
+ raise ValueError(
50
+ "Arg `instance_connection_string` must have "
51
+ "format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
52
+ f"name, got {dns}."
53
+ )
45
54
  return conn_name
46
55
 
47
56
  async def query_dns(self, dns: str) -> ConnectionName:
@@ -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.17.0.dev0"
15
+ __version__ = "1.18.0"
@@ -1,24 +0,0 @@
1
- google/cloud/sql/connector/__init__.py,sha256=kSGB2q6hQwkFLD1eI7RjY4BvPLvg2FD3wfPBmbnsjDE,1149
2
- google/cloud/sql/connector/asyncpg.py,sha256=HUOB0rRhr2-zMbygDtdWTDNlYqEPkN0ADqCIpM3waDc,1839
3
- google/cloud/sql/connector/client.py,sha256=ocUN1nrgZyJRnxgh6Ym3w0c_qKb8hqKCmR1OiJ2KUpM,12622
4
- google/cloud/sql/connector/connection_info.py,sha256=cFNOeHJ0lQODf7q5Hc25LXsNWzWgzYcFMrBuNdZ1y5k,4430
5
- google/cloud/sql/connector/connection_name.py,sha256=4F5XhgmSWGEeF-3TJk2E61YPbxMAecPGw56nPTZmQ2o,2067
6
- google/cloud/sql/connector/connector.py,sha256=emahfH9ZSrbooFBV-m6gcwGJ7yYQwaUJuCJPz0DHfHA,22547
7
- google/cloud/sql/connector/enums.py,sha256=dVBS9eEWNrHtzze0AJlmtbdUogG3EocNWOQcI_8qPd0,2937
8
- google/cloud/sql/connector/exceptions.py,sha256=eyflYggkKZx3t944FTPqQrX-R75lZgtpOwC4uVLPhG0,1823
9
- google/cloud/sql/connector/instance.py,sha256=nRzhE7t_M6AhjiYvEZHOjkBHx3d9wGRCC6RekgDApFI,8379
10
- google/cloud/sql/connector/lazy.py,sha256=p1wLte49xE_aHiY5shjMbxwytgW-ycS0Hd67C34gaDY,4631
11
- google/cloud/sql/connector/pg8000.py,sha256=gZisjfXP4rNIjyxOQS75fvr0Xe6eLrFo4mIhVjoMLTw,1815
12
- google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- google/cloud/sql/connector/pymysql.py,sha256=82DwSJUhZHH2AW-vO7qhkljyShwj6Kmd17WCbOGZH74,2106
14
- google/cloud/sql/connector/pytds.py,sha256=h-1pDhqLDkhviKBBOJ29gnIeYHKWw7Dtj91VLu1iNUI,2453
15
- google/cloud/sql/connector/rate_limiter.py,sha256=bPTupfM4f5QRSCmCAWMqjQylEB7bUM-50plVBU3W2CA,2993
16
- google/cloud/sql/connector/refresh_utils.py,sha256=HfuuLdlqg2IxusB4T3BMj-BJlGsXtHsrCX_Hp-eJ_j0,5063
17
- google/cloud/sql/connector/resolver.py,sha256=cNDJMhmS_0CIAmz8kCABKHJNLNm1wiwIVoPEN-gWzzY,2850
18
- google/cloud/sql/connector/utils.py,sha256=7g1fX-JsZc7xOgTuxrAiroFHQIQk_BEJZNYaKOinwQY,3508
19
- google/cloud/sql/connector/version.py,sha256=YfNpSkpcPhRwrFt2dV7ut5xGe0KjgvNinweha483X2w,603
20
- cloud_sql_python_connector-1.17.0.dev0.dist-info/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
21
- cloud_sql_python_connector-1.17.0.dev0.dist-info/METADATA,sha256=qlls3nyZF0ZxHsLxEvorF9ukUEyY5pQpWUhvbQ0H2tk,30494
22
- cloud_sql_python_connector-1.17.0.dev0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
23
- cloud_sql_python_connector-1.17.0.dev0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
24
- cloud_sql_python_connector-1.17.0.dev0.dist-info/RECORD,,