Skip to content

Commit ba58f8f

Browse files
authored
feat: add support for psycopg (GoogleCloudPlatform#1452)
1 parent 5aa23dc commit ba58f8f

10 files changed

Lines changed: 1321 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ The Cloud SQL Python Connector is a package to be used alongside a database driv
4242
Currently supported drivers are:
4343
- [`pymysql`](https://github.com/PyMySQL/PyMySQL) (MySQL)
4444
- [`pg8000`](https://github.com/tlocke/pg8000) (PostgreSQL)
45+
- [`psycopg`](https://github.com/psycopg/psycopg) (PostgreSQL)
4546
- [`asyncpg`](https://github.com/MagicStack/asyncpg) (PostgreSQL)
4647
- [`pytds`](https://github.com/denisenkom/pytds) (SQL Server)
4748

@@ -56,12 +57,16 @@ based on your database dialect.
5657
pip install "cloud-sql-python-connector[pymysql]"
5758
```
5859
### Postgres
59-
There are two different database drivers that are supported for the Postgres dialect:
60+
There are three different database drivers that are supported for the Postgres dialect:
6061

6162
#### pg8000
6263
```
6364
pip install "cloud-sql-python-connector[pg8000]"
6465
```
66+
#### psycopg
67+
```
68+
pip install "cloud-sql-python-connector[psycopg]"
69+
```
6570
#### asyncpg
6671
```
6772
pip install "cloud-sql-python-connector[asyncpg]"
@@ -137,6 +142,18 @@ pool = sqlalchemy.create_engine(
137142
db="my-db-name"
138143
),
139144
)
145+
146+
# Or with Postgres (psycopg):
147+
pool = sqlalchemy.create_engine(
148+
"postgresql+psycopg://",
149+
creator=lambda: connector.connect(
150+
"project:region:instance",
151+
"psycopg",
152+
user="my-user",
153+
password="my-password",
154+
db="my-db-name"
155+
),
156+
)
140157
```
141158

142159
The returned connection pool engine can then be used to query and modify the database.

‎google/cloud/sql/connector/connector.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from google.cloud.sql.connector import asyncpg
3333
from google.cloud.sql.connector import pg8000
34+
from google.cloud.sql.connector import psycopg
3435
from google.cloud.sql.connector import pymysql
3536
from google.cloud.sql.connector import pytds
3637
from google.cloud.sql.connector.client import CloudSQLClient
@@ -362,6 +363,7 @@ async def connect_async(
362363
"pg8000": pg8000.connect,
363364
"asyncpg": asyncpg.connect,
364365
"pytds": pytds.connect,
366+
"psycopg": psycopg.connect,
365367
}
366368

367369
# only accept supported database drivers

‎google/cloud/sql/connector/enums.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class DriverMapping(Enum):
6262

6363
ASYNCPG = "POSTGRES"
6464
PG8000 = "POSTGRES" # noqa: PIE796
65+
PSYCOPG = "POSTGRES" # noqa: PIE796
6566
PYMYSQL = "MYSQL"
6667
PYTDS = "SQLSERVER"
6768

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
tmpdir = tempfile.mkdtemp()
146+
socket_path = os.path.join(tmpdir, ".s.PGSQL.5432")
147+
logger.debug("psycopg: created Unix socket at %s", socket_path)
148+
149+
local_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
150+
local_sock.bind(socket_path)
151+
local_sock.listen(1)
152+
153+
def _accept_and_proxy() -> None:
154+
"""Accept one connection then proxy bytes until the connection closes."""
155+
unix_conn = None
156+
try:
157+
unix_conn, _ = local_sock.accept()
158+
local_sock.close()
159+
logger.debug("psycopg proxy: accepted connection, starting proxy")
160+
_proxy(unix_conn, remote_sock)
161+
except Exception as e: # noqa: BLE001
162+
logger.debug("psycopg proxy: error in accept/proxy thread: %s", e)
163+
# Ensure cleanup on any exception
164+
if unix_conn:
165+
try:
166+
unix_conn.shutdown(socket.SHUT_RDWR)
167+
except OSError:
168+
pass
169+
try:
170+
unix_conn.close()
171+
except OSError:
172+
pass
173+
try:
174+
remote_sock.shutdown(socket.SHUT_RDWR)
175+
except OSError:
176+
pass
177+
try:
178+
remote_sock.close()
179+
except OSError:
180+
pass
181+
182+
threading.Thread(target=_accept_and_proxy, daemon=True).start()
183+
184+
user = kwargs.pop("user")
185+
db = kwargs.pop("db")
186+
passwd = kwargs.pop("password", None)
187+
# SSL is already handled by the underlying SSLSocket; disable it on the
188+
# Unix socket so psycopg does not attempt a second TLS handshake.
189+
kwargs.pop("sslmode", None)
190+
timeout = kwargs.pop("timeout", None)
191+
if timeout is not None:
192+
kwargs["connect_timeout"] = int(timeout)
193+
194+
logger.debug("psycopg: connecting as user=%s dbname=%s", user, db)
195+
try:
196+
conn = psycopg.connect(
197+
user=user,
198+
dbname=db,
199+
password=passwd,
200+
host=tmpdir,
201+
port=5432,
202+
sslmode="disable",
203+
**kwargs,
204+
)
205+
logger.debug("psycopg: connection established")
206+
return conn
207+
except Exception as e:
208+
logger.debug("psycopg: connection failed: %s", e)
209+
# psycopg never connected (or failed mid-handshake); close the server
210+
# socket so the proxy thread unblocks and exits cleanly.
211+
try:
212+
local_sock.close()
213+
except OSError:
214+
pass
215+
try:
216+
remote_sock.close()
217+
except OSError:
218+
pass
219+
raise
220+
finally:
221+
# The socket file and its parent directory are only needed during the
222+
# initial connect() call; remove them now regardless of outcome.
223+
try:
224+
os.remove(socket_path)
225+
except OSError:
226+
pass
227+
try:
228+
os.rmdir(tmpdir)
229+
except OSError:
230+
pass

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ pymysql = ["PyMySQL>=1.1.0"]
6363
pg8000 = ["pg8000>=1.31.1"]
6464
pytds = ["python-tds>=1.15.0"]
6565
asyncpg = ["asyncpg>=0.30.0"]
66+
psycopg = ["psycopg>=3.1.0"]
6667

6768
[tool.setuptools.dynamic]
6869
version = { attr = "google.cloud.sql.connector.version.__version__" }

‎requirements-test.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ asyncpg==0.31.0
1111
python-tds==1.17.1
1212
aioresponses==0.7.9
1313
pytest-aiohttp==1.1.1
14+
psycopg==3.3.4
15+
psycopg-binary==3.3.4
1416

0 commit comments

Comments
 (0)