@@ -150,6 +150,8 @@ class MockCursor:
150150 """Mock cursor for when we can't create a real cursor from base class.
151151
152152 This is a fallback when the connection is completely mocked.
153+ Provides all attributes that psycopg2 cursors have to ensure compatibility
154+ with frameworks like Django that access cursor properties.
153155 """
154156
155157 def __init__ (self , connection ):
@@ -159,6 +161,14 @@ def __init__(self, connection):
159161 self .arraysize = 1
160162 self ._mock_rows = []
161163 self ._mock_index = 0
164+ # psycopg2 cursor attributes that Django/Flask may access
165+ self .query = None # Last executed query string
166+ self .statusmessage = None # Status message from last command
167+ self .lastrowid = None # OID of last inserted row (if applicable)
168+ self .closed = False
169+ self .name = None # Server-side cursor name (None for client-side)
170+ self .scrollable = None
171+ self .withhold = False
162172 logger .debug ("[MOCK_CURSOR] Created fallback mock cursor" )
163173
164174 def execute (self , query : Any , vars : Any = None ) -> None :
@@ -210,6 +220,58 @@ def __init__(self, connection: Any, instrumentation: Psycopg2Instrumentation, sd
210220
211221 def cursor (self , name : str | None = None , cursor_factory : Any = None , * args : Any , ** kwargs : Any ) -> Any :
212222 """Intercept cursor creation to wrap user-provided cursor_factory."""
223+ # In REPLAY mode, use MockCursor to have full control over cursor state
224+ # This is necessary because psycopg2's cursor.description is a read-only
225+ # C-level property that cannot be set after the cursor is created
226+ if self ._sdk .mode == TuskDriftMode .REPLAY :
227+ cursor = MockCursor (self )
228+ instrumentation = self ._instrumentation
229+ sdk = self ._sdk
230+
231+ # Detect if user wants dict-style cursors (RealDictCursor, DictCursor)
232+ is_dict_cursor = False
233+ effective_cursor_factory = cursor_factory if cursor_factory is not None else self ._default_cursor_factory
234+ if effective_cursor_factory is not None :
235+ try :
236+ import psycopg2 .extras
237+
238+ if effective_cursor_factory in (
239+ psycopg2 .extras .RealDictCursor ,
240+ psycopg2 .extras .DictCursor ,
241+ ) or (
242+ isinstance (effective_cursor_factory , type )
243+ and issubclass (
244+ effective_cursor_factory , (psycopg2 .extras .RealDictCursor , psycopg2 .extras .DictCursor )
245+ )
246+ ):
247+ is_dict_cursor = True
248+ except (ImportError , AttributeError ):
249+ pass
250+
251+ # Store cursor type info on the cursor for _mock_execute_with_data
252+ cursor ._is_dict_cursor = is_dict_cursor # type: ignore[attr-defined]
253+
254+ def mock_execute (query , vars = None ):
255+ def noop_execute (q , v ):
256+ return None
257+
258+ return instrumentation ._traced_execute (cursor , noop_execute , sdk , query , vars )
259+
260+ def mock_executemany (query , vars_list ):
261+ def noop_executemany (q , vl ):
262+ return None
263+
264+ return instrumentation ._traced_executemany (cursor , noop_executemany , sdk , query , vars_list )
265+
266+ cursor .execute = mock_execute # type: ignore[method-assign]
267+ cursor .executemany = mock_executemany # type: ignore[method-assign]
268+
269+ logger .debug (
270+ f"[INSTRUMENTED_CONNECTION] Created MockCursor for REPLAY mode (is_dict_cursor={ is_dict_cursor } )"
271+ )
272+ return cursor
273+
274+ # In RECORD mode, use real cursor with instrumentation
213275 # Use cursor_factory from cursor() call, or fall back to connection's default
214276 base_factory = cursor_factory if cursor_factory is not None else self ._default_cursor_factory
215277 # Create instrumented cursor factory (wrapping the base factory)
@@ -493,6 +555,9 @@ def _replay_execute(self, cursor: Any, sdk: TuskDrift, query_str: str, params: A
493555 logger .warning ("[PSYCOPG2_REPLAY] No mock found for pre-app-start query, returning empty result" )
494556 empty_mock = {"rowcount" : 0 , "rows" : [], "description" : None }
495557 self ._mock_execute_with_data (cursor , empty_mock )
558+ # Set cursor.query to the executed query (psycopg2 cursor attribute)
559+ if hasattr (cursor , "query" ):
560+ cursor .query = query_str .encode ("utf-8" ) if isinstance (query_str , str ) else query_str
496561 span_info .span .end ()
497562 return None
498563
@@ -503,6 +568,9 @@ def _replay_execute(self, cursor: Any, sdk: TuskDrift, query_str: str, params: A
503568 )
504569
505570 self ._mock_execute_with_data (cursor , mock_result )
571+ # Set cursor.query to the executed query (psycopg2 cursor attribute)
572+ if hasattr (cursor , "query" ):
573+ cursor .query = query_str .encode ("utf-8" ) if isinstance (query_str , str ) else query_str
506574 span_info .span .end ()
507575 return None
508576
@@ -621,6 +689,9 @@ def _replay_executemany(self, cursor: Any, sdk: TuskDrift, query_str: str, param
621689 )
622690 empty_mock = {"rowcount" : 0 , "rows" : [], "description" : None }
623691 self ._mock_execute_with_data (cursor , empty_mock )
692+ # Set cursor.query to the executed query (psycopg2 cursor attribute)
693+ if hasattr (cursor , "query" ):
694+ cursor .query = query_str .encode ("utf-8" ) if isinstance (query_str , str ) else query_str
624695 span_info .span .end ()
625696 return None
626697
@@ -631,6 +702,9 @@ def _replay_executemany(self, cursor: Any, sdk: TuskDrift, query_str: str, param
631702 )
632703
633704 self ._mock_execute_with_data (cursor , mock_result )
705+ # Set cursor.query to the executed query (psycopg2 cursor attribute)
706+ if hasattr (cursor , "query" ):
707+ cursor .query = query_str .encode ("utf-8" ) if isinstance (query_str , str ) else query_str
634708 span_info .span .end ()
635709 return None
636710
@@ -777,15 +851,17 @@ def _mock_execute_with_data(self, cursor: Any, mock_data: dict[str, Any]) -> Non
777851 # Deserialize datetime strings back to datetime objects for consistent Flask/Django serialization
778852 mock_rows = [deserialize_db_value (row ) for row in mock_rows ]
779853
780- # Check if this is a dict-cursor (like RealDictCursor) by checking if cursor class
781- # inherits from a dict-returning cursor type
782- is_dict_cursor = False
783- try :
784- import psycopg2 .extras
854+ # Check if this is a dict-cursor (like RealDictCursor)
855+ # First check if cursor has _is_dict_cursor attribute (set by InstrumentedConnection.cursor())
856+ # Then fall back to isinstance check for real dict cursors
857+ is_dict_cursor = getattr (cursor , "_is_dict_cursor" , False )
858+ if not is_dict_cursor :
859+ try :
860+ import psycopg2 .extras
785861
786- is_dict_cursor = isinstance (cursor , (psycopg2 .extras .RealDictCursor , psycopg2 .extras .DictCursor ))
787- except (ImportError , AttributeError ):
788- pass
862+ is_dict_cursor = isinstance (cursor , (psycopg2 .extras .RealDictCursor , psycopg2 .extras .DictCursor ))
863+ except (ImportError , AttributeError ):
864+ pass
789865
790866 # If it's a dict cursor and we have description, convert rows to dicts
791867 if is_dict_cursor and description_data :
0 commit comments