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
@@ -12,7 +12,10 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from typing import List
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Any
16
19
 
17
20
  import dns.asyncresolver
18
21
 
@@ -22,12 +25,32 @@ from google.cloud.sql.connector.connection_name import (
22
25
  _parse_connection_name_with_domain_name,
23
26
  )
24
27
  from google.cloud.sql.connector.connection_name import ConnectionName
28
+ from google.cloud.sql.connector.exceptions import DnsLoopError
25
29
  from google.cloud.sql.connector.exceptions import DnsResolutionError
26
30
 
31
+ # ^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])
32
+
33
+ INSTANCE_DNS_NAME_PATTERN = re.compile(
34
+ r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])\.(sql|sql-psa|sql-psc)\.goog\.?$"
35
+ )
36
+
37
+ GLOBAL_INSTANCE_DNS_PATTERN = re.compile(r"\.global\.sql(-\w+)?\.goog\.?$")
38
+
39
+
40
+ def is_instance_dns_name(name: str) -> bool:
41
+ lower = name.lower()
42
+ return bool(
43
+ INSTANCE_DNS_NAME_PATTERN.search(lower)
44
+ and not GLOBAL_INSTANCE_DNS_PATTERN.search(lower)
45
+ )
46
+
27
47
 
28
48
  class DefaultResolver:
29
49
  """DefaultResolver simply validates and parses instance connection name."""
30
50
 
51
+ def __init__(self, *args: Any, client: Any | None = None, **kwargs: Any) -> None:
52
+ pass
53
+
31
54
  async def resolve(self, connection_name: str) -> ConnectionName:
32
55
  return _parse_connection_name(connection_name)
33
56
 
@@ -38,54 +61,113 @@ class DnsResolver(dns.asyncresolver.Resolver):
38
61
  TXT records in DNS.
39
62
  """
40
63
 
64
+ def __init__(self, *args: Any, client: Any | None = None, **kwargs: Any) -> None:
65
+ super().__init__(*args, **kwargs)
66
+ self._client = client
67
+
41
68
  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:
69
+ current = dns
70
+ visited = {current}
71
+
72
+ # Max 10 iterations to prevent infinite CNAME loops
73
+ for _ in range(10):
74
+ try:
75
+ domain_val = dns if current != dns else ""
76
+ conn_name = _parse_connection_name_with_domain_name(
77
+ current, domain_val
78
+ )
79
+ return conn_name
80
+ except ValueError:
81
+ pass
82
+
83
+ dns_normalized = current.rstrip(".")
84
+ match = INSTANCE_DNS_NAME_PATTERN.match(dns_normalized.lower())
85
+ if match:
86
+ region = match.group(3)
87
+ if region != "global":
88
+ if self._client is None:
89
+ raise ValueError(
90
+ "SQLAdmin client is not configured in the resolver."
91
+ )
92
+
93
+ dns_name_with_dot = dns_normalized + "."
94
+ resp = await self._client.resolve_connect_settings(
95
+ dns_name_with_dot, region
96
+ )
97
+ resolved_conn_name = resp["connectionName"]
98
+ return _parse_connection_name_with_domain_name(
99
+ resolved_conn_name, dns
100
+ )
101
+
102
+ if not _is_valid_domain(current):
51
103
  raise ValueError(
52
104
  "Arg `instance_connection_string` must have "
53
105
  "format: PROJECT:REGION:INSTANCE or be a valid DNS domain "
54
106
  f"name, got {dns}."
55
107
  )
56
- return conn_name
57
108
 
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 []
109
+ cname_found = False
110
+ try:
111
+ cname = await self.resolve_cname(current)
112
+ cname_found = True
113
+ except DnsResolutionError:
114
+ pass
115
+
116
+ if cname_found:
117
+ if cname in visited:
118
+ raise DnsLoopError(f"CNAME loop detected for `{dns}`")
119
+ visited.add(cname)
120
+ current = cname
121
+ continue
122
+
123
+ try:
124
+ rdata = await self.resolve_txt(current)
125
+ except DnsResolutionError as e:
126
+ raise DnsResolutionError(
127
+ f"Unable to resolve TXT record for `{dns}`"
128
+ ) from e
67
129
 
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
130
  rdata.sort()
76
- # Attempt to parse records, returning the first valid record.
77
131
  for record in rdata:
78
132
  try:
79
- conn_name = _parse_connection_name_with_domain_name(record, dns)
133
+ conn_name = _parse_connection_name_with_domain_name(
134
+ record, dns
135
+ )
80
136
  return conn_name
81
- except Exception:
137
+ except Exception: # noqa: BLE001, S112
82
138
  continue
83
- # If all records failed to parse, throw error
139
+
84
140
  raise DnsResolutionError(
85
- f"Unable to parse TXT record for `{dns}` -> `{rdata[0]}`"
141
+ f"Unable to parse TXT record for `{current}` -> `{rdata[0]}`"
142
+ if rdata
143
+ else f"Unable to resolve TXT record for `{current}`"
86
144
  )
87
- # Don't override above DnsResolutionError
88
- except DnsResolutionError:
89
- raise
145
+
146
+ raise DnsLoopError(
147
+ f"CNAME loop detected or max resolution depth reached for `{dns}`"
148
+ )
149
+
150
+ async def resolve_cname(self, dns: str) -> str:
151
+ try:
152
+ answers = await super().resolve(dns, "CNAME", raise_on_no_answer=True)
153
+ return str(answers[0].target).rstrip(".")
154
+ except Exception as e:
155
+ raise DnsResolutionError(
156
+ f"Unable to resolve CNAME record for `{dns}`"
157
+ ) from e
158
+
159
+ async def resolve_txt(self, dns: str) -> list[str]:
160
+ try:
161
+ answers = await super().resolve(dns, "TXT", raise_on_no_answer=True)
162
+ return [record.to_text().strip('"') for record in answers]
90
163
  except Exception as e:
91
- raise DnsResolutionError(f"Unable to resolve TXT record for `{dns}`") from e
164
+ raise DnsResolutionError(
165
+ f"Unable to resolve TXT record for `{dns}`"
166
+ ) from e
167
+
168
+ async def resolve_a_record(self, dns: str) -> list[str]:
169
+ try:
170
+ records = await super().resolve(dns, "A", raise_on_no_answer=True)
171
+ return [record.to_text() for record in records]
172
+ except Exception: # noqa: BLE001
173
+ 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.4"
15
+ __version__ = "1.22.0"
@@ -1,25 +0,0 @@
1
- cloud_sql_python_connector-1.20.4.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=v4ohOnSYjHTOvJPhTE761xP1KKo4FUJQW_WAnNW_2g4,1918
4
- google/cloud/sql/connector/client.py,sha256=uVOMnRe1AxiSh6BnK4-SuSYrRID_jnm7SgMMN6s5jp4,13076
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=MqL4iX9xK0Vys59L0VQpxf4VY39UUyYeL7I3rnFYSdY,26792
8
- google/cloud/sql/connector/enums.py,sha256=dVBS9eEWNrHtzze0AJlmtbdUogG3EocNWOQcI_8qPd0,2937
9
- google/cloud/sql/connector/exceptions.py,sha256=BtuE5bkMLxNPYZo2SrFlRBHp3ueWKZ0otnPbfnvr2TM,2125
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=TnS_SNbgY19oNEawOx-qJyW-f_k42-SnR3VTBOSr5FI,1717
14
- google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- google/cloud/sql/connector/pymysql.py,sha256=PrTQ1lB5EWAcN4BPX_5hb-WerncS4X76bBk4y0es0ks,1948
16
- google/cloud/sql/connector/pytds.py,sha256=KZxmJQQG9XfmsmUoEGX7RpMirWdoreJ2-_FuHO6Td-s,2280
17
- google/cloud/sql/connector/rate_limiter.py,sha256=zqZ9pVXHan4LWoGT8Bq6U3HpZcrdzKhlXVYwsX3ftA0,2928
18
- google/cloud/sql/connector/refresh_utils.py,sha256=jaYLHrKlJtqeZBM8gOrZtI-6mbh6-1PTi8o0aFGH5Yo,5151
19
- google/cloud/sql/connector/resolver.py,sha256=pKcdPCr0iwFwKf2cv0F3HtCyOWNas1JsrnRMOudnEr8,3714
20
- google/cloud/sql/connector/utils.py,sha256=AA_oBBxRuULb7-hHHK8HQJU5fttzBpzdIZwMNZ5kNCM,3473
21
- google/cloud/sql/connector/version.py,sha256=cYVlxi8BSHlb8lkhQjFjx99XUVh4oz0nUaEXXDcL7Yo,598
22
- cloud_sql_python_connector-1.20.4.dist-info/METADATA,sha256=-C2J-fOMiODP2lhLrt6fW0AM9WL8_sL5Ui91WXwoa80,31181
23
- cloud_sql_python_connector-1.20.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
24
- cloud_sql_python_connector-1.20.4.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
25
- cloud_sql_python_connector-1.20.4.dist-info/RECORD,,