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
@@ -61,7 +61,8 @@ 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
+ PSYCOPG = "POSTGRES" # noqa: PIE796
65
66
  PYMYSQL = "MYSQL"
66
67
  PYTDS = "SQLSERVER"
67
68
 
@@ -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):
@@ -79,6 +73,12 @@ class DnsResolutionError(Exception):
79
73
  """
80
74
 
81
75
 
76
+ class DnsLoopError(DnsResolutionError):
77
+ """
78
+ Exception to be raised when a CNAME loop is detected during DNS resolution.
79
+ """
80
+
81
+
82
82
  class CacheClosedError(Exception):
83
83
  """
84
84
  Exception to be raised when a ConnectionInfoCache can not be accessed after
@@ -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,18 +12,24 @@
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
21
+
22
+ import aiohttp
19
23
 
20
24
  from google.cloud.sql.connector.connection_info import ConnectionInfo
21
25
  from google.cloud.sql.connector.connection_info import ConnectionInfoCache
22
26
  from google.cloud.sql.connector.exceptions import CacheClosedError
27
+ from google.cloud.sql.connector.exceptions import DnsLoopError
23
28
  from google.cloud.sql.connector.instance import RefreshAheadCache
24
29
  from google.cloud.sql.connector.lazy import LazyRefreshCache
25
30
  from google.cloud.sql.connector.resolver import DefaultResolver
26
31
  from google.cloud.sql.connector.resolver import DnsResolver
32
+ from google.cloud.sql.connector.resolver import is_instance_dns_name
27
33
 
28
34
  logger = logging.getLogger(name=__name__)
29
35
 
@@ -31,18 +37,27 @@ logger = logging.getLogger(name=__name__)
31
37
  class MonitoredCache(ConnectionInfoCache):
32
38
  def __init__(
33
39
  self,
34
- cache: Union[RefreshAheadCache, LazyRefreshCache],
40
+ cache: RefreshAheadCache | LazyRefreshCache,
35
41
  failover_period: int,
36
- resolver: Union[DefaultResolver, DnsResolver],
42
+ resolver: DefaultResolver | DnsResolver,
37
43
  ) -> None:
38
44
  self.resolver = resolver
39
45
  self.cache = cache
40
- self.domain_name_ticker: Optional[asyncio.Task] = None
46
+ self.domain_name_ticker: asyncio.Task | None = None
41
47
  self.sockets: list[ssl.SSLSocket] = []
42
48
 
43
49
  # If domain name is configured for instance and failover period is set,
44
50
  # poll for DNS record changes.
45
- if self.cache.conn_name.domain_name and failover_period > 0:
51
+ is_instance_dns = False
52
+ if self.cache.conn_name.domain_name:
53
+ is_instance_dns = is_instance_dns_name(
54
+ self.cache.conn_name.domain_name or ""
55
+ )
56
+ if (
57
+ self.cache.conn_name.domain_name
58
+ and failover_period > 0
59
+ and not is_instance_dns
60
+ ):
46
61
  self.domain_name_ticker = asyncio.create_task(
47
62
  ticker(failover_period, self._check_domain_name)
48
63
  )
@@ -87,13 +102,29 @@ class MonitoredCache(ConnectionInfoCache):
87
102
  )
88
103
  await self.close()
89
104
 
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}"
105
+ except Exception as e: # noqa: BLE001
106
+ if self._is_non_transient(e):
107
+ logger.debug(
108
+ f"['{self.cache.conn_name}']: Domain name check failed with "
109
+ f"non-transient error, closing all connections: {e}"
110
+ )
111
+ await self.close()
112
+ else:
113
+ # Domain name checks should not be fatal for transient errors, log error and continue.
114
+ logger.debug(
115
+ f"['{self.cache.conn_name}']: Unable to check domain name, "
116
+ f"domain name {self.cache.conn_name.domain_name} did not "
117
+ f"resolve (transient error): {e}"
118
+ )
119
+
120
+ def _is_non_transient(self, e: Exception) -> bool:
121
+ return (
122
+ isinstance(e, (ValueError, DnsLoopError))
123
+ or (
124
+ isinstance(e, aiohttp.ClientResponseError)
125
+ and e.status in (403, 404)
96
126
  )
127
+ )
97
128
 
98
129
  async def connect_info(self) -> ConnectionInfo:
99
130
  if self.closed:
@@ -48,7 +48,9 @@ def connect(
48
48
  )
49
49
 
50
50
  user = kwargs.pop("user")
51
- db = kwargs.pop("db")
51
+ db = kwargs.pop("database", kwargs.pop("db", None))
52
+ if db is None:
53
+ raise KeyError("database")
52
54
  passwd = kwargs.pop("password", None)
53
55
  return pg8000.dbapi.connect(
54
56
  user,
@@ -0,0 +1,235 @@
1
+ # Copyright 2026 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 logging
16
+ import os
17
+ import selectors
18
+ import socket
19
+ import ssl
20
+ import tempfile
21
+ import threading
22
+ from typing import Any, TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ import psycopg
26
+
27
+ logger = logging.getLogger(name=__name__)
28
+
29
+
30
+ def _proxy(local: socket.socket, remote: "ssl.SSLSocket") -> None:
31
+ """Single-threaded selectors-based proxy to avoid SSLSocket thread-safety issues."""
32
+ sel = selectors.DefaultSelector()
33
+ sel.register(local, selectors.EVENT_READ, data="local")
34
+ sel.register(remote, selectors.EVENT_READ, data="remote")
35
+
36
+ def forward_pending() -> bool:
37
+ """Read any pending decrypted data from SSL buffer and forward it.
38
+ Returns True if EOF was reached or error occurred (should exit).
39
+ """
40
+ if not hasattr(remote, "pending"):
41
+ return False
42
+ pending_bytes = remote.pending()
43
+ if not isinstance(pending_bytes, int):
44
+ return False
45
+
46
+ while pending_bytes > 0:
47
+ try:
48
+ data = remote.recv(8192)
49
+ except OSError as e:
50
+ logger.debug("psycopg proxy: remote recv pending error: %s", e)
51
+ return True
52
+ if not data:
53
+ logger.debug("psycopg proxy: remote pending EOF")
54
+ return True
55
+ try:
56
+ local.sendall(data)
57
+ except OSError as e:
58
+ logger.debug("psycopg proxy: local send pending error: %s", e)
59
+ return True
60
+ try:
61
+ pending_bytes = remote.pending()
62
+ except OSError:
63
+ break
64
+ if not isinstance(pending_bytes, int):
65
+ break
66
+ return False
67
+
68
+ try:
69
+ while True:
70
+ # First check if there is any pending data in SSL buffer
71
+ if forward_pending():
72
+ break
73
+
74
+ events = sel.select()
75
+
76
+ for key, mask in events:
77
+ if key.data == "local":
78
+ try:
79
+ data = local.recv(8192)
80
+ except OSError as e:
81
+ logger.debug("psycopg proxy: local recv error: %s", e)
82
+ return
83
+ if not data:
84
+ logger.debug("psycopg proxy: local EOF")
85
+ return
86
+ try:
87
+ remote.sendall(data)
88
+ except OSError as e:
89
+ logger.debug("psycopg proxy: remote send error: %s", e)
90
+ return
91
+ elif key.data == "remote":
92
+ try:
93
+ data = remote.recv(8192)
94
+ except OSError as e:
95
+ logger.debug("psycopg proxy: remote recv error: %s", e)
96
+ return
97
+ if not data:
98
+ logger.debug("psycopg proxy: remote EOF")
99
+ return
100
+ try:
101
+ local.sendall(data)
102
+ except OSError as e:
103
+ logger.debug("psycopg proxy: local send error: %s", e)
104
+ return
105
+ except OSError as e:
106
+ logger.debug("psycopg proxy: OSError in loop: %s", e)
107
+ finally:
108
+ sel.close()
109
+ for s in (local, remote):
110
+ try:
111
+ s.shutdown(socket.SHUT_RDWR)
112
+ except OSError:
113
+ pass
114
+ try:
115
+ s.close()
116
+ except OSError:
117
+ pass
118
+
119
+
120
+ def connect(
121
+ ip_address: str, remote_sock: "ssl.SSLSocket", **kwargs: Any
122
+ ) -> "psycopg.Connection":
123
+ """Create a psycopg DBAPI connection object.
124
+
125
+ Because psycopg does not accept a pre-connected socket, this function
126
+ creates a temporary Unix domain socket, tells psycopg to connect there,
127
+ and runs a background proxy that forwards bytes between that socket and
128
+ the already-established Cloud SQL TLS connection.
129
+
130
+ Args:
131
+ ip_address (str): IP address of the Cloud SQL instance.
132
+ remote_sock (ssl.SSLSocket): SSL/TLS secure socket stream connected to the
133
+ Cloud SQL proxy server.
134
+
135
+ Returns:
136
+ psycopg.Connection: A psycopg Connection object for the Cloud SQL instance.
137
+ """
138
+ try:
139
+ import psycopg
140
+ except ImportError:
141
+ raise ImportError(
142
+ 'Unable to import module "psycopg." Please install and try again.'
143
+ )
144
+
145
+ if not hasattr(socket, "AF_UNIX"):
146
+ raise NotImplementedError(
147
+ "Unix domain sockets (AF_UNIX) are not supported on this platform"
148
+ )
149
+
150
+ tmpdir = tempfile.mkdtemp()
151
+ socket_path = os.path.join(tmpdir, ".s.PGSQL.5432")
152
+ logger.debug("psycopg: created Unix socket at %s", socket_path)
153
+
154
+ local_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
155
+ local_sock.bind(socket_path)
156
+ local_sock.listen(1)
157
+
158
+ def _accept_and_proxy() -> None:
159
+ """Accept one connection then proxy bytes until the connection closes."""
160
+ unix_conn = None
161
+ try:
162
+ unix_conn, _ = local_sock.accept()
163
+ local_sock.close()
164
+ logger.debug("psycopg proxy: accepted connection, starting proxy")
165
+ _proxy(unix_conn, remote_sock)
166
+ except Exception as e: # noqa: BLE001
167
+ logger.debug("psycopg proxy: error in accept/proxy thread: %s", e)
168
+ # Ensure cleanup on any exception
169
+ if unix_conn:
170
+ try:
171
+ unix_conn.shutdown(socket.SHUT_RDWR)
172
+ except OSError:
173
+ pass
174
+ try:
175
+ unix_conn.close()
176
+ except OSError:
177
+ pass
178
+ try:
179
+ remote_sock.shutdown(socket.SHUT_RDWR)
180
+ except OSError:
181
+ pass
182
+ try:
183
+ remote_sock.close()
184
+ except OSError:
185
+ pass
186
+
187
+ threading.Thread(target=_accept_and_proxy, daemon=True).start()
188
+
189
+ user = kwargs.pop("user")
190
+ db = kwargs.pop("db")
191
+ passwd = kwargs.pop("password", None)
192
+ # SSL is already handled by the underlying SSLSocket; disable it on the
193
+ # Unix socket so psycopg does not attempt a second TLS handshake.
194
+ kwargs.pop("sslmode", None)
195
+ timeout = kwargs.pop("timeout", None)
196
+ if timeout is not None:
197
+ kwargs["connect_timeout"] = int(timeout)
198
+
199
+ logger.debug("psycopg: connecting as user=%s dbname=%s", user, db)
200
+ try:
201
+ conn = psycopg.connect(
202
+ user=user,
203
+ dbname=db,
204
+ password=passwd,
205
+ host=tmpdir,
206
+ port=5432,
207
+ sslmode="disable",
208
+ **kwargs,
209
+ )
210
+ logger.debug("psycopg: connection established")
211
+ return conn
212
+ except Exception as e:
213
+ logger.debug("psycopg: connection failed: %s", e)
214
+ # psycopg never connected (or failed mid-handshake); close the server
215
+ # socket so the proxy thread unblocks and exits cleanly.
216
+ try:
217
+ local_sock.close()
218
+ except OSError:
219
+ pass
220
+ try:
221
+ remote_sock.close()
222
+ except OSError:
223
+ pass
224
+ raise
225
+ finally:
226
+ # The socket file and its parent directory are only needed during the
227
+ # initial connect() call; remove them now regardless of outcome.
228
+ try:
229
+ os.remove(socket_path)
230
+ except OSError:
231
+ pass
232
+ try:
233
+ os.rmdir(tmpdir)
234
+ except OSError:
235
+ pass
@@ -47,11 +47,18 @@ 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")
54
54
  kwargs["connect_timeout"] = kwargs.get("connect_timeout", timeout)
55
+
56
+ # map 'db' to 'database' to avoid deprecation warning in pymysql,
57
+ # giving precedence to 'database' if both are provided
58
+ db = kwargs.pop("database", kwargs.pop("db", None))
59
+ if db is not None:
60
+ kwargs["database"] = db
61
+
55
62
  # Create pymysql connection object and hand in pre-made connection
56
63
  conn = pymysql.Connection(host=ip_address, defer_connect=True, **kwargs)
57
64
  conn.connect(sock)
@@ -47,7 +47,7 @@ def connect(ip_address: str, sock: ssl.SSLSocket, **kwargs: Any) -> "pytds.Conne
47
47
  'Unable to import module "pytds." Please install and try again.'
48
48
  )
49
49
 
50
- db = kwargs.pop("db", None)
50
+ db = kwargs.pop("database", kwargs.pop("db", None))
51
51
 
52
52
  if kwargs.pop("active_directory_auth", False):
53
53
  if platform.system() == "Windows":
@@ -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