Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
- feat: add LOB support — CLOB/NCLOB/DBCLOB and BLOB column values are now wrapped in `ClobValue`/`BlobValue` file-like objects (with a PEP 249-style `read()`), exported from the package root
- fix: wrap LOB columns in terse-mode (list) rows, not just dict rows — `cursor.execute(sql, isTerseResults=True)` was previously returning raw unwrapped values for CLOB/BLOB columns
- fix: declare `gssapi` (Linux/macOS) and `pywin32` (Windows) as runtime dependencies instead of dev-only extras, so Kerberos auth works out of the box without a separate `pip install gssapi` — Windows SSPI support still requires `pywin32`'s `sspi` module even though native Windows headers are present, and macOS ships its own GSSAPI/Kerberos framework and headers so no `brew install krb5` is needed there; Linux still needs `krb5-config`/dev headers (e.g. `libkrb5-dev`) available at install time to build `gssapi`
- ci: install only `twine`/`packaging` in the release job instead of the full `.[dev]` extra, so it no longer compiles `gssapi` (avoids the `krb5-config: not found` build failure on the release runner)
- ci: bump `actions/checkout` v6→v7, `actions/setup-python` v6→v7, `actions/cache` v5→v6
Expand Down
4 changes: 4 additions & 0 deletions mapepire_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from .core.exceptions import CONNECTION_CLOSED, convert_runtime_errors
from .data_types import DaemonServer, JobStatus, QueryOptions, QueryResult
from .lob import BlobValue, ClobValue, LOBValue
from .pool.pool_client import Pool, PoolOptions
from .pool.pool_job import PoolJob
from .version import VERSION as __version__
Expand All @@ -28,6 +29,9 @@
"apilevel",
"threadsafety",
"paramstyle",
"ClobValue",
"BlobValue",
"LOBValue",
"DatabaseError",
"DataError",
"Error",
Expand Down
27 changes: 26 additions & 1 deletion mapepire_python/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from functools import wraps
from typing import Any, Callable, Dict, List, Optional, cast

from ..lob import BLOB_TYPES, CLOB_TYPES, BlobValue, ClobValue
from .exceptions import CONNECTION_CLOSED, ProgrammingError, ReturnType

__all__ = ["raise_if_closed", "DB_TYPE_MAP", "row_to_tuple"]
Expand All @@ -20,12 +21,36 @@
}


def _wrap_lob(value: Any, sql_type: Optional[str]) -> Any:
"""Wrap *value* in a LOB object when *sql_type* is a LOB type.

Returns the value unchanged for all non-LOB types or when sql_type
is unknown.
"""
if sql_type is None or value is None:
return value
upper = sql_type.upper()
if upper in CLOB_TYPES:
return ClobValue(value)
if upper in BLOB_TYPES:
return BlobValue(value)
return value


def row_to_tuple(row: Any, metadata) -> tuple:
if isinstance(row, dict):
if metadata and metadata.columns:
return tuple(row.get(col.name, None) for col in metadata.columns)
return tuple(
_wrap_lob(row.get(col.name, None), col.type)
for col in metadata.columns
)
return tuple(row.values())
if isinstance(row, (list, tuple)):
if metadata and metadata.columns:
return tuple(
_wrap_lob(value, col.type)
for value, col in zip(row, metadata.columns)
)
return tuple(row)
return row

Expand Down
99 changes: 99 additions & 0 deletions mapepire_python/lob.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""LOB (Large Object) value wrappers for CLOB, NCLOB, DBCLOB, and BLOB columns.

The Mapepire server inlines LOB data directly in row payloads, so these
classes wrap the already-loaded value behind a file-like read() interface
as recommended by DB-API 2.0 (PEP 249).
"""

from typing import Optional, Union

__all__ = ["ClobValue", "BlobValue", "LOBValue", "LOB_TYPES", "BLOB_TYPES", "CLOB_TYPES"]

# SQL type names that map to character LOBs (str content)
CLOB_TYPES = frozenset({"CLOB", "NCLOB", "DBCLOB"})

# SQL type names that map to binary LOBs (bytes content)
BLOB_TYPES = frozenset({"BLOB"})

# Union of all LOB type names — used for fast membership checks
LOB_TYPES = CLOB_TYPES | BLOB_TYPES


class ClobValue:
"""Wraps an inlined CLOB/NCLOB/DBCLOB string value.

Provides a file-like read() interface so callers can consume the
content incrementally or all at once, matching DB-API 2.0 expectations.
"""

def __init__(self, value: Optional[str]) -> None:
self._value: str = value if value is not None else ""
self._pos: int = 0

@property
def value(self) -> str:
"""The full underlying string, regardless of read position."""
return self._value

def read(self, size: int = -1) -> str:
"""Read up to *size* characters, advancing the position.

If *size* is -1 (the default), return all remaining content.
Returns an empty string when the end has been reached.
"""
if size == -1:
chunk = self._value[self._pos:]
self._pos = len(self._value)
else:
chunk = self._value[self._pos: self._pos + size]
self._pos += len(chunk)
return chunk

def __repr__(self) -> str:
preview = self._value[:40] + "..." if len(self._value) > 40 else self._value
return f"ClobValue({preview!r})"


class BlobValue:
"""Wraps an inlined BLOB bytes value.

Provides a file-like read() interface so callers can consume the
content incrementally or all at once, matching DB-API 2.0 expectations.
"""

def __init__(self, value: Optional[Union[str, bytes]]) -> None:
# The server may deliver BLOB data as a hex string or raw bytes.
if isinstance(value, str):
self._value = bytes.fromhex(value)
elif isinstance(value, bytes):
self._value = value
else:
self._value = b""
self._pos: int = 0

@property
def value(self) -> bytes:
"""The full underlying bytes, regardless of read position."""
return self._value

def read(self, size: int = -1) -> bytes:
"""Read up to *size* bytes, advancing the position.

If *size* is -1 (the default), return all remaining content.
Returns an empty bytes object when the end has been reached.
"""
if size == -1:
chunk = self._value[self._pos:]
self._pos = len(self._value)
else:
chunk = self._value[self._pos: self._pos + size]
self._pos += len(chunk)
return chunk

def __repr__(self) -> str:
preview = self._value[:20]
return f"BlobValue({preview!r}{'...' if len(self._value) > 20 else ''})"


# Convenience union type alias for type annotations
LOBValue = Union[ClobValue, BlobValue]
80 changes: 79 additions & 1 deletion tests/unit/test_cursor_pep249.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import pytest

from mapepire_python import Cursor
from mapepire_python import BlobValue, ClobValue, Cursor

# ---------------------------------------------------------------------------
# Helpers
Expand All @@ -33,6 +33,17 @@
{"EMPNO": "000030", "FIRSTNME": "BOB", "SALARY": 38500.00},
]

_LOB_COLUMNS = [
{"name": "ID", "label": "ID", "type": "INTEGER", "display_size": 10,
"precision": None, "scale": None, "nullable": False},
{"name": "NOTES", "label": "NOTES", "type": "CLOB", "display_size": 100,
"precision": None, "scale": None, "nullable": True},
{"name": "PHOTO", "label": "PHOTO", "type": "BLOB", "display_size": 100,
"precision": None, "scale": None, "nullable": True},
]

_LOB_METADATA = {"column_count": 3, "job": "TEST/QUSER/JOB001", "columns": _LOB_COLUMNS}


class _FakeConn:
"""Minimal connection stub — just enough for Cursor._closed checks."""
Expand Down Expand Up @@ -300,3 +311,70 @@ def test_reflects_dml_update_count(self, mock_sql_job):
_queue_dml(socket, update_count=5)
cursor.execute("DELETE FROM SAMPLE.EMPLOYEE WHERE BONUS > 1000")
assert cursor.rowcount == 5


# ---------------------------------------------------------------------------
# LOB (CLOB/BLOB) wrapping end-to-end through Cursor
# ---------------------------------------------------------------------------

class TestLobWrapping:
def test_dict_row_wraps_clob_and_blob(self, mock_sql_job):
cursor, socket = _make_cursor(mock_sql_job)
_queue_select(
socket,
rows=[{"ID": 1, "NOTES": "hello world", "PHOTO": "68656c6c6f"}],
metadata=_LOB_METADATA,
)
cursor.execute("SELECT ID, NOTES, PHOTO FROM SAMPLE.DOCS")
row = cursor.fetchone()
assert row[0] == 1
assert isinstance(row[1], ClobValue)
assert row[1].value == "hello world"
assert isinstance(row[2], BlobValue)
assert row[2].value == b"hello"

def test_terse_list_row_wraps_clob_and_blob(self, mock_sql_job):
"""Regression test: LOB wrapping must also apply when the server
returns terse (list) rows, not just dict rows.
"""
cursor, socket = _make_cursor(mock_sql_job)
_queue_select(
socket,
rows=[[1, "hello world", "68656c6c6f"]],
metadata=_LOB_METADATA,
)
cursor.execute("SELECT ID, NOTES, PHOTO FROM SAMPLE.DOCS", isTerseResults=True)
row = cursor.fetchone()
assert row[0] == 1
assert isinstance(row[1], ClobValue)
assert row[1].value == "hello world"
assert isinstance(row[2], BlobValue)
assert row[2].value == b"hello"

def test_fetchall_wraps_lob_columns_in_every_row(self, mock_sql_job):
cursor, socket = _make_cursor(mock_sql_job)
_queue_select(
socket,
rows=[
{"ID": 1, "NOTES": "first", "PHOTO": "68656c6c6f"},
{"ID": 2, "NOTES": "second", "PHOTO": "776f726c64"},
],
metadata=_LOB_METADATA,
)
cursor.execute("SELECT ID, NOTES, PHOTO FROM SAMPLE.DOCS")
rows = cursor.fetchall()
assert all(isinstance(r[1], ClobValue) for r in rows)
assert all(isinstance(r[2], BlobValue) for r in rows)
assert rows[1][2].value == b"world"

def test_null_lob_values_stay_none(self, mock_sql_job):
cursor, socket = _make_cursor(mock_sql_job)
_queue_select(
socket,
rows=[{"ID": 1, "NOTES": None, "PHOTO": None}],
metadata=_LOB_METADATA,
)
cursor.execute("SELECT ID, NOTES, PHOTO FROM SAMPLE.DOCS")
row = cursor.fetchone()
assert row[1] is None
assert row[2] is None
Loading
Loading