diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index d8099f25b..90e2ba812 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -17,15 +17,31 @@ 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) @@ -33,15 +49,27 @@ def get_isolation_level_display(vendor, 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) diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index 9fda4eba5..565d9244b 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -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 @@ -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] @@ -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: @@ -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, @@ -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) @@ -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, } ) diff --git a/docs/changes.rst b/docs/changes.rst index a38df519d..db42bba65 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,8 @@ 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 pyscorg3. +* Added pyscorg3 to the CI. 3.8.1 (2022-12-03) ------------------ diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index ee9a134b9..b54c96931 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -225,6 +225,17 @@ def test_json_param_conversion(self): connection.vendor == "postgresql", "Test valid only on PostgreSQL" ) def test_tuple_param_conversion(self): + """ + For psycopg3 You cannot use IN %s with a tuple + https://www.psycopg.org/psycopg3/docs/basic/from_pg2.html#you-cannot-use-in-s-with-a-tuple + """ + try: + import psycopg # noqa: F401 + + self.skipTest("test not supported for psycopg3") + except ImportError: + pass + self.assertEqual(len(self.panel._queries), 0) list( @@ -377,12 +388,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) diff --git a/tox.ini b/tox.ini index 58fc31907..e94487b3d 100644 --- a/tox.ini +++ b/tox.ini @@ -3,9 +3,10 @@ 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,legacy_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,legacy_postgresql,postgis,mysql} + py{310,311}-dj{42,main}-{sqlite,legacy_postgresql,postgresql,postgis,mysql} [testenv] deps = @@ -13,7 +14,8 @@ deps = dj40: django~=4.0.0 dj41: django~=4.1.3 dj42: django>=4.2a1,<5 - postgresql: psycopg2-binary + legacy_postgresql: psycopg2-binary + postgresql: psycopg[binary] postgis: psycopg2-binary mysql: mysqlclient djmain: https://github.com/django/django/archive/main.tar.gz @@ -47,7 +49,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}-legacy_postgresql] +setenv = + {[testenv]setenv} + DB_BACKEND = postgresql + DB_PORT = {env:DB_PORT:5432} + +[testenv:py{310,311}-dj{42,main}-postgresql] setenv = {[testenv]setenv} DB_BACKEND = postgresql @@ -97,5 +105,6 @@ python = DB_BACKEND = mysql: mysql postgresql: postgresql + legacy_postgresql: postgresql postgis: postgresql sqlite3: sqlite