From ec00fb9e50cfbc8474faf56ea4acca43427688ad Mon Sep 17 00:00:00 2001 From: Najeb Abdullahi Date: Thu, 14 May 2026 15:55:12 -0500 Subject: [PATCH 1/5] feat: implement PEP 249 cursor compliance cursor.description, fetchone/fetchall/fetchmany now return proper tuples, rowcount accurate, AsyncCursor parity, 24 unit tests added --- mapepire_python/asyncio/cursor.py | 33 ++-- mapepire_python/core/cursor.py | 50 ++++- mapepire_python/core/utils.py | 19 +- tests/test_cursor_pep249.py | 303 ++++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 tests/test_cursor_pep249.py diff --git a/mapepire_python/asyncio/cursor.py b/mapepire_python/asyncio/cursor.py index 1a47d54..d249bd1 100644 --- a/mapepire_python/asyncio/cursor.py +++ b/mapepire_python/asyncio/cursor.py @@ -58,13 +58,13 @@ def description(self) -> Optional[Sequence[ColumnDescription]]: return None return [ ( - col.name, - _DB_TYPE_MAP.get(col.type.upper(), str), - col.display_size, - None, - col.precision, - col.scale, - col.nullable, + col.name, + _DB_TYPE_MAP.get(col.type.upper() if col.type else "", str), + col.display_size, + None, + col.precision, + col.scale, + col.nullable, ) for col in self._metadata.columns ] @@ -102,15 +102,24 @@ async def executemany( await self.execute(operation, params) return self + def _row_to_tuple(self, row) -> tuple: + if isinstance(row, dict): + if self._metadata and self._metadata.columns: + return tuple(row.get(col.name, None) for col in self._metadata.columns) + return tuple(row.values()) + if isinstance(row, (list, tuple)): + return tuple(row) + return row + async def fetchone(self) -> Optional[ResultRow]: if self._buffer: - return self._buffer.pop(0) + return self._row_to_tuple(self._buffer.pop(0)) if self._is_done or self._query is None: return None result = await self._query.fetch_more(rows_to_fetch=1) self._is_done = result.is_done self._buffer.extend(result.data or []) - return self._buffer.pop(0) if self._buffer else None + return self._row_to_tuple(self._buffer.pop(0)) if self._buffer else None async def fetchmany(self, size: Optional[int] = None) -> ResultSet: if size is None: @@ -118,7 +127,7 @@ async def fetchmany(self, size: Optional[int] = None) -> ResultSet: rows = [] while len(rows) < size: if self._buffer: - rows.append(self._buffer.pop(0)) + rows.append(self._row_to_tuple(self._buffer.pop(0))) elif self._is_done or self._query is None: break else: @@ -128,12 +137,12 @@ async def fetchmany(self, size: Optional[int] = None) -> ResultSet: return rows async def fetchall(self) -> ResultSet: - rows = list(self._buffer) + rows = [self._row_to_tuple(r) for r in self._buffer] self._buffer.clear() while not self._is_done and self._query is not None: result = await self._query.fetch_more(rows_to_fetch=100) self._is_done = result.is_done - rows.extend(result.data or []) + rows.extend(self._row_to_tuple(r) for r in (result.data or [])) return rows async def close(self) -> None: diff --git a/mapepire_python/core/cursor.py b/mapepire_python/core/cursor.py index 8158df6..e772475 100644 --- a/mapepire_python/core/cursor.py +++ b/mapepire_python/core/cursor.py @@ -29,6 +29,19 @@ __all__ = ["Cursor"] logger = logging.getLogger(__name__) + +_DB_TYPE_MAP = { + "VARCHAR": str, "CHAR": str, "CLOB": str, "NVARCHAR": str, "NCHAR": str, + "NCLOB": str, "GRAPHIC": str, "VARGRAPHIC": str, "DBCLOB": str, "XML": str, + "INTEGER": int, "INT": int, "SMALLINT": int, "BIGINT": int, + "DECIMAL": float, "NUMERIC": float, "FLOAT": float, "DOUBLE": float, "REAL": float, + "DECFLOAT": float, + "BOOLEAN": bool, + "DATE": str, "TIME": str, "TIMESTAMP": str, + "BINARY": bytes, "VARBINARY": bytes, "BLOB": bytes, +} + + class Cursor(pep249.CursorConnectionMixin, pep249.IterableCursorMixin, pep249.TransactionalCursor): max_rows = 2147483647 @@ -39,6 +52,7 @@ def __init__(self, connection: "Connection", job: SQLJob) -> None: self.query: Optional[Query] = None self.query_q: deque[Query] = deque(maxlen=20) self._result_set: Optional[QueryResultSet] = None + self._metadata = None self.__closed = False self.__has_results = False @@ -104,10 +118,15 @@ def execute( prepare_result = query.prepare_sql_execute() + qs = QueryResultSet(prepare_result) + self._metadata = qs.metadata if qs.metadata.columns else None + if prepare_result.has_results: self.query = query self.__set_has_results(True) self.query_q.append(query) + else: + self.__set_has_results(False) if prepare_result.update_count is not None: self.rowcount = prepare_result.update_count @@ -134,7 +153,29 @@ def callproc(self, procname: ProcName, parameters: Optional[ProcArgs] = None) -> def description( self, ) -> Optional[Sequence[ColumnDescription]]: - pass + if not self._metadata or not self._metadata.columns: + return None + return [ + ( + col.name, + _DB_TYPE_MAP.get(col.type.upper() if col.type else "", str), + col.display_size, + None, + col.precision, + col.scale, + col.nullable, + ) + for col in self._metadata.columns + ] + + def _row_to_tuple(self, row: Any) -> tuple: + if isinstance(row, dict): + if self._metadata and self._metadata.columns: + return tuple(row.get(col.name, None) for col in self._metadata.columns) + return tuple(row.values()) + if isinstance(row, (list, tuple)): + return tuple(row) + return row @raise_if_closed @convert_runtime_errors @@ -144,7 +185,8 @@ def fetchone(self) -> Optional[ResultRow]: res = self.query.fetch_more(rows_to_fetch=1) if res: self._result_set = QueryResultSet(res) - return self._result_set.data[0] if self._result_set.data else None + if self._result_set.data: + return self._row_to_tuple(self._result_set.data[0]) return None @raise_if_closed @@ -156,7 +198,7 @@ def fetchall(self) -> ResultSet: res = self.query.fetch_more(rows_to_fetch=self.max_rows) if res: self._result_set = QueryResultSet(res) - return self._result_set.data + return [self._row_to_tuple(row) for row in self._result_set.data] return [] @raise_if_closed @@ -169,7 +211,7 @@ def fetchmany(self, size: Optional[int] = None) -> ResultSet: res = self.query.fetch_more(rows_to_fetch=size) if res: self._result_set = QueryResultSet(res) - return self._result_set.data + return [self._row_to_tuple(row) for row in self._result_set.data] return [] def executescript(self, script: SQLQuery) -> "Cursor": diff --git a/mapepire_python/core/utils.py b/mapepire_python/core/utils.py index a71c7a8..35408fe 100644 --- a/mapepire_python/core/utils.py +++ b/mapepire_python/core/utils.py @@ -2,7 +2,7 @@ import dataclasses from functools import wraps -from typing import Callable, List, Optional +from typing import Any, Callable, Dict, List, Optional, cast from .exceptions import CONNECTION_CLOSED, ProgrammingError, ReturnType @@ -49,11 +49,15 @@ def wrapped(*args, **kwargs): class ColumnMetaData: - def __init__(self, name: str, type: str, display_size: int, label: str): + def __init__(self, name: str, type: str, display_size: int, label: str, + precision=None, scale=None, nullable=None, **kwargs): self.name = name self.type = type self.display_size = display_size self.label = label + self.precision = precision + self.scale = scale + self.nullable = nullable class MetaData: @@ -64,17 +68,18 @@ def __init__(self, column_count: int, job: str, columns: List[ColumnMetaData]): class QueryResultSet: - def __init__(self, result): + def __init__(self, result) -> None: if dataclasses.is_dataclass(result) and not isinstance(result, type): result = dataclasses.asdict(result) + result = cast(Dict[str, Any], result) self.id = result.get("id", None) self.has_results = result.get("has_results", None) self.update_count = result.get("update_count", None) - metadata = result.get("metadata", {}) + metadata = cast(Dict[str, Any], result.get("metadata") or {}) self.metadata = MetaData( - column_count=metadata.get("column_count", None), - job=metadata.get("job", None), - columns=[ColumnMetaData(**col) for col in metadata.get("columns", [])], + column_count=metadata.get("column_count", 0), + job=metadata.get("job", ""), + columns=[ColumnMetaData(**col) for col in metadata.get("columns", [])], # type: ignore[arg-type] ) self.data = result.get("data", []) self.is_done = result.get("is_done", None) diff --git a/tests/test_cursor_pep249.py b/tests/test_cursor_pep249.py new file mode 100644 index 0000000..ce6a4fa --- /dev/null +++ b/tests/test_cursor_pep249.py @@ -0,0 +1,303 @@ +"""Unit tests for PEP 249 cursor compliance (no live server required). + +Covers: + - cursor.description: 7-tuple structure, field mapping, pre/post execute state + - fetchone() / fetchall() / fetchmany(): return tuples not dicts, correct values + - fetchone() returns None when exhausted + - rowcount: -1 for SELECT, actual count for DML +""" +import json + +import pytest + +from mapepire_python.core.cursor import Cursor + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_COLUMNS = [ + {"name": "EMPNO", "label": "EMPNO", "type": "CHAR", "display_size": 6, + "precision": None, "scale": None, "nullable": False}, + {"name": "FIRSTNME", "label": "FIRSTNME", "type": "VARCHAR", "display_size": 12, + "precision": None, "scale": None, "nullable": True}, + {"name": "SALARY", "label": "SALARY", "type": "DECIMAL", "display_size": 10, + "precision": 9, "scale": 2, "nullable": True}, +] + +_METADATA = {"column_count": 3, "job": "TEST/QUSER/JOB001", "columns": _COLUMNS} + +_ROWS = [ + {"EMPNO": "000010", "FIRSTNME": "JOHN", "SALARY": 52750.00}, + {"EMPNO": "000020", "FIRSTNME": "ALICE", "SALARY": 41250.00}, + {"EMPNO": "000030", "FIRSTNME": "BOB", "SALARY": 38500.00}, +] + + +class _FakeConn: + """Minimal connection stub — just enough for Cursor._closed checks.""" + _closed = False + + +def _make_cursor(mock_sql_job): + """Return a (Cursor, MockSocket) wired to the mock job. + + The _FakeConn instance is attached to the cursor to prevent it from being + GC'd — Cursor holds only a weakref, and a dead weakref makes _closed=True. + """ + job, socket = mock_sql_job + conn = _FakeConn() + cursor = Cursor(conn, job) + cursor._test_conn_ref = conn # keep strong reference alive + return cursor, socket + + +def _queue_select(socket, rows=None, update_count=-1, metadata=None, is_done_on_fetch=True): + """Queue the two socket responses needed for execute() + one fetch call.""" + # 1. prepare_sql_execute response: metadata present, no data rows yet + socket.add_response(json.dumps({ + "id": "q1", + "success": True, + "sql_rc": 0, + "sql_state": "", + "is_done": False, + "has_results": True, + "update_count": update_count, + "data": [], + "metadata": metadata if metadata is not None else _METADATA, + "error": None, + "execution_time": None, + })) + # 2. sqlmore response: actual data rows + socket.add_response(json.dumps({ + "id": "q1", + "success": True, + "sql_rc": 0, + "sql_state": "", + "is_done": is_done_on_fetch, + "data": rows if rows is not None else _ROWS, + "error": None, + "execution_time": None, + })) + + +def _queue_dml(socket, update_count=3): + """Queue the single socket response for a DML execute().""" + socket.add_response(json.dumps({ + "id": "q2", + "success": True, + "sql_rc": 0, + "sql_state": "", + "is_done": True, + "has_results": False, + "update_count": update_count, + "data": [], + "metadata": None, + "error": None, + "execution_time": None, + })) + + +# --------------------------------------------------------------------------- +# cursor.description +# --------------------------------------------------------------------------- + +class TestDescription: + def test_none_before_execute(self, mock_sql_job): + cursor, _ = _make_cursor(mock_sql_job) + assert cursor.description is None + + def test_none_after_dml(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_dml(socket) + cursor.execute("DELETE FROM SAMPLE.EMPLOYEE WHERE EMPNO = '000010'") + assert cursor.description is None + + def test_returns_sequence_after_select(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + desc = cursor.description + assert desc is not None + assert len(desc) == 3 + + def test_each_entry_is_seven_tuple(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + for col in cursor.description: + assert len(col) == 7 + + def test_name_mapping(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + desc = cursor.description + assert desc[0][0] == "EMPNO" + assert desc[1][0] == "FIRSTNME" + assert desc[2][0] == "SALARY" + + def test_type_code_mapping(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + desc = cursor.description + assert desc[0][1] == str # CHAR + assert desc[1][1] == str # VARCHAR + assert desc[2][1] == float # DECIMAL + + def test_display_size_mapping(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + desc = cursor.description + assert desc[0][2] == 6 # EMPNO + assert desc[1][2] == 12 # FIRSTNME + assert desc[2][2] == 10 # SALARY + + def test_internal_size_is_always_none(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + for col in cursor.description: + assert col[3] is None + + def test_precision_scale_nullable_mapping(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT EMPNO, FIRSTNME, SALARY FROM SAMPLE.EMPLOYEE") + desc = cursor.description + # SALARY: precision=9, scale=2, nullable=True + assert desc[2][4] == 9 + assert desc[2][5] == 2 + assert desc[2][6] is True + # EMPNO: nullable=False + assert desc[0][6] is False + + def test_unknown_sql_type_falls_back_to_str(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket, metadata={ + "column_count": 1, + "job": "TEST/QUSER/JOB001", + "columns": [{"name": "X", "label": "X", "type": "EXOTIC_DBTYPE", + "display_size": 10, "precision": None, "scale": None, "nullable": None}], + }) + cursor.execute("SELECT X FROM T") + assert cursor.description[0][1] == str + + +# --------------------------------------------------------------------------- +# fetchone() +# --------------------------------------------------------------------------- + +class TestFetchone: + def test_returns_tuple(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + row = cursor.fetchone() + assert isinstance(row, tuple) + + def test_values_ordered_by_metadata_columns(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + # column order: EMPNO, FIRSTNME, SALARY + assert cursor.fetchone() == ("000010", "JOHN", 52750.00) + + def test_returns_none_before_execute(self, mock_sql_job): + cursor, _ = _make_cursor(mock_sql_job) + assert cursor.fetchone() is None + + def test_returns_none_when_query_is_done(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket, rows=[_ROWS[0]], is_done_on_fetch=True) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + cursor.fetchone() # exhausts the single row; query enters RUN_DONE + assert cursor.fetchone() is None + + def test_terse_list_row_becomes_tuple(self, mock_sql_job): + """Terse-mode rows arrive as lists; fetchone must still return tuples.""" + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket, rows=[["000010", "JOHN", 52750.00]]) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + row = cursor.fetchone() + assert isinstance(row, tuple) + assert row == ("000010", "JOHN", 52750.00) + + +# --------------------------------------------------------------------------- +# fetchall() +# --------------------------------------------------------------------------- + +class TestFetchall: + def test_returns_list_of_tuples(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + rows = cursor.fetchall() + assert isinstance(rows, list) + assert len(rows) == 3 + assert all(isinstance(r, tuple) for r in rows) + + def test_row_values(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + rows = cursor.fetchall() + assert rows[0] == ("000010", "JOHN", 52750.00) + assert rows[1] == ("000020", "ALICE", 41250.00) + assert rows[2] == ("000030", "BOB", 38500.00) + + def test_returns_empty_list_before_execute(self, mock_sql_job): + cursor, _ = _make_cursor(mock_sql_job) + assert cursor.fetchall() == [] + + +# --------------------------------------------------------------------------- +# fetchmany() +# --------------------------------------------------------------------------- + +class TestFetchmany: + def test_returns_list_of_tuples(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + rows = cursor.fetchmany(2) + assert isinstance(rows, list) + assert all(isinstance(r, tuple) for r in rows) + + def test_size_respected_by_server_response(self, mock_sql_job): + """fetchmany(2) passes size=2 to the server; if server honours it we get 2 rows.""" + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket, rows=_ROWS[:2]) # server returns 2 + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + rows = cursor.fetchmany(2) + assert len(rows) == 2 + + def test_returns_empty_list_before_execute(self, mock_sql_job): + cursor, _ = _make_cursor(mock_sql_job) + assert cursor.fetchmany(5) == [] + + +# --------------------------------------------------------------------------- +# rowcount +# --------------------------------------------------------------------------- + +class TestRowcount: + def test_minus_one_before_execute(self, mock_sql_job): + cursor, _ = _make_cursor(mock_sql_job) + assert cursor.rowcount == -1 + + def test_minus_one_for_select(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_select(socket, update_count=-1) + cursor.execute("SELECT * FROM SAMPLE.EMPLOYEE") + assert cursor.rowcount == -1 + + def test_reflects_dml_update_count(self, mock_sql_job): + cursor, socket = _make_cursor(mock_sql_job) + _queue_dml(socket, update_count=5) + cursor.execute("DELETE FROM SAMPLE.EMPLOYEE WHERE BONUS > 1000") + assert cursor.rowcount == 5 From 1e141708c6030d7b10f2d915013f6a3061f3e51c Mon Sep 17 00:00:00 2001 From: Najeb Abdullahi Date: Mon, 18 May 2026 12:49:46 -0500 Subject: [PATCH 2/5] cleaned up test imports --- tests/async_pool_test.py | 4 +--- tests/cl_test.py | 3 +-- tests/pep249_async_test.py | 2 +- tests/pep249_test.py | 9 +++++---- tests/pooling_test.py | 4 +--- tests/query_manager_test.py | 2 +- tests/simple_test.py | 3 +-- tests/sql_test.py | 3 +-- tests/test_cursor_pep249.py | 2 +- tests/test_setup.py | 2 +- tests/tls_test.py | 2 +- 11 files changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/async_pool_test.py b/tests/async_pool_test.py index 2968393..c6ec955 100644 --- a/tests/async_pool_test.py +++ b/tests/async_pool_test.py @@ -3,9 +3,7 @@ import pytest -from mapepire_python.client.sql_job import SQLJob -from mapepire_python.data_types import QueryOptions -from mapepire_python.pool.pool_job import PoolJob +from mapepire_python import PoolJob, QueryOptions, SQLJob from mapepire_python.query_manager import QueryManager from .test_setup import * diff --git a/tests/cl_test.py b/tests/cl_test.py index 7514cee..f9017a2 100644 --- a/tests/cl_test.py +++ b/tests/cl_test.py @@ -1,7 +1,6 @@ import os -from mapepire_python.client.sql_job import SQLJob -from mapepire_python.data_types import QueryOptions +from mapepire_python import QueryOptions, SQLJob from .test_setup import * diff --git a/tests/pep249_async_test.py b/tests/pep249_async_test.py index 190622e..4407718 100644 --- a/tests/pep249_async_test.py +++ b/tests/pep249_async_test.py @@ -7,7 +7,7 @@ import mapepire_python from mapepire_python.asyncio import connect from mapepire_python.client.async_sql_job import AsyncSQLJob -from mapepire_python.pool.pool_client import Pool, PoolOptions +from mapepire_python import Pool, PoolOptions from .test_setup import * diff --git a/tests/pep249_test.py b/tests/pep249_test.py index 87975bc..bc0b67c 100644 --- a/tests/pep249_test.py +++ b/tests/pep249_test.py @@ -1,7 +1,7 @@ import os from mapepire_python import connect -from mapepire_python.data_types import QueryOptions +from mapepire_python import QueryOptions from .test_setup import * @@ -216,14 +216,15 @@ def test_pep249_has_results(): cur = conn.cursor() cur.execute("select * from sample.department") assert cur.has_results == True + rows = cur.fetchall() + assert len(rows) > 0 cur.execute( "create or replace variable sample.coolval varchar(8) ccsid 1208 default 'abcd'" ) - assert cur.has_results == True - - assert cur.fetchall() is not None + # DDL has no result set — has_results resets to False + assert cur.has_results == False def test_pep249_has_results_no_select(): diff --git a/tests/pooling_test.py b/tests/pooling_test.py index 971b196..f705802 100644 --- a/tests/pooling_test.py +++ b/tests/pooling_test.py @@ -3,9 +3,7 @@ import pytest -from mapepire_python.client.sql_job import SQLJob -from mapepire_python.data_types import JobStatus -from mapepire_python.pool.pool_client import Pool, PoolOptions +from mapepire_python import JobStatus, Pool, PoolOptions, SQLJob from .test_setup import * diff --git a/tests/query_manager_test.py b/tests/query_manager_test.py index 5d9d56c..6b5fbbd 100644 --- a/tests/query_manager_test.py +++ b/tests/query_manager_test.py @@ -1,6 +1,6 @@ import os -from mapepire_python.client.sql_job import SQLJob +from mapepire_python import SQLJob from mapepire_python.query_manager import QueryManager from .test_setup import * diff --git a/tests/simple_test.py b/tests/simple_test.py index cfe7e42..a42e0a8 100644 --- a/tests/simple_test.py +++ b/tests/simple_test.py @@ -1,7 +1,6 @@ import re -from mapepire_python.client.sql_job import SQLJob -from mapepire_python.data_types import DaemonServer +from mapepire_python import DaemonServer, SQLJob from .test_setup import * diff --git a/tests/sql_test.py b/tests/sql_test.py index a5fb675..1a817b8 100644 --- a/tests/sql_test.py +++ b/tests/sql_test.py @@ -2,8 +2,7 @@ import pytest -from mapepire_python.client.sql_job import SQLJob -from mapepire_python.data_types import QueryOptions +from mapepire_python import QueryOptions, SQLJob from .test_setup import * diff --git a/tests/test_cursor_pep249.py b/tests/test_cursor_pep249.py index ce6a4fa..d93a4f0 100644 --- a/tests/test_cursor_pep249.py +++ b/tests/test_cursor_pep249.py @@ -10,7 +10,7 @@ import pytest -from mapepire_python.core.cursor import Cursor +from mapepire_python import Cursor # --------------------------------------------------------------------------- diff --git a/tests/test_setup.py b/tests/test_setup.py index 8259e08..b47ca50 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -3,7 +3,7 @@ import platform from mapepire_python.authentication.kerberosTokenProvider import KerberosTokenProvider -from mapepire_python.data_types import DaemonServer +from mapepire_python import DaemonServer __all__ = [ "creds", diff --git a/tests/tls_test.py b/tests/tls_test.py index 90c7405..6cf0309 100644 --- a/tests/tls_test.py +++ b/tests/tls_test.py @@ -3,7 +3,7 @@ import pytest -from mapepire_python.client.sql_job import SQLJob +from mapepire_python import SQLJob from mapepire_python.ssl import get_certificate from .test_setup import * From 619602b017b36671b2bbd3166189c8566be94896 Mon Sep 17 00:00:00 2001 From: Najeb Abdullahi Date: Wed, 20 May 2026 13:12:08 -0500 Subject: [PATCH 3/5] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 977bbff..1f4d5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - adopt official Mapepire protocol types using dataclasses (#96) - Native async support for PEP 249 interface (replace to_thread wrapper) #95 - Improve public API surface and top-level exports #94 +- Fix PEP 249 compliance: cursor.description and result types #91 ## [v0.2.0](https://github.com/Mapepire-IBMi/mapepire-python/releases/tag/v0.2.0) - 2024-11-26 - replace `websocket-client` with `websockets` From 314f1fa4ec8258a6db1aadc6916cd6622ec255f2 Mon Sep 17 00:00:00 2001 From: Najeb Abdullahi Date: Wed, 20 May 2026 16:07:13 -0500 Subject: [PATCH 4/5] fix sorting --- tests/pep249_async_test.py | 2 +- tests/pep249_test.py | 3 +-- tests/test_cursor_pep249.py | 1 - tests/test_setup.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/pep249_async_test.py b/tests/pep249_async_test.py index 4407718..c8d24ee 100644 --- a/tests/pep249_async_test.py +++ b/tests/pep249_async_test.py @@ -5,9 +5,9 @@ import pytest import mapepire_python +from mapepire_python import Pool, PoolOptions from mapepire_python.asyncio import connect from mapepire_python.client.async_sql_job import AsyncSQLJob -from mapepire_python import Pool, PoolOptions from .test_setup import * diff --git a/tests/pep249_test.py b/tests/pep249_test.py index bc0b67c..a19a341 100644 --- a/tests/pep249_test.py +++ b/tests/pep249_test.py @@ -1,7 +1,6 @@ import os -from mapepire_python import connect -from mapepire_python import QueryOptions +from mapepire_python import QueryOptions, connect from .test_setup import * diff --git a/tests/test_cursor_pep249.py b/tests/test_cursor_pep249.py index d93a4f0..7dc3502 100644 --- a/tests/test_cursor_pep249.py +++ b/tests/test_cursor_pep249.py @@ -12,7 +12,6 @@ from mapepire_python import Cursor - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/test_setup.py b/tests/test_setup.py index b47ca50..c9d4ce0 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -2,8 +2,8 @@ import os import platform -from mapepire_python.authentication.kerberosTokenProvider import KerberosTokenProvider from mapepire_python import DaemonServer +from mapepire_python.authentication.kerberosTokenProvider import KerberosTokenProvider __all__ = [ "creds", From d4fa79c579a034873efc99055780ad456a6709f7 Mon Sep 17 00:00:00 2001 From: Najeb Abdullahi Date: Wed, 20 May 2026 16:28:53 -0500 Subject: [PATCH 5/5] test --- tests/conftest.py | 163 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..48563f3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,163 @@ +"""Shared fixtures for mapepire-python tests. + +Unit test fixtures live here so they are available to tests/unit/ without +requiring a live IBM i server connection. Integration tests continue to use +tests/test_setup.py for credentials. +""" +import json +from typing import List, Optional + +import pytest + +from mapepire_python import DaemonServer, JobStatus, SQLJob + + +class MockSocket: + + def __init__(self, responses: Optional[List[str]] = None) -> None: + self._response_queue: List[str] = list(responses or []) + self.sent: List[str] = [] + self.closed: bool = False + + def send(self, data: str) -> None: + self.sent.append(data) + + def recv(self) -> str: + if not self._response_queue: + raise RuntimeError("MockSocket: no more responses queued") + return self._response_queue.pop(0) + + def close(self) -> None: + self.closed = True + + def add_response(self, response: str) -> None: + self._response_queue.append(response) + + def __len__(self) -> int: + return len(self._response_queue) + + + +@pytest.fixture +def mock_creds() -> DaemonServer: + """DaemonServer with fake credentials – no network calls are made.""" + return DaemonServer( + host="test.example.com", + user="testuser", + password="testpass", + port=8076, + ignoreUnauthorized=True, + ) + + +@pytest.fixture +def mock_socket() -> MockSocket: + """Bare MockSocket with no pre-loaded responses.""" + return MockSocket() + + +@pytest.fixture +def mock_sql_job(): + """Pre-connected SQLJob backed by a MockSocket. + + Returns a ``(job, socket)`` tuple. The job is in ``JobStatus.Ready`` + state with its ``_socket`` already injected so tests can enqueue + responses via ``socket.add_response(...)``. + """ + job = SQLJob() + socket = MockSocket() + job._socket = socket # type: ignore[assignment] + job._status = JobStatus.Ready + job.id = "TEST/QUSER/JOB001" + return job, socket + + + + +@pytest.fixture +def make_connect_result(): + """Return a factory for connection-result JSON strings.""" + def _make( + job_id: str = "TEST/QUSER/JOB001", + success: bool = True, + error: Optional[str] = None, + ) -> str: + return json.dumps({ + "id": "connect1", + "success": success, + "sql_rc": 0, + "sql_state": "", + "job": job_id, + "error": error, + "execution_time": None, + }) + return _make + + +@pytest.fixture +def make_query_result(): + """Return a factory for QueryResult JSON strings.""" + def _make( + data: Optional[list] = None, + is_done: bool = True, + has_results: bool = False, + success: bool = True, + id: str = "query1", + error: Optional[str] = None, + update_count: int = 0, + sql_rc: int = 0, + sql_state: str = "", + metadata: Optional[dict] = None, + ) -> str: + return json.dumps({ + "id": id, + "success": success, + "sql_rc": sql_rc, + "sql_state": sql_state, + "is_done": is_done, + "has_results": has_results, + "update_count": update_count, + "data": data if data is not None else [], + "metadata": metadata, + "error": error, + "execution_time": None, + }) + return _make + + +@pytest.fixture +def make_sql_more_response(): + """Return a factory for SqlMoreResponse JSON strings.""" + def _make( + data: Optional[list] = None, + is_done: bool = True, + success: bool = True, + id: str = "sqlmore1", + error: Optional[str] = None, + ) -> str: + return json.dumps({ + "id": id, + "success": success, + "sql_rc": 0, + "sql_state": "", + "is_done": is_done, + "data": data if data is not None else [], + "error": error, + "execution_time": None, + }) + return _make + + +@pytest.fixture +def make_close_response(): + """Return a factory for SqlCloseResponse JSON strings.""" + def _make(id: str = "sqlclose1", success: bool = True) -> str: + return json.dumps({ + "id": id, + "success": success, + "sql_rc": 0, + "sql_state": "", + "error": None, + "execution_time": None, + }) + return _make \ No newline at end of file