Skip to content

Added psycopg3 support #1739

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ jobs:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
database: [postgresql, postgis]
# Add psycopg3 to our matrix for 3.10 and 3.11
include:
- python-version: '3.10'
database: psycopg3
- python-version: '3.11'
database: psycopg3

services:
postgres:
Expand Down
64 changes: 46 additions & 18 deletions debug_toolbar/panels/sql/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,59 @@

def get_isolation_level_display(vendor, level):
if vendor == "postgresql":
import psycopg2.extensions

choices = {
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT: _("Autocommit"),
psycopg2.extensions.ISOLATION_LEVEL_READ_UNCOMMITTED: _("Read uncommitted"),
psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED: _("Read committed"),
psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ: _("Repeatable read"),
psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE: _("Serializable"),
}
try:
import psycopg

choices = {
# AUTOCOMMIT level does not exists in psycopg3
psycopg.IsolationLevel.READ_UNCOMMITTED: _("Read uncommitted"),
psycopg.IsolationLevel.READ_COMMITTED: _("Read committed"),
psycopg.IsolationLevel.REPEATABLE_READ: _("Repeatable read"),
psycopg.IsolationLevel.SERIALIZABLE: _("Serializable"),
}
except ImportError:
import psycopg2.extensions

choices = {
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT: _("Autocommit"),
psycopg2.extensions.ISOLATION_LEVEL_READ_UNCOMMITTED: _(
"Read uncommitted"
),
psycopg2.extensions.ISOLATION_LEVEL_READ_COMMITTED: _("Read committed"),
psycopg2.extensions.ISOLATION_LEVEL_REPEATABLE_READ: _(
"Repeatable read"
),
psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE: _("Serializable"),
}

else:
raise ValueError(vendor)
return choices.get(level)


def get_transaction_status_display(vendor, level):
if vendor == "postgresql":
import psycopg2.extensions

choices = {
psycopg2.extensions.TRANSACTION_STATUS_IDLE: _("Idle"),
psycopg2.extensions.TRANSACTION_STATUS_ACTIVE: _("Active"),
psycopg2.extensions.TRANSACTION_STATUS_INTRANS: _("In transaction"),
psycopg2.extensions.TRANSACTION_STATUS_INERROR: _("In error"),
psycopg2.extensions.TRANSACTION_STATUS_UNKNOWN: _("Unknown"),
}
try:
import psycopg

choices = {
psycopg.pq.TransactionStatus.IDLE: _("Idle"),
psycopg.pq.TransactionStatus.ACTIVE: _("Active"),
psycopg.pq.TransactionStatus.INTRANS: _("In transaction"),
psycopg.pq.TransactionStatus.INERROR: _("In error"),
psycopg.pq.TransactionStatus.UNKNOWN: _("Unknown"),
}
except ImportError:
import psycopg2.extensions

choices = {
psycopg2.extensions.TRANSACTION_STATUS_IDLE: _("Idle"),
psycopg2.extensions.TRANSACTION_STATUS_ACTIVE: _("Active"),
psycopg2.extensions.TRANSACTION_STATUS_INTRANS: _("In transaction"),
psycopg2.extensions.TRANSACTION_STATUS_INERROR: _("In error"),
psycopg2.extensions.TRANSACTION_STATUS_UNKNOWN: _("Unknown"),
}

else:
raise ValueError(vendor)
return choices.get(level)
Expand Down
33 changes: 24 additions & 9 deletions debug_toolbar/panels/sql/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
from debug_toolbar.utils import get_stack_trace, get_template_info

try:
from psycopg2._json import Json as PostgresJson
from psycopg2.extensions import STATUS_IN_TRANSACTION
import psycopg

PostgresJson = psycopg.types.json.Jsonb
STATUS_IN_TRANSACTION = psycopg.pq.TransactionStatus.INTRANS
except ImportError:
PostgresJson = None
STATUS_IN_TRANSACTION = None
try:
from psycopg2._json import Json as PostgresJson
from psycopg2.extensions import STATUS_IN_TRANSACTION
except ImportError:
PostgresJson = None
STATUS_IN_TRANSACTION = None

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

def _decode(self, param):
if PostgresJson and isinstance(param, PostgresJson):
return param.dumps(param.adapted)
# psycopg3
if hasattr(param, "obj"):
return param.dumps(param.obj)
# psycopg2
if hasattr(param, "adapted"):
return param.dumps(param.adapted)

# If a sequence type, decode each element separately
if isinstance(param, (tuple, list)):
return [self._decode(element) for element in param]
Expand All @@ -149,7 +161,7 @@ def _record(self, method, sql, params):
if vendor == "postgresql":
# The underlying DB connection (as opposed to Django's wrapper)
conn = self.db.connection
initial_conn_status = conn.status
initial_conn_status = conn.info.transaction_status

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

# Sql might be an object (such as psycopg Composed).
# For logging purposes, make sure it's str.
sql = str(sql)
if vendor == "postgresql" and not isinstance(sql, str):
sql = sql.as_string(conn)
else:
sql = str(sql)

params = {
"vendor": vendor,
Expand Down Expand Up @@ -205,7 +220,7 @@ def _record(self, method, sql, params):
# case where Django can start a transaction before the first query
# executes, so in that case logger.current_transaction_id() will
# generate a new transaction ID since one does not already exist.
final_conn_status = conn.status
final_conn_status = conn.info.transaction_status
if final_conn_status == STATUS_IN_TRANSACTION:
if initial_conn_status == STATUS_IN_TRANSACTION:
trans_id = self.logger.current_transaction_id(alias)
Expand All @@ -217,7 +232,7 @@ def _record(self, method, sql, params):
params.update(
{
"trans_id": trans_id,
"trans_status": conn.get_transaction_status(),
"trans_status": conn.info.transaction_status,
"iso_level": iso_level,
}
)
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Pending
memory leaks and sometimes very verbose and hard to silence output in some
environments (but not others). The maintainers judged that time and effort is
better invested elsewhere.
* Added support for psycopg3.

3.8.1 (2022-12-03)
------------------
Expand Down
22 changes: 17 additions & 5 deletions tests/panels/test_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
import debug_toolbar.panels.sql.tracking as sql_tracking
from debug_toolbar import settings as dt_settings

try:
import psycopg
except ImportError:
psycopg = None

from ..base import BaseMultiDBTestCase, BaseTestCase
from ..models import PostgresJSON

Expand Down Expand Up @@ -222,9 +227,13 @@ def test_json_param_conversion(self):
)

@unittest.skipUnless(
connection.vendor == "postgresql", "Test valid only on PostgreSQL"
connection.vendor == "postgresql" and psycopg is None,
"Test valid only on PostgreSQL with psycopg2",
)
def test_tuple_param_conversion(self):
"""
Regression test for tuple parameter conversion.
"""
self.assertEqual(len(self.panel._queries), 0)

list(
Expand Down Expand Up @@ -377,12 +386,15 @@ def test_erroneous_query(self):
@unittest.skipUnless(
connection.vendor == "postgresql", "Test valid only on PostgreSQL"
)
def test_execute_with_psycopg2_composed_sql(self):
def test_execute_with_psycopg_composed_sql(self):
"""
Test command executed using a Composed psycopg2 object is logged.
Ref: http://initd.org/psycopg/docs/sql.html
Test command executed using a Composed psycopg object is logged.
Ref: https://www.psycopg.org/psycopg3/docs/api/sql.html
"""
from psycopg2 import sql
try:
from psycopg import sql
except ImportError:
from psycopg2 import sql

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

Expand Down
13 changes: 9 additions & 4 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ isolated_build = true
envlist =
docs
packaging
py{38,39,310}-dj{32,41,42}-{sqlite,postgresql,postgis,mysql}
py{38,39,310}-dj{32}-{sqlite,postgresql,postgis,mysql}
py{310}-dj{40}-{sqlite}
py{310,311}-dj{41,42,main}-{sqlite,postgresql,postgis,mysql}
py{310,311}-dj{41}-{sqlite,postgresql,postgis,mysql}
py{310,311}-dj{42,main}-{sqlite,postgresql,postgis,mysql}
py{310,311}-dj{42,main}-psycopg3

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

[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-postgresql]
[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-{postgresql,psycopg3}]
setenv =
{[testenv]setenv}
DB_BACKEND = postgresql
DB_PORT = {env:DB_PORT:5432}


[testenv:py{38,39,310,311}-dj{32,40,41,42,main}-postgis]
setenv =
{[testenv]setenv}
Expand Down Expand Up @@ -97,5 +101,6 @@ python =
DB_BACKEND =
mysql: mysql
postgresql: postgresql
postgis: postgresql
psycopg3: psycopg3
postgis: postgis
sqlite3: sqlite