Skip to content

Commit d94bf40

Browse files
authored
Added psycopg3 support (#1739)
* Added support and test cases for psycopg3 updated trans_id to rely on transaction status instead of connection status as it works with pyscorg3 Use correct json property based on psycopg version Skip test_tuple_param_conversion if using psycopg3 Move psycopg3 skip logic into decorator.
1 parent 4613c2b commit d94bf40

File tree

6 files changed

+103
-36
lines changed

6 files changed

+103
-36
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ jobs:
7979
matrix:
8080
python-version: ['3.8', '3.9', '3.10', '3.11']
8181
database: [postgresql, postgis]
82+
# Add psycopg3 to our matrix for 3.10 and 3.11
83+
include:
84+
- python-version: '3.10'
85+
database: psycopg3
86+
- python-version: '3.11'
87+
database: psycopg3
8288

8389
services:
8490
postgres:

debug_toolbar/panels/sql/panel.py

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,59 @@
1717

1818
def get_isolation_level_display(vendor, level):
1919
if vendor == "postgresql":
20-
import psycopg2.extensions
21-
22-
choices = {
23-
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT: _("Autocommit"),
24-
psycopg2.extensions.ISOLATION_LEVEL_READ_UNCOMMITTED: _("Read uncommitted"),
25-
psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED: _("Read committed"),
26-
psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ: _("Repeatable read"),
27-
psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE: _("Serializable"),
28-
}
20+
try:
21+
import psycopg
22+
23+
choices = {
24+
# AUTOCOMMIT level does not exists in psycopg3
25+
psycopg.IsolationLevel.READ_UNCOMMITTED: _("Read uncommitted"),
26+
psycopg.IsolationLevel.READ_COMMITTED: _("Read committed"),
27+
psycopg.IsolationLevel.REPEATABLE_READ: _("Repeatable read"),
28+
psycopg.IsolationLevel.SERIALIZABLE: _("Serializable"),
29+
}
30+
except ImportError:
31+
import psycopg2.extensions
32+
33+
choices = {
34+
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT: _("Autocommit"),
35+
psycopg2.extensions.ISOLATION_LEVEL_READ_UNCOMMITTED: _(
36+
"Read uncommitted"
37+
),
38+
psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED: _("Read committed"),
39+
psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ: _(
40+
"Repeatable read"
41+
),
42+
psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE: _("Serializable"),
43+
}
44+
2945
else:
3046
raise ValueError(vendor)
3147
return choices.get(level)
3248

3349

3450
def get_transaction_status_display(vendor, level):
3551
if vendor == "postgresql":
36-
import psycopg2.extensions
37-
38-
choices = {
39-
psycopg2.extensions.TRANSACTION_STATUS_IDLE: _("Idle"),
40-
psycopg2.extensions.TRANSACTION_STATUS_ACTIVE: _("Active"),
41-
psycopg2.extensions.TRANSACTION_STATUS_INTRANS: _("In transaction"),
42-
psycopg2.extensions.TRANSACTION_STATUS_INERROR: _("In error"),
43-
psycopg2.extensions.TRANSACTION_STATUS_UNKNOWN: _("Unknown"),
44-
}
52+
try:
53+
import psycopg
54+
55+
choices = {
56+
psycopg.pq.TransactionStatus.IDLE: _("Idle"),
57+
psycopg.pq.TransactionStatus.ACTIVE: _("Active"),
58+
psycopg.pq.TransactionStatus.INTRANS: _("In transaction"),
59+
psycopg.pq.TransactionStatus.INERROR: _("In error"),
60+
psycopg.pq.TransactionStatus.UNKNOWN: _("Unknown"),
61+
}
62+
except ImportError:
63+
import psycopg2.extensions
64+
65+
choices = {
66+
psycopg2.extensions.TRANSACTION_STATUS_IDLE: _("Idle"),
67+
psycopg2.extensions.TRANSACTION_STATUS_ACTIVE: _("Active"),
68+
psycopg2.extensions.TRANSACTION_STATUS_INTRANS: _("In transaction"),
69+
psycopg2.extensions.TRANSACTION_STATUS_INERROR: _("In error"),
70+
psycopg2.extensions.TRANSACTION_STATUS_UNKNOWN: _("Unknown"),
71+
}
72+
4573
else:
4674
raise ValueError(vendor)
4775
return choices.get(level)

debug_toolbar/panels/sql/tracking.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@
99
from debug_toolbar.utils import get_stack_trace, get_template_info
1010

1111
try:
12-
from psycopg2._json import Json as PostgresJson
13-
from psycopg2.extensions import STATUS_IN_TRANSACTION
12+
import psycopg
13+
14+
PostgresJson = psycopg.types.json.Jsonb
15+
STATUS_IN_TRANSACTION = psycopg.pq.TransactionStatus.INTRANS
1416
except ImportError:
15-
PostgresJson = None
16-
STATUS_IN_TRANSACTION = None
17+
try:
18+
from psycopg2._json import Json as PostgresJson
19+
from psycopg2.extensions import STATUS_IN_TRANSACTION
20+
except ImportError:
21+
PostgresJson = None
22+
STATUS_IN_TRANSACTION = None
1723

1824
# Prevents SQL queries from being sent to the DB. It's used
1925
# by the TemplatePanel to prevent the toolbar from issuing
@@ -126,7 +132,13 @@ def _quote_params(self, params):
126132

127133
def _decode(self, param):
128134
if PostgresJson and isinstance(param, PostgresJson):
129-
return param.dumps(param.adapted)
135+
# psycopg3
136+
if hasattr(param, "obj"):
137+
return param.dumps(param.obj)
138+
# psycopg2
139+
if hasattr(param, "adapted"):
140+
return param.dumps(param.adapted)
141+
130142
# If a sequence type, decode each element separately
131143
if isinstance(param, (tuple, list)):
132144
return [self._decode(element) for element in param]
@@ -149,7 +161,7 @@ def _record(self, method, sql, params):
149161
if vendor == "postgresql":
150162
# The underlying DB connection (as opposed to Django's wrapper)
151163
conn = self.db.connection
152-
initial_conn_status = conn.status
164+
initial_conn_status = conn.info.transaction_status
153165

154166
start_time = time()
155167
try:
@@ -166,7 +178,10 @@ def _record(self, method, sql, params):
166178

167179
# Sql might be an object (such as psycopg Composed).
168180
# For logging purposes, make sure it's str.
169-
sql = str(sql)
181+
if vendor == "postgresql" and not isinstance(sql, str):
182+
sql = sql.as_string(conn)
183+
else:
184+
sql = str(sql)
170185

171186
params = {
172187
"vendor": vendor,
@@ -205,7 +220,7 @@ def _record(self, method, sql, params):
205220
# case where Django can start a transaction before the first query
206221
# executes, so in that case logger.current_transaction_id() will
207222
# generate a new transaction ID since one does not already exist.
208-
final_conn_status = conn.status
223+
final_conn_status = conn.info.transaction_status
209224
if final_conn_status == STATUS_IN_TRANSACTION:
210225
if initial_conn_status == STATUS_IN_TRANSACTION:
211226
trans_id = self.logger.current_transaction_id(alias)
@@ -217,7 +232,7 @@ def _record(self, method, sql, params):
217232
params.update(
218233
{
219234
"trans_id": trans_id,
220-
"trans_status": conn.get_transaction_status(),
235+
"trans_status": conn.info.transaction_status,
221236
"iso_level": iso_level,
222237
}
223238
)

docs/changes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Pending
1414
memory leaks and sometimes very verbose and hard to silence output in some
1515
environments (but not others). The maintainers judged that time and effort is
1616
better invested elsewhere.
17+
* Added support for psycopg3.
1718

1819
3.8.1 (2022-12-03)
1920
------------------

tests/panels/test_sql.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
import debug_toolbar.panels.sql.tracking as sql_tracking
1717
from debug_toolbar import settings as dt_settings
1818

19+
try:
20+
import psycopg
21+
except ImportError:
22+
psycopg = None
23+
1924
from ..base import BaseMultiDBTestCase, BaseTestCase
2025
from ..models import PostgresJSON
2126

@@ -222,9 +227,13 @@ def test_json_param_conversion(self):
222227
)
223228

224229
@unittest.skipUnless(
225-
connection.vendor == "postgresql", "Test valid only on PostgreSQL"
230+
connection.vendor == "postgresql" and psycopg is None,
231+
"Test valid only on PostgreSQL with psycopg2",
226232
)
227233
def test_tuple_param_conversion(self):
234+
"""
235+
Regression test for tuple parameter conversion.
236+
"""
228237
self.assertEqual(len(self.panel._queries), 0)
229238

230239
list(
@@ -377,12 +386,15 @@ def test_erroneous_query(self):
377386
@unittest.skipUnless(
378387
connection.vendor == "postgresql", "Test valid only on PostgreSQL"
379388
)
380-
def test_execute_with_psycopg2_composed_sql(self):
389+
def test_execute_with_psycopg_composed_sql(self):
381390
"""
382-
Test command executed using a Composed psycopg2 object is logged.
383-
Ref: http://initd.org/psycopg/docs/sql.html
391+
Test command executed using a Composed psycopg object is logged.
392+
Ref: https://www.psycopg.org/psycopg3/docs/api/sql.html
384393
"""
385-
from psycopg2 import sql
394+
try:
395+
from psycopg import sql
396+
except ImportError:
397+
from psycopg2 import sql
386398

387399
self.assertEqual(len(self.panel._queries), 0)
388400

tox.ini

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ isolated_build = true
33
envlist =
44
docs
55
packaging
6-
py{38,39,310}-dj{32,41,42}-{sqlite,postgresql,postgis,mysql}
6+
py{38,39,310}-dj{32}-{sqlite,postgresql,postgis,mysql}
77
py{310}-dj{40}-{sqlite}
8-
py{310,311}-dj{41,42,main}-{sqlite,postgresql,postgis,mysql}
8+
py{310,311}-dj{41}-{sqlite,postgresql,postgis,mysql}
9+
py{310,311}-dj{42,main}-{sqlite,postgresql,postgis,mysql}
10+
py{310,311}-dj{42,main}-psycopg3
911

1012
[testenv]
1113
deps =
@@ -14,6 +16,7 @@ deps =
1416
dj41: django~=4.1.3
1517
dj42: django>=4.2a1,<5
1618
postgresql: psycopg2-binary
19+
psycopg3: psycopg[binary]
1720
postgis: psycopg2-binary
1821
mysql: mysqlclient
1922
djmain: https://github.com/django/django/archive/main.tar.gz
@@ -47,12 +50,13 @@ allowlist_externals = make
4750
pip_pre = True
4851
commands = python -b -W always -m coverage run -m django test -v2 {posargs:tests}
4952

50-
[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-postgresql]
53+
[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-{postgresql,psycopg3}]
5154
setenv =
5255
{[testenv]setenv}
5356
DB_BACKEND = postgresql
5457
DB_PORT = {env:DB_PORT:5432}
5558

59+
5660
[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-postgis]
5761
setenv =
5862
{[testenv]setenv}
@@ -97,5 +101,6 @@ python =
97101
DB_BACKEND =
98102
mysql: mysql
99103
postgresql: postgresql
100-
postgis: postgresql
104+
psycopg3: psycopg3
105+
postgis: postgis
101106
sqlite3: sqlite

0 commit comments

Comments
 (0)