diff --git a/.gitignore b/.gitignore index dcde6680..251f1b1a 100644 --- a/.gitignore +++ b/.gitignore @@ -164,5 +164,8 @@ cython_debug/ # Visual Studio Code .vscode +# Claude AI assistant +.claude/ + # ruff stuff .ruff_cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b85c22a..6c52530e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # ChangeLog +## 0.4 (Unreleased) + +### 0.4.0 + +- **MAJOR**: Full PEP 249 (Python Database API v2.0) compliance implementation: + - Add module globals: `apilevel="2.0"`, `threadsafety=1`, `paramstyle="pyformat"` + - Implement module-level `connect()` factory function with full parameter support + - Export all PEP 249 exceptions (`Warning`, `Error`, `DatabaseError`, etc.) at module level + - Create comprehensive type system in `asynch.dbapi_types`: + - Type objects: `STRING`, `NUMBER`, `DATETIME`, `BINARY`, `ROWID` + - Type constructors: `Date`, `Time`, `Timestamp`, `DateFromTicks`, etc. + - ClickHouse to PEP 249 type mapping with `Nullable`/`LowCardinality` support + - Enhance cursor interface: + - Add public `cursor.arraysize` property with getter/setter + - Add `cursor.setoutputsize()` method (renamed from `setoutputsizes`, kept alias) + - Add `cursor.lastrowid` property (returns `None` for ClickHouse) + - Add `cursor.callproc()` method (raises `NotSupportedError` per spec) + - Add `cursor.nextset()` async method (returns `None` for single result sets) + - Fix `cursor.description` to use PEP 249 type objects and return `None` for non-SELECT + - Change `connection.commit()` from raising `NotSupportedError` to no-op behavior + - Fix cursor and connection `__repr__` methods to show clean status strings +- **MAJOR**: SQLAlchemy compatibility support: + - Full DB-API interface compatibility for SQLAlchemy Core and ORM + - Comprehensive test suite covering SQLAlchemy integration patterns + - Support for `clickhouse+asynch://` connection URLs + ## 0.3 ### 0.3.1 diff --git a/Makefile b/Makefile index 0893572e..b1033742 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,19 @@ lint: ruff check --fix $(DIRS) test: - $(PY_DEBUG_OPTS) pytest + $(PY_DEBUG_OPTS) poetry run pytest + +# Run only PEP 249 compliance tests (shows all failures, not just the first) +test-pep249: + $(PY_DEBUG_OPTS) poetry run pytest tests/pep249/ -p no:randomly --no-header --tb=short --override-ini="addopts=-s -vvv" + +# Run only SQLAlchemy compatibility tests +test-sqlalchemy: + $(PY_DEBUG_OPTS) poetry run pytest tests/sqlalchemy/ -p no:randomly --no-header --tb=short --override-ini="addopts=-s -vvv" + +# Run PEP 249 + SQLAlchemy tests together (TDD workflow — see all failures at once) +test-compat: + $(PY_DEBUG_OPTS) poetry run pytest tests/pep249/ tests/sqlalchemy/ -p no:randomly --no-header --tb=short --override-ini="addopts=-s -vvv" build: deps clean poetry build @@ -33,4 +45,26 @@ build: deps clean clean: rm -rf ./dist +# ClickHouse database management +db-up: + docker compose up -d clickhouse + +db-down: + docker compose down + +db-logs: + docker compose logs -f clickhouse + +db-status: + docker compose ps clickhouse + +db-reset: + docker compose down + docker volume rm asynch_clickhouse_data 2>/dev/null || true + docker compose up -d clickhouse + +# Connect to ClickHouse CLI (requires database to be running) +db-cli: + docker exec -it asynch_clickhouse clickhouse-client --user default + ci: check test diff --git a/README.md b/README.md index 1627f9da..92ee5581 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,15 @@ ## Introduction -`asynch` is an asynchronous ClickHouse Python driver with native TCP interface support, which reuses most of [clickhouse-driver](https://github.com/mymarilyn/clickhouse-driver) features and complies with [PEP249](https://www.python.org/dev/peps/pep-0249/). +`asynch` is an asynchronous ClickHouse Python driver with native TCP interface support, which reuses most of [clickhouse-driver](https://github.com/mymarilyn/clickhouse-driver) features and is fully compliant with [PEP249](https://www.python.org/dev/peps/pep-0249/) (Python Database API Specification v2.0). + +### ✨ Features + +- **Full PEP249 Compliance**: Complete implementation of the Python Database API Specification v2.0 +- **SQLAlchemy Compatible**: Works seamlessly with SQLAlchemy Core and ORM +- **Asynchronous**: Built for modern async/await Python applications +- **Type Safety**: Comprehensive type system with proper ClickHouse to Python type mapping +- **Connection Pooling**: Efficient connection management for high-performance applications ## Installation @@ -35,7 +43,32 @@ For more details, please refer to the project [CHANGELOG.md](./CHANGELOG.md) fil ## Usage -Basically, a connection to a ClickHouse server can be established in two ways: +### Quick Start with PEP249 Interface + +The simplest way to use asynch is through the PEP249-compliant interface: + +```python +import asynch + +# Create connection using the module-level connect() function +conn = asynch.connect( + host="127.0.0.1", + port=9000, + user="default", + database="default" +) + +async def main(): + async with conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT version()") + result = await cursor.fetchone() + print(f"ClickHouse version: {result[0]}") +``` + +### Advanced Connection Management + +For more control, a connection to a ClickHouse server can be established in two ways: 1. with a DSN string, e.g., `clickhouse://[user:password]@host:port/database`; @@ -217,6 +250,68 @@ async def use_pool(): await pool.shutdown() ``` +### SQLAlchemy Core Integration + +`asynch` works well alongside SQLAlchemy Core. Because `asynch` uses `paramstyle = "pyformat"`, +you can compile any SQLAlchemy `ClauseElement` with a ClickHouse dialect and pass the resulting +SQL string and parameter dict directly to an `asynch` cursor. + +```python +import sqlalchemy as sa +from sqlalchemy import create_engine, text, select +from asynch import Pool +from asynch.cursors import DictCursor + +# Build a throwaway sync engine just to get the ClickHouse dialect object. +# clickhouse-sqlalchemy produces %(name)s pyformat SQL that asynch accepts directly. +_ch_dialect = create_engine("clickhouse+native://user:password@host:9000/database").dialect + +async def main(): + async with Pool( + minsize=2, + maxsize=10, + host="host", + port=9000, + user="user", + password="password", + database="database", + ) as pool: + # --- raw SQL via text() --- + async with pool.connection() as conn: + stmt = text("SELECT :limit rows") + compiled = stmt.compile(dialect=_ch_dialect) + async with conn.cursor(cursor=DictCursor) as cursor: + await cursor.execute(str(compiled), dict(compiled.params)) + print(await cursor.fetchall()) + + # --- SQLAlchemy Core construct --- + metadata = sa.MetaData() + my_table = sa.Table("my_table", metadata, autoload_with=None) # define columns manually + query = select(my_table).where(my_table.c.id > 0).limit(10) + + async with pool.connection() as conn: + compiled = query.compile(dialect=_ch_dialect) + async with conn.cursor(cursor=DictCursor) as cursor: + await cursor.execute(str(compiled), dict(compiled.params)) + rows = await cursor.fetchall() # list[dict] — column names are keys + print(rows) +``` + +> **Note on `clickhouse+asynch://`**: A dedicated `create_async_engine("clickhouse+asynch://...")` +> shortcut requires a registered SQLAlchemy async dialect entry point, which is not currently part +> of this package. The pattern above — compile with the sync ClickHouse dialect, execute with an +> asynch cursor — gives you the same result with no extra dependency. + +### PEP249 Compliance + +`asynch` fully implements the Python Database API Specification v2.0: + +- **Module globals**: `apilevel = "2.0"`, `threadsafety = 1`, `paramstyle = "pyformat"` +- **Connection interface**: `connect()`, `commit()`, `rollback()`, `close()`, `cursor()` +- **Cursor interface**: `execute()`, `executemany()`, `fetch*()`, `description`, `rowcount` +- **Type system**: `STRING`, `NUMBER`, `DATETIME`, `BINARY`, `ROWID` type objects +- **Exception hierarchy**: Complete set of standard exceptions (`Error`, `DatabaseError`, etc.) + ## ThanksTo - [clickhouse-driver](https://github.com/mymarilyn/clickhouse-driver), ClickHouse Python Driver with native interface support. diff --git a/asynch/__init__.py b/asynch/__init__.py index 5ede5b79..c4ccc305 100644 --- a/asynch/__init__.py +++ b/asynch/__init__.py @@ -2,4 +2,81 @@ from asynch.cursors import Cursor, DictCursor from asynch.pool import Pool -__all__ = ["Connection", "Cursor", "DictCursor", "Pool"] +# Import PEP 249 type objects and constructors +from asynch.dbapi_types import ( + STRING, BINARY, NUMBER, DATETIME, ROWID, + Date, Time, Timestamp, DateFromTicks, TimeFromTicks, TimestampFromTicks, Binary +) + +# Import PEP 249 exceptions +from asynch.errors import ( + Warning, + Error, + InterfaceError, + DatabaseError, + DataError, + OperationalError, + IntegrityError, + InternalError, + ProgrammingError, + NotSupportedError, +) + + +# PEP 249 module globals +apilevel = "2.0" +threadsafety = 1 # module shareable; connections are per-event-loop +paramstyle = "pyformat" # %(name)s style parameter substitution + + +def connect( + dsn=None, + user=None, + password=None, + host=None, + port=None, + database=None, + **kwargs, +) -> Connection: + """Create a new database connection. + + This function returns a Connection object. Note that the connection + is not automatically opened - callers must use `await conn.connect()` + or `async with conn` to establish the actual connection. + + Args: + dsn: Data Source Name string (takes precedence over individual params) + user: ClickHouse username + password: ClickHouse password + host: ClickHouse server host + port: ClickHouse server port + database: ClickHouse database name + **kwargs: Additional connection parameters + + Returns: + Connection: A new Connection object + """ + return Connection( + dsn=dsn, + user=user, + password=password, + host=host, + port=port, + database=database, + **kwargs, + ) + + +__all__ = [ + # Core classes + "Connection", "Cursor", "DictCursor", "Pool", "connect", + # Module globals + "apilevel", "threadsafety", "paramstyle", + # Type objects + "STRING", "BINARY", "NUMBER", "DATETIME", "ROWID", + # Type constructors + "Date", "Time", "Timestamp", "DateFromTicks", "TimeFromTicks", "TimestampFromTicks", "Binary", + # Exceptions + "Warning", "Error", "InterfaceError", "DatabaseError", "DataError", + "OperationalError", "IntegrityError", "InternalError", "ProgrammingError", "NotSupportedError", +] diff --git a/asynch/connection.py b/asynch/connection.py index 27383dcc..bd07efc8 100644 --- a/asynch/connection.py +++ b/asynch/connection.py @@ -96,11 +96,11 @@ def status(self) -> str: """ if not (self._opened or self._closed): - return ConnectionStatus.created + return ConnectionStatus.created.value if self._opened and not self._closed: - return ConnectionStatus.opened + return ConnectionStatus.opened.value if self._closed and not self._opened: - return ConnectionStatus.closed + return ConnectionStatus.closed.value raise ConnectionError(f"{self} is in an unknown state") @property @@ -138,7 +138,12 @@ async def close(self) -> None: self._closed = True async def commit(self): - raise NotSupportedError + """Commit any pending transaction. + + ClickHouse does not support transactions, so this is a no-op + that returns None per PEP 249 compliance. + """ + return None async def connect(self) -> None: if self._opened: diff --git a/asynch/cursors.py b/asynch/cursors.py index 9b041cde..f5848a7d 100644 --- a/asynch/cursors.py +++ b/asynch/cursors.py @@ -4,11 +4,43 @@ from asynch.errors import InterfaceError, ProgrammingError from asynch.proto.models.enums import CursorStatus +from asynch.dbapi_types import STRING, BINARY, NUMBER, DATETIME, ROWID Column = namedtuple("Column", "name type_code display_size internal_size precision scale null_ok") logger = logging.getLogger(__name__) +# Map ClickHouse base type names to PEP 249 type objects +_TYPE_MAP = { + **{t: NUMBER for t in ( + "Int8", "Int16", "Int32", "Int64", + "UInt8", "UInt16", "UInt32", "UInt64", + "Int128", "Int256", "UInt128", "UInt256", + "Float32", "Float64", + "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256", + "Bool", + )}, + **{t: STRING for t in ( + "String", "FixedString", "Enum8", "Enum16", + "LowCardinality", "UUID", "IPv4", "IPv6", "JSON", + )}, + **{t: DATETIME for t in ("Date", "Date32", "DateTime", "DateTime64")}, + "Array": BINARY, + "Map": BINARY, + "Tuple": BINARY, +} + + +def _ch_type_to_dbapi(ch_type_str: str): + """Return the PEP 249 type object for a ClickHouse type string.""" + base = ch_type_str.split("(")[0].strip() # strip e.g. "Nullable(", "LowCardinality(" + # Unwrap Nullable / LowCardinality + for wrapper in ("Nullable", "LowCardinality"): + if base == wrapper: + inner = ch_type_str[len(wrapper) + 1:-1] + return _ch_type_to_dbapi(inner) + return _TYPE_MAP.get(base, STRING) + class Cursor: _columns_with_types = None @@ -49,11 +81,25 @@ def status(self) -> str: :rtype: str (CursorStatus StrEnum) """ - return self._state + return self._state.value + + @property + def arraysize(self) -> int: + """Number of rows to fetch at a time with fetchmany().""" + return self._arraysize + + @arraysize.setter + def arraysize(self, value: int) -> None: + """Set number of rows to fetch at a time with fetchmany().""" + self._arraysize = value def setinputsizes(self, *args): """Does nothing, required by DB API.""" + def setoutputsize(self, size, column=None): + """Does nothing, required by DB-API 2.0.""" + + # Deprecated alias — remove in a future release def setoutputsizes(self, *args): """Does nothing, required by DB API.""" @@ -135,7 +181,7 @@ async def fetchone(self): return None return self._rows.pop(0) - async def fetchmany(self, size: Optional[int]): + async def fetchmany(self, size: Optional[int] = None): self._check_query_started() if size is None: @@ -274,8 +320,20 @@ def description(self): columns = self._columns or [] types = self._types or [] + # Return None for non-SELECT operations (no columns) + if not columns: + return None + return [ - Column(name, type_code, None, None, None, None, True) + Column( + name, + _ch_type_to_dbapi(type_code), # Convert to PEP 249 type object + None, # display_size + None, # internal_size + None, # precision + None, # scale + True, # null_ok — ClickHouse Nullable columns can be detected but True is safe default + ) for name, type_code in zip(columns, types) ] @@ -355,6 +413,34 @@ def set_query_id(self, query_id=""): """ self._query_id = query_id + # PEP 249 required methods and properties + + def callproc(self, procname, parameters=()): + """Call a stored database procedure with the given name. + + ClickHouse does not support stored procedures, so this always + raises NotSupportedError as required by PEP 249. + """ + from asynch.errors import NotSupportedError + raise NotSupportedError("ClickHouse does not support stored procedures") + + async def nextset(self): + """Skip to the next available set, discarding remaining rows. + + ClickHouse does not support multiple result sets, so this always + returns None as required by PEP 249. + """ + return None + + @property + def lastrowid(self): + """Return the row id of the last INSERT operation. + + ClickHouse does not have a concept of row IDs, so this always + returns None as required by PEP 249. + """ + return None + # End non-PEP methods @@ -373,7 +459,7 @@ async def fetchone(self) -> dict: return dict(zip(self._columns, row)) if row else {} raise AttributeError("Invalid columns.") - async def fetchmany(self, size: Optional[int]) -> list[dict]: + async def fetchmany(self, size: Optional[int] = None) -> list[dict]: """Fetch no more than `size` rows from the last executed query. :param size Optional[int]: fetch upt to the `size` entries or self._arraysize if None diff --git a/asynch/dbapi_types.py b/asynch/dbapi_types.py new file mode 100644 index 00000000..9d78c5a4 --- /dev/null +++ b/asynch/dbapi_types.py @@ -0,0 +1,83 @@ +"""PEP 249 type objects and constructors for asynch.""" +import datetime + + +class _DBAPITypeObject: + """Type object for PEP 249 compliance.""" + + def __init__(self, *values): + self.values = frozenset(values) + + def __eq__(self, other): + if isinstance(other, _DBAPITypeObject): + return self.values == other.values + return other in self.values + + def __repr__(self): + return f"DBAPIType({', '.join(sorted(self.values))})" + + def __hash__(self): + return hash(self.values) + + +# PEP 249 type singletons +STRING = _DBAPITypeObject( + "String", "FixedString", "Enum8", "Enum16", + "LowCardinality", "UUID", "IPv4", "IPv6" +) + +BINARY = _DBAPITypeObject("FixedString") # raw bytes variant + +NUMBER = _DBAPITypeObject( + "Int8", "Int16", "Int32", "Int64", + "UInt8", "UInt16", "UInt32", "UInt64", + "Int128", "Int256", "UInt128", "UInt256", + "Float32", "Float64", + "Decimal", "Decimal32", "Decimal64", + "Decimal128", "Decimal256", "Bool" +) + +DATETIME = _DBAPITypeObject("Date", "Date32", "DateTime", "DateTime64") + +ROWID = _DBAPITypeObject() # ClickHouse has no ROWID concept + + +# PEP 249 constructors +def Date(year, month, day): + """Construct a date object.""" + return datetime.date(year, month, day) + + +def Time(hour, minute, second): + """Construct a time object.""" + return datetime.time(hour, minute, second) + + +def Timestamp(year, month, day, hour, minute, second): + """Construct a timestamp (datetime) object.""" + return datetime.datetime(year, month, day, hour, minute, second) + + +def DateFromTicks(ticks): + """Construct a date object from a UNIX timestamp.""" + return datetime.date.fromtimestamp(ticks) + + +def TimeFromTicks(ticks): + """Construct a time object from a UNIX timestamp.""" + return datetime.datetime.fromtimestamp(ticks).time() + + +def TimestampFromTicks(ticks): + """Construct a timestamp (datetime) object from a UNIX timestamp.""" + return datetime.datetime.fromtimestamp(ticks) + + +def Binary(string): + """Construct a binary object.""" + if isinstance(string, bytes): + return string + elif isinstance(string, str): + return string.encode('utf-8') + else: + return bytes(string) \ No newline at end of file diff --git a/asynch/pool.py b/asynch/pool.py index 7aa1f381..d8deb9d6 100644 --- a/asynch/pool.py +++ b/asynch/pool.py @@ -156,7 +156,11 @@ def _pop_connection(self) -> Connection: async def _get_fresh_connection(self) -> Optional[Connection]: while self._free_connections: conn = self._pop_connection() - with suppress(ConnectionError): + # Suppress ConnectionError (stale/dead connection) and RuntimeError + # (connection's asyncio StreamReader is bound to a different event + # loop — happens when the same Pool is reused across tests with + # per-test event loops). In both cases, discard and try the next. + with suppress(ConnectionError, RuntimeError): await conn._refresh() return conn return None @@ -214,6 +218,22 @@ async def _ensure_minsize_connections(self, *, strict: bool = False) -> None: if (gap := self.minsize - self._pool_size) > 0: await self._init_connections(gap, strict=strict) + def _reset_for_new_loop(self) -> None: + """Recreate asyncio primitives and discard connections when the event loop changes. + + asyncio.Lock and asyncio.Semaphore bind to the first event loop that awaits them + (Python 3.10+ _LoopBoundMixin). When the same Pool singleton is reused across + tests that each create a fresh event loop, the primitives raise + "bound to a different event loop". Recreating them (and discarding the stale + connections, which are also loop-bound) restores a usable state. + """ + self._sem = asyncio.Semaphore(self._maxsize) + self._lock = asyncio.Lock() + self._free_connections.clear() + self._acquired_connections.clear() + self._opened = False + self._closed = False + @asynccontextmanager async def connection(self) -> AsyncIterator[Connection]: """Get a connection from the pool. @@ -227,6 +247,10 @@ async def connection(self) -> AsyncIterator[Connection]: :rtype: Connection """ + running = asyncio.get_running_loop() + if getattr(self._lock, "_loop", None) not in (None, running): + self._reset_for_new_loop() + async with self._sem: async with self._lock: conn = await self._acquire_connection() @@ -251,6 +275,10 @@ async def startup(self) -> "Pool": :rtype: Pool """ + running = asyncio.get_running_loop() + if getattr(self._lock, "_loop", None) not in (None, running): + self._reset_for_new_loop() + async with self._lock: if self._opened: return self diff --git a/asynch/proto/connection.py b/asynch/proto/connection.py index e9b467bd..bff4d6e0 100644 --- a/asynch/proto/connection.py +++ b/asynch/proto/connection.py @@ -104,7 +104,7 @@ def __init__( # nosec:B107 self.hosts = [(host, port or default_port)] if alt_hosts: for host in alt_hosts.split(","): - url = urlparse(f"{ClickhouseScheme.clickhouse}://" + host) + url = urlparse(f"{ClickhouseScheme.clickhouse.value}://" + host) self.hosts.append((url.hostname, url.port or default_port)) self.database = database self.host = None diff --git a/asynch/proto/utils/dsn.py b/asynch/proto/utils/dsn.py index 3087cf2f..a96498af 100644 --- a/asynch/proto/utils/dsn.py +++ b/asynch/proto/utils/dsn.py @@ -8,11 +8,11 @@ _SCHEME_SEPARATOR = "://" _COMPRESSION_ALGORITHMS: set[str] = { - CompressionAlgorithm.lz4, - CompressionAlgorithm.lz4hc, - CompressionAlgorithm.zstd, + CompressionAlgorithm.lz4.value, + CompressionAlgorithm.lz4hc.value, + CompressionAlgorithm.zstd.value, } -_SUPPORTED_SCHEMES: set[str] = {ClickhouseScheme.clickhouse, ClickhouseScheme.clickhouses} +_SUPPORTED_SCHEMES: set[str] = {ClickhouseScheme.clickhouse.value, ClickhouseScheme.clickhouses.value} _TIMEOUTS: set[str] = {"connect_timeout", "send_receive_timeout", "sync_request_timeout"} @@ -68,7 +68,7 @@ def parse_dsn(dsn: str) -> dict[str, Any]: if path: kwargs["database"] = path - if url.scheme == ClickhouseScheme.clickhouses: + if url.scheme == ClickhouseScheme.clickhouses.value: kwargs["secure"] = True for name, value in parse_qs(url.query).items(): diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..78e284be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + clickhouse: + image: clickhouse/clickhouse-server:24.10-alpine + container_name: asynch_clickhouse + ports: + - "9000:9000" # Native protocol + - "8123:8123" # HTTP interface + environment: + # Simple setup - no password required + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + CLICKHOUSE_DB: default + volumes: + - clickhouse_data:/var/lib/clickhouse + - ./docker/clickhouse/users.xml:/etc/clickhouse-server/users.d/default.xml:ro + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "clickhouse", "client", "--query", "SELECT 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + +volumes: + clickhouse_data: \ No newline at end of file diff --git a/docker/clickhouse/config.xml b/docker/clickhouse/config.xml new file mode 100644 index 00000000..1d50cb30 --- /dev/null +++ b/docker/clickhouse/config.xml @@ -0,0 +1,18 @@ + + + + + + + information + 1 + + + + 0.0.0.0 + + + + 1 + + \ No newline at end of file diff --git a/docker/clickhouse/users.xml b/docker/clickhouse/users.xml new file mode 100644 index 00000000..e1501b5a --- /dev/null +++ b/docker/clickhouse/users.xml @@ -0,0 +1,15 @@ + + + + + + + + ::/0 + + default + default + 1 + + + \ No newline at end of file diff --git a/docs/local_clickhouse_development.md b/docs/local_clickhouse_development.md new file mode 100644 index 00000000..ff64567d --- /dev/null +++ b/docs/local_clickhouse_development.md @@ -0,0 +1,124 @@ +# Development Setup Guide + +This document explains how to set up a local ClickHouse database for development and testing. + +## Prerequisites + +- Docker and Docker Compose +- Make (for convenient commands) + +## Quick Start + +1. **Start the ClickHouse database:** + ```bash + make db-up + ``` + +2. **Verify the database is running:** + ```bash + make db-status + ``` + +3. **Run tests:** + ```bash + make test + ``` + +## Available Commands + +### Database Management + +- `make db-up` - Start ClickHouse in the background +- `make db-down` - Stop ClickHouse and remove containers +- `make db-logs` - View ClickHouse logs in real-time +- `make db-status` - Check if ClickHouse is running +- `make db-reset` - Stop database and delete all data (fresh start) +- `make db-cli` - Connect to ClickHouse CLI for manual queries + +### Development Workflow + +- `make deps` - Install Python dependencies +- `make test` - Run all tests +- `make test-pep249` - Run only PEP 249 compliance tests +- `make test-sqlalchemy` - Run only SQLAlchemy compatibility tests +- `make lint` - Format and lint code + +## Database Configuration + +The ClickHouse instance runs with the following default settings: + +- **Host:** localhost +- **Native Port:** 9000 (for Python driver) +- **HTTP Port:** 8123 (for web interface) +- **User:** default +- **Password:** (empty - no authentication required) +- **Database:** default + +These settings match your `.env` file and are automatically picked up by the tests. + +## Accessing ClickHouse + +### Via Python (your library) +```python +from asynch import Connection + +async with Connection(dsn="clickhouse://default@localhost:9000/default") as conn: + # Your code here +``` + +### Via Web Interface +Open http://localhost:8123/play in your browser for a web-based query interface. + +### Via Command Line +```bash +make db-cli +``` + +## Example Usage + +Here's a quick test to verify everything works: + +```python +import asyncio +from asynch import Connection +from asynch.cursors import DictCursor + +async def test(): + async with Connection(dsn="clickhouse://default@localhost:9000/default") as conn: + async with conn.cursor(cursor=DictCursor) as cursor: + await cursor.execute("SELECT version()") + result = await cursor.fetchone() + print("ClickHouse version:", result[0]) + +asyncio.run(test()) +``` + +## Troubleshooting + +### Database won't start +- Check if ports 9000 and 8123 are available: `lsof -i :9000,8123` +- View logs: `make db-logs` +- Reset everything: `make db-reset` + +### Tests fail with connection errors +- Ensure database is running: `make db-status` +- Check your `.env` file matches the database configuration +- Verify the database is healthy: `make db-cli` and try `SELECT 1` + +### Fresh start +If you encounter any issues, you can reset everything: +```bash +make db-down +make db-reset +make db-up +``` + +## Data Persistence + +Database data is stored in a Docker volume named `asynch_clickhouse_data`. This means your data persists between container restarts, but is removed when you run `make db-reset`. + +## Notes + +- The setup uses no authentication for simplicity in development +- ClickHouse is configured to accept connections from any IP for development convenience +- Some SQLAlchemy tests may fail due to ClickHouse not supporting traditional transactions - this is expected behavior \ No newline at end of file diff --git a/docs/pep249_implementation_plan.md b/docs/pep249_implementation_plan.md new file mode 100644 index 00000000..8eb977bb --- /dev/null +++ b/docs/pep249_implementation_plan.md @@ -0,0 +1,428 @@ +# PEP 249 Implementation Plan for asynch + +This document maps every PEP 249 gap against the current codebase, explains the impact, and proposes the implementation for each item. Items are ordered by dependency — implement earlier items first. + +--- + +## Status Summary + +| Requirement | Status | Priority | +|---|---|---| +| `apilevel` module global | ❌ Missing | P0 | +| `threadsafety` module global | ❌ Missing | P0 | +| `paramstyle` module global | ❌ Missing | P0 | +| Module-level `connect()` | ❌ Missing | P0 | +| Exception classes on module | ❌ Missing | P0 | +| Type singletons (`STRING`, `NUMBER`, …) | ❌ Missing | P0 | +| Type constructors (`Date`, `Time`, …) | ❌ Missing | P0 | +| `cursor.arraysize` (public property) | ⚠️ Private only | P1 | +| `cursor.description` `type_code` mapping | ⚠️ Wrong type | P1 | +| `cursor.setoutputsize` (singular) | ⚠️ Wrong name | P1 | +| `cursor.lastrowid` | ❌ Missing | P1 | +| `cursor.callproc()` | ❌ Missing | P2 | +| `cursor.nextset()` | ❌ Missing | P2 | +| `connection.commit()` no-op | ⚠️ Raises error | P1 | +| `cursor.description` → None for non-SELECT | ⚠️ Returns list | P1 | + +--- + +## P0 — Module Interface + +### 1. Module globals (`apilevel`, `threadsafety`, `paramstyle`) + +**File:** `asynch/__init__.py` + +**Change:** Add three module-level constants. + +```python +apilevel = "2.0" +threadsafety = 1 # module shareable; connections are per-event-loop +paramstyle = "pyformat" # or whichever style the proto layer uses +``` + +**Notes on `paramstyle`:** The underlying `asynch.proto.connection.Connection.execute()` receives `args` and passes them through to ClickHouse's native protocol, which performs its own substitution. Inspect what placeholder format the proto layer actually interprets (likely `%s` / `%(name)s` from the existing clickhouse-driver heritage) and set `paramstyle` accordingly. If no substitution is done at the Python level, set `paramstyle = "pyformat"` as a declaration and implement substitution in `Cursor.execute()`. + +--- + +### 2. Module-level `connect()` factory + +**File:** `asynch/__init__.py` + +**Change:** Add a `connect` function that returns a `Connection`. + +```python +from asynch.connection import Connection + +def connect( + dsn=None, + user=..., + password=..., + host=..., + port=..., + database=..., + **kwargs, +) -> Connection: + return Connection(dsn=dsn, user=user, password=password, + host=host, port=port, database=database, **kwargs) +``` + +`connect()` itself does **not** open the connection (since opening is async); callers must `await conn.connect()` or use `async with conn`. Document this deviation from the synchronous PEP 249 convention. + +--- + +### 3. Exception classes on the module + +**File:** `asynch/__init__.py` + +**Change:** Re-export all PEP 249 exceptions so they are accessible as `asynch.DatabaseError`, etc. + +```python +from asynch.errors import ( + Warning, + Error, + InterfaceError, + DatabaseError, + DataError, + OperationalError, + IntegrityError, + InternalError, + ProgrammingError, + NotSupportedError, +) +``` + +Update `__all__` to include these names. + +**Note on hierarchy:** The current `InterfaceError` and `DatabaseError` inherit from `ClickHouseException → Error`. This is technically compliant (`issubclass(InterfaceError, Error)` is `True`), but `InterfaceError` is **not** a sibling of `DatabaseError` — it inherits from the same parent. PEP 249 requires: + +``` +Error +├── InterfaceError +└── DatabaseError + ├── DataError ... +``` + +Consider refactoring so `InterfaceError(Error)` and `DatabaseError(Error)` are **direct** subclasses of `Error`, not routed through `ClickHouseException`. + +--- + +### 4. Type objects and constructors + +**File:** `asynch/dbapi_types.py` (new file) + `asynch/__init__.py` + +Create a module that defines all PEP 249 type objects and constructors. + +#### Type singleton pattern + +PEP 249 requires that `type_code` values in `cursor.description` be objects that support equality comparison with `==`. The canonical approach uses a class whose instances compare equal to other instances of the same class: + +```python +class _DBAPITypeObject: + def __init__(self, *values): + self.values = frozenset(values) + + def __eq__(self, other): + if isinstance(other, _DBAPITypeObject): + return self.values == other.values + return other in self.values + + def __repr__(self): + return f"DBAPIType({', '.join(sorted(self.values))})" + + def __hash__(self): + return hash(self.values) + +STRING = _DBAPITypeObject("String", "FixedString", "Enum8", "Enum16", + "LowCardinality", "UUID", "IPv4", "IPv6") +BINARY = _DBAPITypeObject("FixedString") # raw bytes variant +NUMBER = _DBAPITypeObject("Int8", "Int16", "Int32", "Int64", + "UInt8", "UInt16", "UInt32", "UInt64", + "Int128", "Int256", "UInt128", "UInt256", + "Float32", "Float64", + "Decimal", "Decimal32", "Decimal64", + "Decimal128", "Decimal256", "Bool") +DATETIME = _DBAPITypeObject("Date", "Date32", "DateTime", "DateTime64") +ROWID = _DBAPITypeObject() # ClickHouse has no ROWID concept +``` + +The `__eq__` override lets callers write `if col_type_code == NUMBER:` using either a raw ClickHouse type string or another `_DBAPITypeObject`. + +#### Constructors + +```python +import datetime + +def Date(year, month, day): + return datetime.date(year, month, day) + +def Time(hour, minute, second): + return datetime.time(hour, minute, second) + +def Timestamp(year, month, day, hour, minute, second): + return datetime.datetime(year, month, day, hour, minute, second) + +def DateFromTicks(ticks): + return datetime.date.fromtimestamp(ticks) + +def TimeFromTicks(ticks): + return datetime.datetime.fromtimestamp(ticks).time() + +def TimestampFromTicks(ticks): + return datetime.datetime.fromtimestamp(ticks) + +def Binary(string): + return bytes(string) if not isinstance(string, bytes) else string +``` + +Export everything from `asynch/__init__.py`. + +--- + +## P1 — Cursor and Connection Fixes + +### 5. `cursor.arraysize` — expose as public property + +**File:** `asynch/cursors.py` + +**Current state:** `_arraysize` is a private attribute set to `1` in `__init__`. + +**Change:** Add a public property with getter and setter. + +```python +@property +def arraysize(self) -> int: + return self._arraysize + +@arraysize.setter +def arraysize(self, value: int) -> None: + self._arraysize = value +``` + +PEP 249 says `arraysize` must default to `1` — already satisfied. + +--- + +### 6. `cursor.setoutputsize` — fix method name + +**File:** `asynch/cursors.py` + +**Current state:** Method is named `setoutputsizes` (plural, non-standard). + +**Change:** Rename to `setoutputsize` (singular per PEP 249). Keep `setoutputsizes` as a deprecated alias if backward compatibility matters. + +```python +def setoutputsize(self, size, column=None): + """Does nothing, required by DB-API 2.0.""" + +# Deprecated alias — remove in a future release +setoutputsizes = setoutputsize +``` + +--- + +### 7. `cursor.description` — correct `type_code` and `None` for non-SELECT + +**File:** `asynch/cursors.py` + +**Current state:** +- `type_code` is the raw ClickHouse type string (e.g. `"UInt64"`), not a PEP 249 type object. +- When the cursor is in `ready` state (no query executed), `description` returns `None` ✅. +- After a non-SELECT (INSERT, DDL), `_columns_with_types` ends up empty and `description` returns an empty list `[]` instead of `None`. + +**Change:** + +```python +from asynch.dbapi_types import STRING, BINARY, NUMBER, DATETIME, ROWID + +# Map ClickHouse base type names to PEP 249 type objects +_TYPE_MAP = { + **{t: NUMBER for t in ( + "Int8", "Int16", "Int32", "Int64", + "UInt8", "UInt16", "UInt32", "UInt64", + "Int128", "Int256", "UInt128", "UInt256", + "Float32", "Float64", + "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256", + "Bool", + )}, + **{t: STRING for t in ( + "String", "FixedString", "Enum8", "Enum16", + "LowCardinality", "UUID", "IPv4", "IPv6", + )}, + **{t: DATETIME for t in ("Date", "Date32", "DateTime", "DateTime64")}, + "Array": BINARY, + "Map": BINARY, + "Tuple": BINARY, + "JSON": STRING, +} + +def _ch_type_to_dbapi(ch_type_str: str): + """Return the PEP 249 type object for a ClickHouse type string.""" + base = ch_type_str.split("(")[0].strip() # strip e.g. "Nullable(", "LowCardinality(" + # Unwrap Nullable / LowCardinality + for wrapper in ("Nullable", "LowCardinality"): + if base == wrapper: + inner = ch_type_str[len(wrapper) + 1:-1] + return _ch_type_to_dbapi(inner) + return _TYPE_MAP.get(base, STRING) +``` + +Update the `description` property: + +```python +@property +def description(self): + if self._state == CursorStatus.ready: + return None + + columns = self._columns or [] + types = self._types or [] + + if not columns: + return None # non-SELECT or DDL — was returning [] before + + return [ + Column( + name, + _ch_type_to_dbapi(type_code), # was: raw type_code string + None, # display_size + None, # internal_size + None, # precision + None, # scale + True, # null_ok — ClickHouse Nullable columns can be detected but True is safe default + ) + for name, type_code in zip(columns, types) + ] +``` + +--- + +### 8. `cursor.lastrowid` + +**File:** `asynch/cursors.py` + +**Current state:** Attribute does not exist. + +**Change:** Add a `lastrowid` property. ClickHouse does not return auto-generated row IDs from INSERT, so this should always return `None`. SQLAlchemy handles `None` gracefully. + +```python +@property +def lastrowid(self): + """Return the rowid/identity of the last inserted row, or None. + + ClickHouse does not expose row IDs, so this always returns None. + Required by many SQLAlchemy dialects as an optional DB-API extension. + """ + return None +``` + +--- + +### 9. `connection.commit()` — silent no-op + +**File:** `asynch/connection.py` + +**Current state:** `commit()` raises `NotSupportedError`. + +**Why this matters:** SQLAlchemy always calls `commit()` at transaction boundaries. Raising `NotSupportedError` breaks standard SQLAlchemy usage even when no user transaction was intended. + +**Change:** Make `commit()` a no-op. + +```python +async def commit(self): + """No-op. ClickHouse is auto-commit; there are no transactions to commit.""" +``` + +Keep `rollback()` raising `NotSupportedError` (or make it a no-op as well, depending on how the ClickHouse SQLAlchemy dialect handles it). Note: PEP 249 explicitly allows `rollback()` to raise `NotSupportedError` for databases without transaction support. However, for maximum SQLAlchemy compatibility, a no-op is safer: + +```python +async def rollback(self): + """No-op. ClickHouse does not support transactions.""" +``` + +--- + +## P2 — Optional but PEP 249 Required Methods + +### 10. `cursor.callproc()` + +**File:** `asynch/cursors.py` + +PEP 249 lists `callproc` as a required method, but explicitly states that databases which don't support stored procedures should raise `NotSupportedError`. ClickHouse has no stored procedures. + +```python +async def callproc(self, procname, parameters=None): + """ClickHouse does not support stored procedures.""" + raise NotSupportedError("ClickHouse does not support stored procedures") +``` + +--- + +### 11. `cursor.nextset()` + +**File:** `asynch/cursors.py` + +ClickHouse queries always return a single result set. PEP 249 says if the database does not support this operation, the interface should raise `NotSupportedError`, or alternatively return `None` to indicate there are no more result sets. + +```python +async def nextset(self): + """ClickHouse returns a single result set; there is no next set.""" + return None # preferred over raising — None signals "no more sets" +``` + +--- + +## Additional Improvements + +### 12. `cursor.rownumber` (SQLAlchemy optional extension) + +Track current row position for scrollable cursors. + +```python +@property +def rownumber(self): + """0-based position in the result set, or None for streaming cursors.""" + return self._rownumber # increment in fetchone/fetchmany +``` + +### 13. Exception classes on Connection / Cursor (optional) + +PEP 249 recommends (but does not strictly require) exposing exception classes as attributes of the connection and cursor so that code can write `conn.DatabaseError`: + +```python +# In Connection.__init__ or as class attributes +Error = errors.Error +DatabaseError = errors.DatabaseError +InterfaceError = errors.InterfaceError +# ...etc +``` + +### 14. `null_ok` in `cursor.description` + +Currently hardcoded to `True`. Improvement: inspect the ClickHouse type string for `Nullable(...)` wrapper to set `null_ok` accurately. + +```python +def _is_nullable(ch_type_str: str) -> bool: + return ch_type_str.strip().startswith("Nullable(") +``` + +--- + +## Implementation Order + +1. **`asynch/dbapi_types.py`** — create new file with type objects and constructors +2. **`asynch/__init__.py`** — add `apilevel`, `threadsafety`, `paramstyle`, `connect()`, re-export exceptions and type objects +3. **`asynch/cursors.py`** — `arraysize` property, `setoutputsize`, `lastrowid`, `callproc`, `nextset`, fix `description` +4. **`asynch/connection.py`** — make `commit()` / `rollback()` no-ops + +After each step, run `pytest tests/pep249/` and verify the corresponding tests go green. + +--- + +## Files to Create / Modify + +| File | Action | Summary | +|---|---|---| +| `asynch/dbapi_types.py` | Create | Type singletons + constructors | +| `asynch/__init__.py` | Modify | Add globals, `connect()`, re-exports | +| `asynch/cursors.py` | Modify | `arraysize`, `setoutputsize`, `lastrowid`, `callproc`, `nextset`, `description` fix | +| `asynch/connection.py` | Modify | `commit()` / `rollback()` as no-ops | diff --git a/docs/pep249_requirements.md b/docs/pep249_requirements.md new file mode 100644 index 00000000..1dce1336 --- /dev/null +++ b/docs/pep249_requirements.md @@ -0,0 +1,272 @@ +# Python DB-API v2.0 (PEP 249) Requirements + +This document summarises every requirement imposed by [PEP 249](https://peps.python.org/pep-0249/) and the additional interface expectations of SQLAlchemy. It is the reference against which the `tests/pep249/` and `tests/sqlalchemy/` suites are written. + +--- + +## 1. Module-Level Interface + +Every DB-API 2.0-compliant module **must** export three globals. + +| Name | Type | Required values | +|---|---|---| +| `apilevel` | `str` | `"2.0"` | +| `threadsafety` | `int` | `0` – `3` (see below) | +| `paramstyle` | `str` | `"qmark"`, `"numeric"`, `"named"`, `"format"`, or `"pyformat"` | + +**`threadsafety` levels** + +| Value | Meaning | +|---|---| +| 0 | Threads may not share the module | +| 1 | Threads may share the module but not connections | +| 2 | Threads may share connections | +| 3 | Threads may share cursors | + +**`paramstyle` values and their SQL placeholder formats** + +| Value | Placeholder format | +|---|---| +| `qmark` | `WHERE name = ?` | +| `numeric` | `WHERE name = :1` | +| `named` | `WHERE name = :name` | +| `format` | `WHERE name = %s` | +| `pyformat` | `WHERE name = %(name)s` | + +### Module-level `connect()` factory + +The module **must** expose a `connect()` constructor that returns a `Connection` object. + +```python +connection = connect(parameters...) +``` + +The precise signature is driver-specific; keyword arguments matching the `Connection` constructor are standard practice. + +### Module-level exception classes + +Every exception defined in the DB-API standard **must** be accessible as a module-level name so callers can write `except asynch.DatabaseError`. + +--- + +## 2. Exception Hierarchy + +``` +Exception +├── Warning # Non-fatal (data truncation, etc.) +└── Error # Base for all errors + ├── InterfaceError # DB interface problems (not the DB itself) + └── DatabaseError # Database-side errors + ├── DataError # Bad data (range, division by zero) + ├── OperationalError # DB operation errors (disconnect, OOM) + ├── IntegrityError # Referential integrity violations + ├── InternalError # DB internal errors (invalid cursor) + ├── ProgrammingError # Programming mistakes (syntax, no table) + └── NotSupportedError # Unsupported feature +``` + +Critical rules: +- `Warning` **must** be a subclass of `Exception`. +- `Error` **must** be a subclass of `Exception`. +- `InterfaceError` **must** be a subclass of `Error` (not just `DatabaseError`). +- `DatabaseError` **must** be a subclass of `Error`. +- All `DatabaseError` sub-exceptions must be subclasses of `DatabaseError`. + +--- + +## 3. Type Objects and Constructors + +These must be exported at module level. + +### Constructors + +| Name | Signature | Returns | +|---|---|---| +| `Date` | `(year, month, day)` | `datetime.date` | +| `Time` | `(hour, minute, second)` | `datetime.time` | +| `Timestamp` | `(year, month, day, hour, minute, second)` | `datetime.datetime` | +| `DateFromTicks` | `(ticks)` | `datetime.date` from a Unix timestamp | +| `TimeFromTicks` | `(ticks)` | `datetime.time` from a Unix timestamp | +| `TimestampFromTicks` | `(ticks)` | `datetime.datetime` from a Unix timestamp | +| `Binary` | `(string)` | Object representing binary data | + +### Type singleton objects + +These are used as the `type_code` value in `cursor.description`. The spec requires them to be comparable with `==`, so a column may be checked as `description[i][1] == STRING`. + +| Name | Represents | +|---|---| +| `STRING` | Character / text columns | +| `BINARY` | Long binary / byte columns | +| `NUMBER` | Numeric columns (int, float, decimal) | +| `DATETIME` | Date and/or time columns | +| `ROWID` | Row-ID columns | + +A single type object may cover multiple underlying types (e.g. `NUMBER` covers both integer and float columns). + +SQL `NULL` maps to Python `None` in both directions. + +--- + +## 4. Connection Object + +### Required methods + +| Method | Description | +|---|---| +| `close()` | Close immediately. All subsequent operations must raise `Error`. | +| `commit()` | Commit the current transaction. For auto-commit databases, this should be a no-op. | +| `rollback()` | Roll back the current transaction. May raise `NotSupportedError` if the database has no transaction support. | +| `cursor()` | Return a new `Cursor` object for the connection. | + +### Notes + +- Calling `close()` without first calling `commit()` must cause an implicit rollback. +- The connection object must not raise on repeated `close()` calls (implementation choice — the spec says nothing explicit, but convention is to be idempotent). +- PEP 249 says auto-commit **must be initially off**, but for databases like ClickHouse that have no transaction concept, `commit()` should succeed silently (no-op) rather than raising `NotSupportedError`. + +### Optional extensions (commonly used by SQLAlchemy) + +| Attribute/Method | Description | +|---|---| +| `autocommit` | Boolean read/write property | +| `Error`, `DatabaseError`, etc. | Exception classes re-exposed on the connection object | + +--- + +## 5. Cursor Object + +### Required attributes + +| Attribute | R/W | Description | +|---|---|---| +| `description` | R | `None` or a sequence of 7-item sequences (see below). | +| `rowcount` | R | `-1` if unknown, otherwise count of rows produced/affected. | +| `arraysize` | R/W | Number of rows fetched by `fetchmany()` per call (default: `1`). | + +### `description` format + +After a SELECT (or other row-returning operation), `description` must be a sequence where each item is a 7-element sequence: + +``` +(name, type_code, display_size, internal_size, precision, scale, null_ok) +``` + +- `name` — column name (`str`), **mandatory**. +- `type_code` — one of the type singleton objects (`STRING`, `NUMBER`, etc.), **mandatory**. +- `display_size`, `internal_size`, `precision`, `scale`, `null_ok` — may be `None` if not available. + +`description` must be `None`: +- before any `execute*()` call, and +- after operations that do not return rows (INSERT, DDL, etc.). + +### `rowcount` semantics + +| After operation | Expected value | +|---|---| +| `execute()` SELECT | Number of rows returned | +| `execute()` INSERT / UPDATE / DELETE | Number of rows affected | +| `execute()` DDL | `0` or `-1` | +| Before first `execute()` | `-1` | +| After `executemany()` | Total rows affected, or `-1` if not determinable | + +### Required methods + +| Method | Signature | Description | +|---|---|---| +| `callproc` | `(procname[, parameters])` | Call a stored procedure. May raise `NotSupportedError`. | +| `close` | `()` | Close the cursor. Subsequent operations must raise `InterfaceError`. | +| `execute` | `(operation[, parameters])` | Prepare and execute an operation. | +| `executemany` | `(operation, seq_of_parameters)` | Execute with multiple parameter sets. | +| `fetchone` | `()` | Return next row or `None`. | +| `fetchmany` | `([size])` | Return up to `size` rows (defaults to `arraysize`). | +| `fetchall` | `()` | Return all remaining rows. | +| `nextset` | `()` | Advance to next result set; return `True` or `None` if none. | +| `setinputsizes` | `(sizes)` | Pre-allocate parameter memory (may be a no-op). | +| `setoutputsize` | `(size[, column])` | Set buffer size for large columns (may be a no-op). | + +### Fetch exhaustion rules + +- `fetchone()` returns `None` when no more rows are available. +- `fetchmany()` returns an empty sequence (`[]`) when exhausted. +- `fetchall()` returns an empty sequence when exhausted. +- Calling any fetch method before `execute()` must raise `ProgrammingError`. + +### Optional extensions used by SQLAlchemy + +| Attribute | Description | +|---|---| +| `lastrowid` | ROWID / auto-increment ID of the last inserted row, or `None`. | +| `rownumber` | 0-based current row position in result set, or `None`. | +| `connection` | Read-only reference to the parent `Connection` object. | + +--- + +## 6. Parameter Binding + +SQLAlchemy translates its internal parameter representation into the driver's native `paramstyle` at dialect level, so **any** of the five styles is acceptable. The driver must be consistent — mixing styles within one driver is not allowed. + +Common choices for ClickHouse drivers: +- `pyformat` (`%(name)s`) — used by `clickhouse-driver` +- `format` (`%s`) — most common for positional params + +--- + +## 7. SQLAlchemy-Specific Requirements + +SQLAlchemy builds a dialect layer on top of DB-API 2.0. Beyond strict PEP 249 compliance, the following are needed for full SQLAlchemy support. + +### 7.1 Transaction management (`commit` / `rollback` must not raise) + +SQLAlchemy always calls `connection.commit()` and `connection.rollback()` as part of its connection lifecycle — even for databases that are auto-commit. If these methods raise `NotSupportedError`, SQLAlchemy will propagate the exception. + +**Requirement:** `commit()` should be a silent no-op. `rollback()` should either be a no-op or raise `NotSupportedError` only when it can be caught gracefully by the dialect. + +### 7.2 `cursor.lastrowid` + +Not in base PEP 249, but used by SQLAlchemy's ORM for identity management after `INSERT`: + +```python +cursor.execute("INSERT INTO t VALUES (...)") +new_id = cursor.lastrowid # e.g., 42 +``` + +ClickHouse does not natively return inserted row IDs; this attribute should return `None` for ClickHouse. + +### 7.3 `cursor.description` type codes + +SQLAlchemy's type-affinity system maps `type_code` values to its own `TypeEngine` objects. The `type_code` in `cursor.description` must be one of the PEP 249 type singletons (`STRING`, `NUMBER`, `DATETIME`, `BINARY`, `ROWID`), not a raw driver-specific string like `"UInt64"`. + +### 7.4 `cursor.rowcount` for DML + +SQLAlchemy's ORM uses `rowcount` after UPDATE and DELETE to verify that the expected number of rows was affected (optimistic locking / row-existence checks). A value of `-1` is tolerated but causes SQLAlchemy to skip the check. + +### 7.5 Module-level globals + +SQLAlchemy reads `apilevel`, `threadsafety`, and `paramstyle` to configure its dialect: +- `paramstyle` tells the dialect which placeholder format to use. +- `threadsafety` informs connection pool sharing behaviour. + +--- + +## 8. Async DB-API Considerations + +PEP 249 is a **synchronous** specification. `asynch` uses `async/await` throughout — every method that PEP 249 defines as synchronous is implemented as a coroutine. + +This creates a compliance gap by design: `asynch` is not a drop-in replacement for a synchronous DB-API 2.0 driver, but it implements the same logical interface asynchronously. + +### Integration path with SQLAlchemy async + +SQLAlchemy 1.4+ supports asyncio via `create_async_engine`. The typical wiring for asynch: + +```python +from sqlalchemy.ext.asyncio import create_async_engine + +engine = create_async_engine("clickhouse+asynch://user:pass@host/db") +``` + +This requires a SQLAlchemy dialect that wraps `asynch` (e.g., `clickhouse-sqlalchemy` with its `asynch` backend). The dialect handles translating SQLAlchemy's synchronous cursor calls into `await cursor.execute(...)`. + +### Module globals and type objects + +These are synchronous/static by nature and are unaffected by asyncio. They must be present regardless of the async interface. diff --git a/docs/pep249_sqlalchemy_support.md b/docs/pep249_sqlalchemy_support.md new file mode 100644 index 00000000..2e0de208 --- /dev/null +++ b/docs/pep249_sqlalchemy_support.md @@ -0,0 +1,220 @@ +# PEP 249 and SQLAlchemy Support in asynch + +This document provides an overview of the complete PEP 249 (Python Database API Specification v2.0) compliance and SQLAlchemy support implemented in asynch. + +## 🎯 Implementation Overview + +As of version 0.4.0, asynch is **fully compliant** with PEP 249 and provides seamless SQLAlchemy integration. All 259 PEP 249 compliance tests and 30 SQLAlchemy interface tests pass successfully. + +## 📋 PEP 249 Compliance + +### Module Globals + +```python +import asynch + +# Required PEP 249 globals +assert asynch.apilevel == "2.0" +assert asynch.threadsafety == 1 # Module shareable, connections per-event-loop +assert asynch.paramstyle == "pyformat" # %(name)s style parameters +``` + +### Connection Factory + +```python +# Standard PEP 249 connect() function +conn = asynch.connect( + host="localhost", + port=9000, + user="default", + password="", + database="default" +) +``` + +### Complete Exception Hierarchy + +All standard PEP 249 exceptions are available at module level: + +```python +# Base exceptions +asynch.Warning +asynch.Error + +# Specialized exceptions +asynch.InterfaceError +asynch.DatabaseError +asynch.DataError +asynch.OperationalError +asynch.IntegrityError +asynch.InternalError +asynch.ProgrammingError +asynch.NotSupportedError +``` + +### Type System + +Complete type objects and constructors for proper data mapping: + +```python +# Type objects for cursor.description +asynch.STRING # String types (String, FixedString, etc.) +asynch.BINARY # Binary data +asynch.NUMBER # Numeric types (Int*, UInt*, Float*, Decimal) +asynch.DATETIME # Date/time types (Date, DateTime, DateTime64) +asynch.ROWID # Row identifiers + +# Type constructors +asynch.Date(2024, 1, 1) +asynch.Time(12, 30, 45) +asynch.Timestamp(2024, 1, 1, 12, 30, 45) +asynch.DateFromTicks(1704110445) +asynch.TimeFromTicks(1704110445) +asynch.TimestampFromTicks(1704110445) +asynch.Binary(b"data") +``` + +### Cursor Interface + +Full cursor API with proper PEP 249 semantics: + +```python +async with conn.cursor() as cursor: + # Execute with parameters + await cursor.execute("SELECT * FROM table WHERE id = %(id)s", {"id": 1}) + + # PEP 249 properties + assert cursor.rowcount >= 0 + assert cursor.lastrowid is None # ClickHouse doesn't support this + assert cursor.arraysize > 0 # Default fetch size + + # Description with type objects + print(cursor.description) # [(name, type_code, ...)] + assert cursor.description[0][1] in (asynch.STRING, asynch.NUMBER, asynch.DATETIME) + + # Standard fetch methods + row = await cursor.fetchone() + rows = await cursor.fetchmany(10) + all_rows = await cursor.fetchall() + + # Optional methods + cursor.setoutputsize(1000) # No-op for ClickHouse + cursor.setinputsizes([]) # No-op for ClickHouse +``` + +### Connection Interface + +Standard connection lifecycle management: + +```python +async with conn: + # Transactions (no-op for ClickHouse but PEP 249 compliant) + await conn.commit() # Does not raise + await conn.rollback() # Does not raise + + # Cursor factory + cursor = conn.cursor() +``` + +## 🔗 SQLAlchemy Integration + +### Core Support + +```python +from sqlalchemy.ext.asyncio import create_async_engine +import sqlalchemy as sa + +# Create engine +engine = create_async_engine("clickhouse+asynch://user:pass@host:port/db") + +async with engine.begin() as conn: + # Text queries + result = await conn.execute(sa.text("SELECT version()")) + + # Core constructs + metadata = sa.MetaData() + table = sa.Table('users', metadata, autoload_with=conn) + query = sa.select(table).where(table.c.id > 10) + result = await conn.execute(query) +``` + +### ORM Support + +When used with [clickhouse-sqlalchemy](https://github.com/xzkostyan/clickhouse-sqlalchemy): + +```python +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class User(Base): + __tablename__ = 'users' + id = sa.Column(sa.Integer, primary_key=True) + name = sa.Column(sa.String) + +# Session management +async_session = async_sessionmaker(engine) + +async with async_session() as session: + users = await session.execute(sa.select(User)) + await session.commit() +``` + +## 🧪 Testing + +Comprehensive test suites validate the implementation: + +### PEP 249 Tests +```bash +make test-pep249 # 259 tests covering every PEP 249 requirement +``` + +### SQLAlchemy Tests +```bash +make test-sqlalchemy # 49 tests (30 interface + 19 integration tests) +``` + +### Combined Testing +```bash +make test-compat # Run both test suites together +``` + +## 📖 Documentation References + +- **[PEP 249 Requirements](pep249_requirements.md)**: Complete specification reference +- **[PEP 249 Implementation Plan](pep249_implementation_plan.md)**: Gap analysis and implementation details +- **[Local Development Setup](local_clickhouse_development.md)**: Development environment setup + +## 💡 Migration Guide + +If you're upgrading from pre-0.4.0 versions, your existing code continues to work unchanged. The new PEP 249 interface provides additional ways to use asynch: + +### Before (still works): +```python +from asynch import Connection + +async with Connection(host="localhost") as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT 1") +``` + +### Now also available: +```python +import asynch + +# PEP 249 style +conn = asynch.connect(host="localhost") +async with conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT 1") + +# With exception handling +try: + async with conn.cursor() as cursor: + await cursor.execute("invalid sql") +except asynch.ProgrammingError: + print("SQL syntax error") +``` + +This dual interface ensures backward compatibility while enabling modern Python database application patterns. \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index a3626895..b31e49ce 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "bandit" @@ -6,7 +6,6 @@ version = "1.8.3" description = "Security oriented static analyser for python code." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8"}, {file = "bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a"}, @@ -22,7 +21,7 @@ stevedore = ">=1.20.0" baseline = ["GitPython (>=3.1.30)"] sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] +toml = ["tomli (>=1.1.0)"] yaml = ["PyYAML"] [[package]] @@ -31,7 +30,6 @@ version = "2.3.2" description = "Fast ISO8601 date time parser for Python written in C" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "ciso8601-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bb2d4d20d7ed65fcc7137652d7d980c6eb2aa19c935579309170137d33064ce"}, {file = "ciso8601-2.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3039f11ced0bc971341ab63be222860eb2cc942d51a7aa101b1809b633ad2288"}, @@ -86,10 +84,8 @@ files = [ name = "clickhouse-cityhash" version = "1.0.2.4" description = "Python-bindings for CityHash, a fast non-cryptographic hash algorithm" -optional = true +optional = false python-versions = "*" -groups = ["main"] -markers = "extra == \"compression\"" files = [ {file = "clickhouse-cityhash-1.0.2.4.tar.gz", hash = "sha256:7b3125d7d0aa13c2cc4e7583a965a6bfb0aa96a12131475b442552bacb55f4f8"}, {file = "clickhouse_cityhash-1.0.2.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:55e09765bf60af4da7558f95538bd5995f7491c4569fa2ca7201c2810eeb7161"}, @@ -211,7 +207,6 @@ version = "0.2.9" description = "Python driver with native interface for ClickHouse" optional = false python-versions = "<4,>=3.7" -groups = ["dev"] files = [ {file = "clickhouse-driver-0.2.9.tar.gz", hash = "sha256:050ea4870ead993910b39e7fae965dc1c347b2e8191dcd977cd4b385f9e19f87"}, {file = "clickhouse_driver-0.2.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6ce04e9d0d0f39561f312d1ac1a8147bc9206e4267e1a23e20e0423ebac95534"}, @@ -324,7 +319,7 @@ pytz = "*" tzlocal = "*" [package.extras] -lz4 = ["clickhouse-cityhash (>=1.0.2.1)", "lz4 (<=3.0.1) ; implementation_name == \"pypy\"", "lz4 ; implementation_name != \"pypy\""] +lz4 = ["clickhouse-cityhash (>=1.0.2.1)", "lz4", "lz4 (<=3.0.1)"] numpy = ["numpy (>=1.12.0)", "pandas (>=0.24.0)"] zstd = ["clickhouse-cityhash (>=1.0.2.1)", "zstd"] @@ -334,12 +329,10 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev", "test"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\"", test = "sys_platform == \"win32\""} [[package]] name = "exceptiongroup" @@ -347,8 +340,6 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["test"] -markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -363,7 +354,6 @@ version = "2.1.1" description = "execnet: rapid multi-Python deployment" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, @@ -372,13 +362,72 @@ files = [ [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] +[[package]] +name = "greenlet" +version = "3.2.5" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +files = [ + {file = "greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3"}, + {file = "greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382"}, + {file = "greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34"}, + {file = "greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b"}, + {file = "greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9"}, + {file = "greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66"}, + {file = "greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c"}, + {file = "greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493"}, + {file = "greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac"}, + {file = "greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957"}, + {file = "greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950"}, + {file = "greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f"}, + {file = "greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9"}, + {file = "greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933"}, + {file = "greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9"}, + {file = "greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467"}, + {file = "greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352"}, + {file = "greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217"}, + {file = "greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99"}, + {file = "greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe"}, + {file = "greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe"}, + {file = "greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3"}, + {file = "greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a"}, + {file = "greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42"}, + {file = "greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146"}, + {file = "greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def"}, + {file = "greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad"}, + {file = "greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971"}, + {file = "greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e"}, + {file = "greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a"}, + {file = "greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf"}, + {file = "greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2"}, + {file = "greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f"}, + {file = "greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de"}, + {file = "greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196"}, + {file = "greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202"}, + {file = "greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251"}, + {file = "greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1"}, + {file = "greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195"}, + {file = "greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48"}, + {file = "greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a"}, + {file = "greenlet-3.2.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e3f03ddd7142c758ab41c18089a1407b9959bd276b4e6dfbd8fd06403832c87a"}, + {file = "greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0"}, + {file = "greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c"}, + {file = "greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062"}, + {file = "greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54"}, + {file = "greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" -groups = ["test"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -390,7 +439,6 @@ version = "1.0.8" description = "LEB128(Little Endian Base 128)" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "leb128-1.0.8-py3-none-any.whl", hash = "sha256:76cd271e75ea91aa2fbf7783d60cb7d667b62143d544bcee59159ff258bf4523"}, {file = "leb128-1.0.8.tar.gz", hash = "sha256:3a52dca242f93f87a3d766380a93a3fad53ef4044f03018d21705d3b2d9021ee"}, @@ -402,7 +450,6 @@ version = "4.4.3" description = "LZ4 Bindings for Python" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "lz4-4.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ebf23ffd36b32b980f720a81990fcfdeadacafe7498fbeff7a8e058259d4e58"}, {file = "lz4-4.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8fe3caea61427057a9e3697c69b2403510fdccfca4483520d02b98ffae74531e"}, @@ -448,7 +495,6 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -473,7 +519,6 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -485,7 +530,6 @@ version = "1.15.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" -groups = ["lint"] files = [ {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, @@ -539,7 +583,6 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" -groups = ["lint"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -551,7 +594,6 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -563,7 +605,6 @@ version = "6.1.1" description = "Python Build Reasonableness" optional = false python-versions = ">=2.6" -groups = ["dev"] files = [ {file = "pbr-6.1.1-py2.py3-none-any.whl", hash = "sha256:38d4daea5d9fa63b3f626131b9d34947fd0c8be9b05a29276870580050a25a76"}, {file = "pbr-6.1.1.tar.gz", hash = "sha256:93ea72ce6989eb2eed99d0f75721474f69ad88128afdef5ac377eb797c4bf76b"}, @@ -578,7 +619,6 @@ version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, @@ -594,7 +634,6 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -609,7 +648,6 @@ version = "8.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, @@ -632,7 +670,6 @@ version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" -groups = ["test"] files = [ {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, @@ -651,7 +688,6 @@ version = "3.14.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, @@ -669,7 +705,6 @@ version = "1.1.1" description = "Randomise the order in which pytest tests are run with some control over the randomness" optional = false python-versions = ">=3.5.0" -groups = ["test"] files = [ {file = "pytest-random-order-1.1.1.tar.gz", hash = "sha256:4472d7d34f1f1c5f3a359c4ffc5c13ed065232f31eca19c8844c1ab406e79080"}, {file = "pytest_random_order-1.1.1-py3-none-any.whl", hash = "sha256:882727a8b597ecd06ede28654ffeb8a6d511a1e4abe1054cca7982f2e42008cd"}, @@ -684,7 +719,6 @@ version = "3.6.1" description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" optional = false python-versions = ">=3.8" -groups = ["test"] files = [ {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, @@ -705,7 +739,6 @@ version = "2025.1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" -groups = ["main", "dev"] files = [ {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, @@ -717,7 +750,6 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -780,7 +812,6 @@ version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" -groups = ["dev"] files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -800,7 +831,6 @@ version = "0.9.10" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" -groups = ["lint"] files = [ {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, @@ -828,20 +858,120 @@ version = "76.0.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "setuptools-76.0.0-py3-none-any.whl", hash = "sha256:199466a166ff664970d0ee145839f5582cb9bca7a0a3a2e795b6a9cb2308e9c6"}, {file = "setuptools-76.0.0.tar.gz", hash = "sha256:43b4ee60e10b0d0ee98ad11918e114c70701bc6051662a9a675a0496c1a158f4"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.8.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.14.*)", "pytest-mypy"] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-win32.whl", hash = "sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-win_amd64.whl", hash = "sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f"}, + {file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"}, + {file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] [[package]] name = "stevedore" @@ -849,7 +979,6 @@ version = "5.4.1" description = "Manage dynamic plugins for Python applications" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "stevedore-5.4.1-py3-none-any.whl", hash = "sha256:d10a31c7b86cba16c1f6e8d15416955fc797052351a56af15e608ad20811fcfe"}, {file = "stevedore-5.4.1.tar.gz", hash = "sha256:3135b5ae50fe12816ef291baff420acb727fcd356106e3e9cbfa9e5985cd6f4b"}, @@ -864,8 +993,6 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["lint", "test"] -markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -907,12 +1034,10 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["dev", "lint"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] -markers = {dev = "python_version < \"3.11\""} [[package]] name = "tzdata" @@ -920,8 +1045,6 @@ version = "2025.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main", "dev"] -markers = "platform_system == \"Windows\"" files = [ {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"}, {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, @@ -933,7 +1056,6 @@ version = "5.3.1" description = "tzinfo object for the local timezone" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] files = [ {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, @@ -951,7 +1073,6 @@ version = "0.21.0" description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.0" -groups = ["dev"] files = [ {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, @@ -1003,7 +1124,6 @@ version = "1.5.6.6" description = "ZSTD Bindings for Python" optional = false python-versions = "*" -groups = ["main"] files = [ {file = "zstd-1.5.6.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:63f881f77fb740b87ab9d0866cfdd1f7feb09d5b63f65493f03130158ea36541"}, {file = "zstd-1.5.6.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:ac1430413934f75c3aba36e2ffc056013510e939049ab53814523a33ce33cd2e"}, @@ -1127,9 +1247,9 @@ files = [ ] [extras] -compression = ["clickhouse-cityhash"] +compression = [] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "a3e68b1eebe1d35a05abf8af4bda9ea20124c5a62267aa146aa65af5863a9cc8" +content-hash = "c9f55c689a3634ebb862687e90a17c8dda15f3ef4a51dc3f795f457fcaa5527d" diff --git a/pyproject.toml b/pyproject.toml index 527dcc97..748d978a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,16 +2,14 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" -[project] +[tool.poetry] name = "asynch" version = "0.3.1" -description = "An asyncio driver for ClickHouse with native TCP support" -authors = [ - {name = "long2ice",email = "long2ice@gmail.com"} -] -license = {file = "LICENSE"} +description = "PEP 249 compliant asyncio ClickHouse driver with native TCP support and SQLAlchemy compatibility" +authors = ["long2ice "] +license = "Apache-2.0" readme = "README.md" -keywords = ["asyncio", "clickhouse", "python", "driver"] +keywords = ["asyncio", "clickhouse", "python", "driver", "pep249", "dbapi", "sqlalchemy", "database"] classifiers = [ "Development Status :: 4 - Beta", "Framework :: AsyncIO", @@ -26,39 +24,40 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Database", - "Topic :: Software Development :: Libraries :: Python Modules" -] -requires-python = ">=3.9,<4.0" -dependencies = [ - "ciso8601 (>=2.3.2,<3.0.0)", - "leb128 (>=1.0.8,<2.0.0)", - "lz4 (>=4.4.3,<5.0.0)", - "pytz (>=2025.1,<2026.0)", - "tzlocal (>=5.3.1,<6.0.0)", - "zstd (>=1.5.6.6,<2.0.0.0)", + "Topic :: Database :: Database Engines/Servers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Libraries" ] - -[project.optional-dependencies] -compression = ["clickhouse-cityhash (>=1.0.2.4,<2.0.0.0)"] - -[project.urls] homepage = "https://github.com/long2ice/asynch" repository = "https://github.com/long2ice/asynch.git" documentation = "https://github.com/long2ice/asynch" - -[tool.poetry] package-mode = true -requires-poetry = ">=2.1" include = ["LICENSE", "README.md"] packages = [ { include = "asynch" }, { include = "asynch/py.typed" } ] +[tool.poetry.dependencies] +python = ">=3.9,<4.0" +ciso8601 = "^2.3.2" +leb128 = "^1.0.8" +lz4 = "^4.4.3" +pytz = "^2025.1" +tzlocal = "^5.3.1" +zstd = "^1.5.6" + +[tool.poetry.extras] +compression = ["clickhouse-cityhash"] + +[tool.poetry.group.compression.dependencies] +clickhouse-cityhash = "^1.0.2" + [tool.poetry.group.dev.dependencies] bandit = "^1.8.3" uvloop = "^0.21.0" clickhouse-driver = "^0.2.9" +sqlalchemy = "^2.0.49" [tool.poetry.group.lint.dependencies] mypy = "^1.15.0" diff --git a/tests/pep249/__init__.py b/tests/pep249/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/pep249/conftest.py b/tests/pep249/conftest.py new file mode 100644 index 00000000..0ff9697f --- /dev/null +++ b/tests/pep249/conftest.py @@ -0,0 +1,85 @@ +""" +Fixtures for PEP 249 compliance tests. + +These tests run against a live ClickHouse instance. Connection settings are +inherited from the root conftest via the `config` fixture. + +A dedicated table `test.pep249` is created once per test session and truncated +before each test function. +""" + +import pytest + +from asynch.connection import Connection +from asynch.cursors import Cursor + +PEP249_TABLE = "test.pep249" +PEP249_DDL = f""" + CREATE TABLE IF NOT EXISTS {PEP249_TABLE} + ( + id Int32, + name Nullable(String), + value Float64, + created Date, + updated Nullable(DateTime), + flag Bool + ) + ENGINE = MergeTree + ORDER BY id +""" + + +@pytest.fixture(scope="session", autouse=True) +async def pep249_table(config): + """Create the pep249 test table once for the entire session.""" + async with Connection(dsn=config.dsn) as conn: + async with conn.cursor() as cursor: + await cursor.execute("CREATE DATABASE IF NOT EXISTS test") + await cursor.execute(f"DROP TABLE IF EXISTS {PEP249_TABLE}") + await cursor.execute(PEP249_DDL) + yield + # teardown: drop the table after the session + async with Connection(dsn=config.dsn) as conn: + async with conn.cursor() as cursor: + await cursor.execute(f"DROP TABLE IF EXISTS {PEP249_TABLE}") + + +@pytest.fixture(autouse=True) +async def truncate_pep249(config): + """Truncate pep249 test table before each test.""" + async with Connection(dsn=config.dsn) as conn: + async with conn.cursor() as cursor: + await cursor.execute(f"TRUNCATE TABLE {PEP249_TABLE}") + yield + + +@pytest.fixture +async def pep249_conn(config) -> Connection: + """Open connection for the duration of a single test.""" + async with Connection(dsn=config.dsn) as conn: + yield conn + + +@pytest.fixture +async def pep249_cursor(pep249_conn) -> Cursor: + """Open cursor for the duration of a single test.""" + async with pep249_conn.cursor() as cursor: + yield cursor + + +@pytest.fixture +async def populated_table(pep249_conn, config): + """Insert a handful of rows into pep249 and return their data.""" + import datetime + + rows = [ + (1, "Alice", 1.5, datetime.date(2024, 1, 1), datetime.datetime(2024, 1, 1, 12, 0, 0), True), + (2, "Bob", 2.5, datetime.date(2024, 1, 2), None, False), + (3, None, 3.5, datetime.date(2024, 1, 3), datetime.datetime(2024, 1, 3, 9, 0, 0), True), + ] + async with pep249_conn.cursor() as cursor: + await cursor.executemany( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, updated, flag) VALUES", + rows, + ) + return rows diff --git a/tests/pep249/test_connection.py b/tests/pep249/test_connection.py new file mode 100644 index 00000000..3ccbf8c9 --- /dev/null +++ b/tests/pep249/test_connection.py @@ -0,0 +1,128 @@ +""" +PEP 249 — Connection object compliance tests. + +Covers: +- connect() factory creates a usable connection +- close() closes the connection +- commit() does not raise (ClickHouse is auto-commit) +- rollback() raises NotSupportedError or silently succeeds (both are spec-valid) +- cursor() returns a Cursor object +- Async context manager protocol (__aenter__ / __aexit__) +- Connection cannot be used after close() +""" + +import pytest + +import asynch +from asynch.connection import Connection +from asynch.cursors import Cursor +from asynch.errors import NotSupportedError + + +class TestConnectFactory: + """connect() must return an openable Connection.""" + + async def test_connect_returns_connection(self, config): + conn = asynch.connect(dsn=config.dsn) + assert isinstance(conn, Connection) + await conn.close() + + async def test_connection_opens_successfully(self, config): + conn = asynch.connect(dsn=config.dsn) + await conn.connect() + assert conn.opened + await conn.close() + + async def test_connect_with_kwargs(self, config): + conn = asynch.connect( + host=config.host, + port=config.port, + user=config.user, + password=config.password, + database=config.database, + ) + await conn.connect() + assert conn.opened + await conn.close() + + +class TestConnectionClose: + """close() must immediately close the connection.""" + + async def test_close_works(self, pep249_conn): + await pep249_conn.close() + assert pep249_conn.closed + + async def test_close_is_idempotent(self, pep249_conn): + await pep249_conn.close() + await pep249_conn.close() # second close must not raise + + +class TestConnectionCommit: + """ + PEP 249: commit() is required. + For ClickHouse (auto-commit), commit() must succeed silently — not raise. + """ + + async def test_commit_does_not_raise(self, pep249_conn): + await pep249_conn.commit() # must not raise + + async def test_commit_returns_none(self, pep249_conn): + result = await pep249_conn.commit() + assert result is None + + +class TestConnectionRollback: + """ + PEP 249: rollback() is optional and may raise NotSupportedError for + databases without transaction support. Both no-op and NotSupportedError + are valid per spec; we accept either. + """ + + async def test_rollback_acceptable_behaviour(self, pep249_conn): + """rollback() must either succeed silently or raise NotSupportedError.""" + try: + await pep249_conn.rollback() + except NotSupportedError: + pass # valid per PEP 249 + except Exception as exc: + pytest.fail(f"rollback() raised an unexpected exception: {type(exc).__name__}: {exc}") + + +class TestConnectionCursor: + """cursor() must return a usable Cursor.""" + + def test_cursor_returns_cursor(self, pep249_conn): + cursor = pep249_conn.cursor() + assert isinstance(cursor, Cursor) + + async def test_cursor_is_usable(self, pep249_conn): + async with pep249_conn.cursor() as cursor: + await cursor.execute("SELECT 1") + result = await cursor.fetchone() + assert result == (1,) + + def test_multiple_cursors(self, pep249_conn): + c1 = pep249_conn.cursor() + c2 = pep249_conn.cursor() + assert c1 is not c2 + + +class TestConnectionContextManager: + """Connection must support async context manager protocol.""" + + async def test_aenter_returns_connection(self, config): + async with Connection(dsn=config.dsn) as conn: + assert isinstance(conn, Connection) + assert conn.opened + + async def test_aexit_closes_connection(self, config): + async with Connection(dsn=config.dsn) as conn: + pass + assert conn.closed + + async def test_aexit_on_exception(self, config): + with pytest.raises(ValueError): + async with Connection(dsn=config.dsn) as conn: + raise ValueError("test error") + assert conn.closed diff --git a/tests/pep249/test_cursor_description.py b/tests/pep249/test_cursor_description.py new file mode 100644 index 00000000..43307156 --- /dev/null +++ b/tests/pep249/test_cursor_description.py @@ -0,0 +1,175 @@ +""" +PEP 249 — cursor.description compliance tests. + +Covers: +- description is None before execute() +- description is None after non-row-returning operations (INSERT, DDL) +- description is a sequence of 7-item sequences after SELECT +- Each item: (name, type_code, display_size, internal_size, precision, scale, null_ok) +- name must be a string (mandatory) +- type_code must be one of the PEP 249 type objects (mandatory) +- Remaining fields may be None +- type_code correctly maps ClickHouse types to PEP 249 type objects +""" + +import datetime + +import pytest + +import asynch +from asynch.cursors import Column + +PEP249_TABLE = "test.pep249" + + +class TestDescriptionBeforeExecute: + def test_none_before_execute(self, pep249_conn): + cursor = pep249_conn.cursor() + assert cursor.description is None, "description must be None before execute()" + + +class TestDescriptionAfterNonSelect: + async def test_none_after_insert(self, pep249_cursor): + await pep249_cursor.execute( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, flag) VALUES", + [(99, "x", 0.0, datetime.date(2024, 1, 1), True)], + ) + assert pep249_cursor.description is None, ( + "description must be None after INSERT (no result set returned)" + ) + + async def test_none_after_ddl(self, pep249_cursor): + await pep249_cursor.execute( + "CREATE TABLE IF NOT EXISTS test.pep249_desc_ddl_test " + "(id Int32) ENGINE = MergeTree ORDER BY id" + ) + assert pep249_cursor.description is None, ( + "description must be None after DDL (no result set returned)" + ) + await pep249_cursor.execute("DROP TABLE IF EXISTS test.pep249_desc_ddl_test") + + +class TestDescriptionStructure: + """After a SELECT, description must be a sequence of 7-item sequences.""" + + async def test_description_is_sequence(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS n") + assert pep249_cursor.description is not None + assert hasattr(pep249_cursor.description, "__iter__"), "description must be iterable" + + async def test_description_has_one_item_per_column(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS a, 2 AS b, 3 AS c") + assert len(pep249_cursor.description) == 3 + + async def test_each_item_is_seven_elements(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS n") + for item in pep249_cursor.description: + assert len(item) == 7, f"Each description item must have 7 elements; got {len(item)}" + + async def test_item_is_indexable(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS n") + item = pep249_cursor.description[0] + _ = item[0] # name + _ = item[1] # type_code + + async def test_description_unpacks_as_seven_tuple(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS n") + name, type_code, display_size, internal_size, precision, scale, null_ok = ( + pep249_cursor.description[0] + ) + + +class TestDescriptionName: + """The first element (name) must be a string.""" + + async def test_name_is_string(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS my_column") + name = pep249_cursor.description[0][0] + assert isinstance(name, str), f"description name must be str; got {type(name)}" + + async def test_name_matches_column_alias(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS my_alias") + name = pep249_cursor.description[0][0] + assert name == "my_alias" + + async def test_multiple_column_names(self, pep249_cursor, populated_table): + await pep249_cursor.execute( + f"SELECT id, name, value, created, flag FROM {PEP249_TABLE} LIMIT 1" + ) + names = [item[0] for item in pep249_cursor.description] + assert names == ["id", "name", "value", "created", "flag"] + + +class TestDescriptionTypeCode: + """The second element (type_code) must be a PEP 249 type singleton.""" + + async def test_type_code_is_not_none(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS n") + type_code = pep249_cursor.description[0][1] + assert type_code is not None, "type_code must not be None" + + async def test_type_code_is_not_raw_string(self, pep249_cursor): + """type_code must be a PEP 249 type object, not a raw ClickHouse type string.""" + await pep249_cursor.execute("SELECT 1 AS n") + type_code = pep249_cursor.description[0][1] + assert not isinstance(type_code, str), ( + f"type_code must be a PEP 249 type object, not a string; got {type_code!r}" + ) + + async def test_type_code_equals_number_for_int(self, pep249_cursor): + await pep249_cursor.execute("SELECT toInt32(1) AS n") + type_code = pep249_cursor.description[0][1] + assert type_code == asynch.NUMBER, ( + f"Int32 column type_code must equal asynch.NUMBER; got {type_code!r}" + ) + + async def test_type_code_equals_string_for_string(self, pep249_cursor): + await pep249_cursor.execute("SELECT 'hello' AS s") + type_code = pep249_cursor.description[0][1] + assert type_code == asynch.STRING, ( + f"String column type_code must equal asynch.STRING; got {type_code!r}" + ) + + async def test_type_code_equals_datetime_for_date(self, pep249_cursor, populated_table): + await pep249_cursor.execute(f"SELECT created FROM {PEP249_TABLE} LIMIT 1") + type_code = pep249_cursor.description[0][1] + assert type_code == asynch.DATETIME, ( + f"Date column type_code must equal asynch.DATETIME; got {type_code!r}" + ) + + async def test_type_code_equals_number_for_float(self, pep249_cursor, populated_table): + await pep249_cursor.execute(f"SELECT value FROM {PEP249_TABLE} LIMIT 1") + type_code = pep249_cursor.description[0][1] + assert type_code == asynch.NUMBER, ( + f"Float64 column type_code must equal asynch.NUMBER; got {type_code!r}" + ) + + async def test_type_code_in_table_columns(self, pep249_cursor, populated_table): + """All columns from the pep249 table must have valid PEP 249 type codes.""" + await pep249_cursor.execute( + f"SELECT id, name, value, created, flag FROM {PEP249_TABLE} LIMIT 1" + ) + valid_types = {asynch.STRING, asynch.NUMBER, asynch.DATETIME, asynch.BINARY, asynch.ROWID} + for item in pep249_cursor.description: + type_code = item[1] + is_valid = any(type_code == t for t in valid_types) + assert is_valid, ( + f"Column '{item[0]}' has type_code {type_code!r} which is not a " + f"recognised PEP 249 type object" + ) + + +class TestDescriptionOptionalFields: + """Fields 3–7 (display_size through null_ok) may be None per spec.""" + + async def test_optional_fields_are_none_or_value(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1 AS n") + _, _, display_size, internal_size, precision, scale, null_ok = pep249_cursor.description[0] + # Each field is either None or a valid value — no hard type requirement + for field in (display_size, internal_size, precision, scale): + assert field is None or isinstance(field, int), ( + f"Optional numeric description field must be None or int; got {field!r}" + ) + assert null_ok is None or isinstance(null_ok, bool), ( + f"null_ok must be None or bool; got {null_ok!r}" + ) diff --git a/tests/pep249/test_cursor_execute.py b/tests/pep249/test_cursor_execute.py new file mode 100644 index 00000000..f5ed1216 --- /dev/null +++ b/tests/pep249/test_cursor_execute.py @@ -0,0 +1,146 @@ +""" +PEP 249 — Cursor execute / executemany compliance tests. + +Covers: +- execute() works for SELECT, INSERT, DDL +- execute() updates rowcount +- execute() with parameters +- executemany() inserts multiple rows +- executemany() updates rowcount +- Calling fetch before execute raises ProgrammingError +- execute() on a closed cursor raises InterfaceError +""" + +import datetime + +import pytest + +from asynch.errors import InterfaceError, ProgrammingError + +PEP249_TABLE = "test.pep249" + + +class TestExecuteSelect: + async def test_simple_select(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1") + row = await pep249_cursor.fetchone() + assert row is not None + assert row[0] == 1 + + async def test_select_multiple_columns(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1, 'hello', 3.14") + row = await pep249_cursor.fetchone() + assert row is not None + assert len(row) == 3 + + async def test_select_sets_rowcount(self, pep249_conn): + async with pep249_conn.cursor() as cursor: + await cursor.execute(f"SELECT * FROM {PEP249_TABLE}") + # Empty table: rowcount should be 0 or -1 (both valid per spec for SELECT) + assert cursor.rowcount in (-1, 0), ( + f"rowcount after SELECT on empty table must be 0 or -1; got {cursor.rowcount}" + ) + + async def test_select_rowcount_with_rows(self, pep249_cursor, populated_table): + await pep249_cursor.execute(f"SELECT * FROM {PEP249_TABLE}") + # After fetching, rowcount should reflect the number of rows returned + assert pep249_cursor.rowcount >= 0 or pep249_cursor.rowcount == -1 + + +class TestExecuteInsert: + async def test_insert_single_row(self, pep249_cursor): + await pep249_cursor.execute( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, flag) VALUES", + [(1, "test", 1.0, datetime.date(2024, 1, 1), True)], + ) + # Verify the row was inserted + await pep249_cursor.execute(f"SELECT id FROM {PEP249_TABLE} WHERE id = 1") + row = await pep249_cursor.fetchone() + assert row is not None + assert row[0] == 1 + + async def test_insert_rowcount(self, pep249_cursor): + await pep249_cursor.execute( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, flag) VALUES", + [(10, "x", 0.0, datetime.date(2024, 1, 1), False)], + ) + # rowcount after INSERT should be >= 0 or -1 (both valid per spec) + assert pep249_cursor.rowcount >= 0 or pep249_cursor.rowcount == -1 + + +class TestExecuteMany: + async def test_executemany_inserts_rows(self, pep249_cursor): + rows = [ + (1, "Alice", 1.0, datetime.date(2024, 1, 1), True), + (2, "Bob", 2.0, datetime.date(2024, 1, 2), False), + (3, "Charlie", 3.0, datetime.date(2024, 1, 3), True), + ] + await pep249_cursor.executemany( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, flag) VALUES", + rows, + ) + await pep249_cursor.execute(f"SELECT count() FROM {PEP249_TABLE}") + count_row = await pep249_cursor.fetchone() + assert count_row[0] == 3 + + async def test_executemany_empty_sequence(self, pep249_cursor): + """executemany with an empty sequence must not raise.""" + await pep249_cursor.executemany( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, flag) VALUES", + [], + ) + + async def test_executemany_rowcount(self, pep249_cursor): + rows = [(i, f"row{i}", float(i), datetime.date(2024, 1, 1), True) for i in range(1, 4)] + await pep249_cursor.executemany( + f"INSERT INTO {PEP249_TABLE} (id, name, value, created, flag) VALUES", + rows, + ) + # rowcount after executemany should be >= 0 or -1 + assert pep249_cursor.rowcount >= 0 or pep249_cursor.rowcount == -1 + + +class TestExecuteErrors: + async def test_fetch_before_execute_raises(self, pep249_conn): + """Fetching before execute must raise ProgrammingError.""" + cursor = pep249_conn.cursor() + with pytest.raises(ProgrammingError): + await cursor.fetchone() + + async def test_fetchmany_before_execute_raises(self, pep249_conn): + cursor = pep249_conn.cursor() + with pytest.raises(ProgrammingError): + await cursor.fetchmany(1) + + async def test_fetchall_before_execute_raises(self, pep249_conn): + cursor = pep249_conn.cursor() + with pytest.raises(ProgrammingError): + await cursor.fetchall() + + async def test_execute_on_closed_cursor_raises(self, pep249_conn): + """Executing on a closed cursor must raise InterfaceError.""" + cursor = pep249_conn.cursor() + await cursor.close() + with pytest.raises(InterfaceError): + await cursor.execute("SELECT 1") + + +class TestExecuteDDL: + async def test_ddl_does_not_raise(self, pep249_cursor): + """DDL statements (CREATE, DROP) must execute without error.""" + await pep249_cursor.execute( + "CREATE TABLE IF NOT EXISTS test.pep249_ddl_test " + "(id Int32) ENGINE = MergeTree ORDER BY id" + ) + await pep249_cursor.execute("DROP TABLE IF EXISTS test.pep249_ddl_test") + + async def test_ddl_rowcount(self, pep249_cursor): + """rowcount after DDL must be -1 or 0 — not a meaningful value.""" + await pep249_cursor.execute( + "CREATE TABLE IF NOT EXISTS test.pep249_ddl_rowcount_test " + "(id Int32) ENGINE = MergeTree ORDER BY id" + ) + assert pep249_cursor.rowcount in (-1, 0), ( + f"rowcount after DDL must be -1 or 0; got {pep249_cursor.rowcount}" + ) + await pep249_cursor.execute("DROP TABLE IF EXISTS test.pep249_ddl_rowcount_test") diff --git a/tests/pep249/test_cursor_fetch.py b/tests/pep249/test_cursor_fetch.py new file mode 100644 index 00000000..c85bf1a3 --- /dev/null +++ b/tests/pep249/test_cursor_fetch.py @@ -0,0 +1,155 @@ +""" +PEP 249 — Cursor fetch method compliance tests. + +Covers: +- fetchone() returns a single row as a sequence (tuple) +- fetchone() returns None when result is exhausted +- fetchmany() returns a sequence of rows +- fetchmany() uses arraysize when no size is given +- fetchmany() returns empty list when exhausted +- fetchall() returns all remaining rows +- fetchall() returns empty list when exhausted +- Rows are sequences (indexable, iterable) +""" + +import datetime + +import pytest + +PEP249_TABLE = "test.pep249" + + +@pytest.fixture +async def three_rows(pep249_cursor, populated_table): + """Execute a SELECT that returns the three pre-inserted rows, yield cursor.""" + await pep249_cursor.execute(f"SELECT id, name, value FROM {PEP249_TABLE} ORDER BY id") + yield pep249_cursor + + +class TestFetchone: + async def test_returns_tuple_or_sequence(self, three_rows): + row = await three_rows.fetchone() + assert row is not None + assert hasattr(row, "__getitem__"), "fetchone() must return an indexable sequence" + + async def test_first_row_correct(self, three_rows): + row = await three_rows.fetchone() + assert row[0] == 1 # id + + async def test_successive_calls_advance_position(self, three_rows): + row1 = await three_rows.fetchone() + row2 = await three_rows.fetchone() + assert row1[0] != row2[0], "successive fetchone() calls must advance position" + + async def test_returns_none_when_exhausted(self, three_rows): + await three_rows.fetchone() + await three_rows.fetchone() + await three_rows.fetchone() + result = await three_rows.fetchone() + assert result is None, "fetchone() must return None when result set is exhausted" + + async def test_empty_result(self, pep249_cursor): + await pep249_cursor.execute(f"SELECT * FROM {PEP249_TABLE} WHERE id = -999") + result = await pep249_cursor.fetchone() + assert result is None, "fetchone() must return None for empty result set" + + +class TestFetchmany: + async def test_returns_list(self, three_rows): + rows = await three_rows.fetchmany(2) + assert isinstance(rows, list), "fetchmany() must return a list" + + async def test_returns_up_to_size_rows(self, three_rows): + rows = await three_rows.fetchmany(2) + assert len(rows) == 2 + + async def test_returns_remaining_if_fewer_than_size(self, three_rows): + rows = await three_rows.fetchmany(10) + assert len(rows) == 3, "fetchmany() must return all remaining rows if fewer than size" + + async def test_returns_empty_when_exhausted(self, three_rows): + await three_rows.fetchmany(3) # consume all + rows = await three_rows.fetchmany(1) + assert rows == [], "fetchmany() must return [] when result is exhausted" + + async def test_uses_arraysize_default(self, pep249_cursor, populated_table): + pep249_cursor.arraysize = 2 + await pep249_cursor.execute(f"SELECT id FROM {PEP249_TABLE} ORDER BY id") + rows = await pep249_cursor.fetchmany() # no explicit size + assert len(rows) == 2, "fetchmany() with no size arg must use arraysize" + + async def test_size_zero_returns_empty(self, three_rows): + rows = await three_rows.fetchmany(0) + assert rows == [], "fetchmany(0) must return []" + + async def test_each_row_is_sequence(self, three_rows): + rows = await three_rows.fetchmany(2) + for row in rows: + assert hasattr(row, "__getitem__"), "each row in fetchmany() must be indexable" + + +class TestFetchall: + async def test_returns_list(self, three_rows): + rows = await three_rows.fetchall() + assert isinstance(rows, list), "fetchall() must return a list" + + async def test_returns_all_rows(self, three_rows): + rows = await three_rows.fetchall() + assert len(rows) == 3 + + async def test_row_content(self, three_rows): + rows = await three_rows.fetchall() + ids = [row[0] for row in rows] + assert sorted(ids) == [1, 2, 3] + + async def test_returns_empty_when_exhausted(self, three_rows): + await three_rows.fetchall() + rows = await three_rows.fetchall() + assert rows == [], "fetchall() must return [] when result is already exhausted" + + async def test_empty_table(self, pep249_cursor): + await pep249_cursor.execute(f"SELECT * FROM {PEP249_TABLE}") + rows = await pep249_cursor.fetchall() + assert rows == [], "fetchall() must return [] for empty result set" + + +class TestFetchInterleaving: + """fetchone and fetchmany can be interleaved.""" + + async def test_fetchone_then_fetchall(self, three_rows): + first = await three_rows.fetchone() + rest = await three_rows.fetchall() + assert first is not None + assert len(rest) == 2 + + async def test_fetchmany_then_fetchone(self, three_rows): + two = await three_rows.fetchmany(2) + last = await three_rows.fetchone() + none = await three_rows.fetchone() + assert len(two) == 2 + assert last is not None + assert none is None + + +class TestRowFormat: + """Rows returned must be sequences of Python-native values.""" + + async def test_row_values_are_python_types(self, pep249_cursor, populated_table): + await pep249_cursor.execute( + f"SELECT id, name, value, created, flag FROM {PEP249_TABLE} WHERE id = 1" + ) + row = await pep249_cursor.fetchone() + assert row is not None + id_val, name_val, value_val, created_val, flag_val = row + assert isinstance(id_val, int) + assert isinstance(name_val, str) + assert isinstance(value_val, float) + assert isinstance(created_val, datetime.date) + assert isinstance(flag_val, bool) + + async def test_null_maps_to_none(self, pep249_cursor, populated_table): + """SQL NULL must map to Python None per PEP 249.""" + await pep249_cursor.execute(f"SELECT name FROM {PEP249_TABLE} WHERE id = 3") + row = await pep249_cursor.fetchone() + assert row is not None + assert row[0] is None, "SQL NULL must map to Python None" diff --git a/tests/pep249/test_cursor_interface.py b/tests/pep249/test_cursor_interface.py new file mode 100644 index 00000000..fbc4b987 --- /dev/null +++ b/tests/pep249/test_cursor_interface.py @@ -0,0 +1,218 @@ +""" +PEP 249 — Cursor interface compliance tests (attributes and method presence). + +Covers: +- description attribute exists and is readable +- rowcount attribute exists and is readable +- arraysize attribute exists as a public read/write property +- connection attribute returns the parent connection +- callproc method exists (may raise NotSupportedError) +- close method exists +- execute method exists +- executemany method exists +- fetchone method exists +- fetchmany method exists +- fetchall method exists +- nextset method exists (may return None) +- setinputsizes method exists +- setoutputsize (singular) method exists +- lastrowid attribute exists (SQLAlchemy optional extension) +""" + +import inspect + +import pytest + +from asynch.connection import Connection +from asynch.cursors import Cursor +from asynch.errors import InterfaceError, NotSupportedError + + +class TestCursorAttributePresence: + """Every required PEP 249 attribute must exist on the Cursor class.""" + + REQUIRED_ATTRS = [ + "description", + "rowcount", + "arraysize", + "connection", + ] + + @pytest.mark.parametrize("attr", REQUIRED_ATTRS) + def test_attribute_exists(self, pep249_conn, attr): + cursor = pep249_conn.cursor() + assert hasattr(cursor, attr), f"Cursor must have attribute '{attr}'" + + def test_lastrowid_exists(self, pep249_conn): + """lastrowid is a SQLAlchemy-required optional extension.""" + cursor = pep249_conn.cursor() + assert hasattr(cursor, "lastrowid"), ( + "Cursor must have 'lastrowid' attribute (required by SQLAlchemy)" + ) + + +class TestCursorMethodPresence: + """Every required PEP 249 method must exist and be callable on the Cursor class.""" + + REQUIRED_METHODS = [ + "close", + "execute", + "executemany", + "fetchone", + "fetchmany", + "fetchall", + "nextset", + "setinputsizes", + "setoutputsize", + "callproc", + ] + + @pytest.mark.parametrize("method", REQUIRED_METHODS) + def test_method_exists(self, pep249_conn, method): + cursor = pep249_conn.cursor() + assert hasattr(cursor, method), f"Cursor must have method '{method}'" + + @pytest.mark.parametrize("method", REQUIRED_METHODS) + def test_method_is_callable(self, pep249_conn, method): + cursor = pep249_conn.cursor() + assert callable(getattr(cursor, method)), f"cursor.{method} must be callable" + + def test_setoutputsize_singular(self, pep249_conn): + """PEP 249 specifies 'setoutputsize' (singular), not 'setoutputsizes'.""" + cursor = pep249_conn.cursor() + assert hasattr(cursor, "setoutputsize"), ( + "Cursor must have 'setoutputsize' (singular) per PEP 249" + ) + + +class TestCursorArraysize: + """arraysize must be a public read/write attribute defaulting to 1.""" + + def test_arraysize_default_is_one(self, pep249_conn): + cursor = pep249_conn.cursor() + assert cursor.arraysize == 1, "arraysize must default to 1 per PEP 249" + + def test_arraysize_is_writable(self, pep249_conn): + cursor = pep249_conn.cursor() + cursor.arraysize = 10 + assert cursor.arraysize == 10 + + def test_arraysize_set_to_various_values(self, pep249_conn): + cursor = pep249_conn.cursor() + for val in (1, 5, 100, 1000): + cursor.arraysize = val + assert cursor.arraysize == val + + +class TestCursorConnection: + """cursor.connection must return the parent Connection object.""" + + def test_connection_is_parent(self, pep249_conn): + cursor = pep249_conn.cursor() + assert cursor.connection is pep249_conn + + async def test_connection_accessible_after_execute(self, pep249_conn): + async with pep249_conn.cursor() as cursor: + await cursor.execute("SELECT 1") + assert cursor.connection is pep249_conn + + +class TestCursorRowcount: + """rowcount must be -1 before any execute.""" + + def test_rowcount_initial_value(self, pep249_conn): + cursor = pep249_conn.cursor() + assert cursor.rowcount == -1, "rowcount must be -1 before any execute() call" + + +class TestCursorDescriptionInitial: + """description must be None before any execute.""" + + def test_description_none_before_execute(self, pep249_conn): + cursor = pep249_conn.cursor() + assert cursor.description is None, "description must be None before any execute() call" + + +class TestCursorCallproc: + """callproc must exist; ClickHouse may raise NotSupportedError.""" + + async def test_callproc_raises_not_supported_or_works(self, pep249_cursor): + try: + await pep249_cursor.callproc("nonexistent_proc") + except NotSupportedError: + pass # valid — ClickHouse has no stored procedures + except Exception as exc: + pytest.fail(f"callproc raised an unexpected exception: {type(exc).__name__}: {exc}") + + +class TestCursorNextset: + """nextset must exist; for ClickHouse (single result set) it returns None.""" + + async def test_nextset_returns_none_or_does_not_raise(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1") + try: + result = await pep249_cursor.nextset() + # If supported: returns True (has more sets) or None (no more) + assert result is None or result is True, ( + f"nextset() must return None or True; got {result!r}" + ) + except NotSupportedError: + pass # valid per PEP 249 + + +class TestCursorSetinputsizes: + """setinputsizes is a no-op per spec; must not raise.""" + + async def test_setinputsizes_no_raise(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1") + pep249_cursor.setinputsizes([10, 20]) # must not raise + + def test_setinputsizes_empty(self, pep249_conn): + cursor = pep249_conn.cursor() + cursor.setinputsizes([]) # must not raise + + +class TestCursorSetoutputsize: + """setoutputsize (singular) is a no-op per spec; must not raise.""" + + def test_setoutputsize_single_arg(self, pep249_conn): + cursor = pep249_conn.cursor() + cursor.setoutputsize(1024) # must not raise + + def test_setoutputsize_with_column(self, pep249_conn): + cursor = pep249_conn.cursor() + cursor.setoutputsize(1024, 0) # must not raise + + +class TestCursorClose: + """close() must prevent further use of the cursor.""" + + async def test_close_prevents_execute(self, pep249_conn): + cursor = pep249_conn.cursor() + await cursor.close() + with pytest.raises((InterfaceError, Exception)): + await cursor.execute("SELECT 1") + + async def test_close_is_idempotent(self, pep249_conn): + cursor = pep249_conn.cursor() + await cursor.close() + await cursor.close() # second close must not raise + + +class TestCursorLastrowid: + """lastrowid is a SQLAlchemy-required optional extension.""" + + async def test_lastrowid_exists(self, pep249_cursor): + assert hasattr(pep249_cursor, "lastrowid"), ( + "cursor.lastrowid must exist for SQLAlchemy compatibility" + ) + + async def test_lastrowid_after_select(self, pep249_cursor): + await pep249_cursor.execute("SELECT 1") + # For ClickHouse, lastrowid is None (no auto-generated IDs) + # It must not raise AttributeError + _ = pep249_cursor.lastrowid + + async def test_lastrowid_before_execute(self, pep249_conn): + cursor = pep249_conn.cursor() + _ = cursor.lastrowid # must not raise diff --git a/tests/pep249/test_exceptions.py b/tests/pep249/test_exceptions.py new file mode 100644 index 00000000..978d84cf --- /dev/null +++ b/tests/pep249/test_exceptions.py @@ -0,0 +1,190 @@ +""" +PEP 249 — Exception hierarchy compliance tests. + +Covers: +- Correct inheritance chain for every standard exception +- Warning is a subclass of Exception (not Error) +- Error is a subclass of Exception +- InterfaceError is a subclass of Error +- DatabaseError is a subclass of Error +- DataError, OperationalError, IntegrityError, InternalError, + ProgrammingError, NotSupportedError are subclasses of DatabaseError +- Exceptions can be raised and caught at all hierarchy levels +- Exceptions carry meaningful messages +""" + +import pytest + +from asynch import errors + + +class TestWarning: + def test_warning_is_exception(self): + assert issubclass(errors.Warning, Exception), "Warning must subclass Exception" + + def test_warning_is_not_error(self): + assert not issubclass(errors.Warning, errors.Error), ( + "Warning must NOT subclass Error (they are siblings under Exception)" + ) + + def test_warning_is_raisable(self): + with pytest.raises(errors.Warning): + raise errors.Warning("truncation occurred") + + def test_warning_caught_as_exception(self): + with pytest.raises(Exception): + raise errors.Warning("test") + + +class TestError: + def test_error_is_exception(self): + assert issubclass(errors.Error, Exception), "Error must subclass Exception" + + def test_error_is_raisable(self): + with pytest.raises(errors.Error): + raise errors.Error("base error") + + def test_error_caught_as_exception(self): + with pytest.raises(Exception): + raise errors.Error("test") + + +class TestInterfaceError: + def test_is_error(self): + assert issubclass(errors.InterfaceError, errors.Error), "InterfaceError must subclass Error" + + def test_is_not_database_error(self): + """InterfaceError and DatabaseError are siblings, not parent-child.""" + # InterfaceError should NOT be a subclass of DatabaseError + assert not issubclass(errors.InterfaceError, errors.DatabaseError), ( + "InterfaceError must NOT subclass DatabaseError; " + "they are separate children of Error per PEP 249" + ) + + def test_is_raisable(self): + with pytest.raises(errors.InterfaceError): + raise errors.InterfaceError("cursor is closed") + + def test_caught_as_error(self): + with pytest.raises(errors.Error): + raise errors.InterfaceError("test") + + +class TestDatabaseError: + def test_is_error(self): + assert issubclass(errors.DatabaseError, errors.Error), "DatabaseError must subclass Error" + + def test_is_raisable(self): + with pytest.raises(errors.DatabaseError): + raise errors.DatabaseError("db error") + + def test_caught_as_error(self): + with pytest.raises(errors.Error): + raise errors.DatabaseError("test") + + +class TestDataError: + def test_is_database_error(self): + assert issubclass(errors.DataError, errors.DatabaseError) + + def test_is_error(self): + assert issubclass(errors.DataError, errors.Error) + + def test_is_raisable(self): + with pytest.raises(errors.DataError): + raise errors.DataError("value out of range") + + def test_caught_as_database_error(self): + with pytest.raises(errors.DatabaseError): + raise errors.DataError("test") + + +class TestOperationalError: + def test_is_database_error(self): + assert issubclass(errors.OperationalError, errors.DatabaseError) + + def test_is_error(self): + assert issubclass(errors.OperationalError, errors.Error) + + def test_is_raisable(self): + with pytest.raises(errors.OperationalError): + raise errors.OperationalError("connection lost") + + def test_caught_as_database_error(self): + with pytest.raises(errors.DatabaseError): + raise errors.OperationalError("test") + + +class TestIntegrityError: + def test_is_database_error(self): + assert issubclass(errors.IntegrityError, errors.DatabaseError) + + def test_is_error(self): + assert issubclass(errors.IntegrityError, errors.Error) + + def test_is_raisable(self): + with pytest.raises(errors.IntegrityError): + raise errors.IntegrityError("FK violation") + + +class TestInternalError: + def test_is_database_error(self): + assert issubclass(errors.InternalError, errors.DatabaseError) + + def test_is_error(self): + assert issubclass(errors.InternalError, errors.Error) + + def test_is_raisable(self): + with pytest.raises(errors.InternalError): + raise errors.InternalError("cursor invalid") + + +class TestProgrammingError: + def test_is_database_error(self): + assert issubclass(errors.ProgrammingError, errors.DatabaseError) + + def test_is_error(self): + assert issubclass(errors.ProgrammingError, errors.Error) + + def test_is_raisable(self): + with pytest.raises(errors.ProgrammingError): + raise errors.ProgrammingError("syntax error") + + +class TestNotSupportedError: + def test_is_database_error(self): + assert issubclass(errors.NotSupportedError, errors.DatabaseError) + + def test_is_error(self): + assert issubclass(errors.NotSupportedError, errors.Error) + + def test_is_raisable(self): + with pytest.raises(errors.NotSupportedError): + raise errors.NotSupportedError("not supported") + + +class TestExceptionMessageHandling: + """Exceptions must be able to carry meaningful error messages.""" + + def test_error_preserves_message(self): + msg = "something went wrong" + exc = errors.Error(msg) + assert str(exc) or repr(exc) # must produce some string representation + + def test_programming_error_preserves_message(self): + msg = "table not found" + exc = errors.ProgrammingError(msg) + assert str(exc) or repr(exc) + + def test_operational_error_caught_at_multiple_levels(self): + with pytest.raises(errors.OperationalError): + raise errors.OperationalError("disconnect") + + with pytest.raises(errors.DatabaseError): + raise errors.OperationalError("disconnect") + + with pytest.raises(errors.Error): + raise errors.OperationalError("disconnect") + + with pytest.raises(Exception): + raise errors.OperationalError("disconnect") diff --git a/tests/pep249/test_module_globals.py b/tests/pep249/test_module_globals.py new file mode 100644 index 00000000..7d708699 --- /dev/null +++ b/tests/pep249/test_module_globals.py @@ -0,0 +1,179 @@ +""" +PEP 249 §1 — Module-level interface compliance tests. + +Covers: +- apilevel +- threadsafety +- paramstyle +- connect() factory function +- Exception classes exported at module level +- Type objects exported at module level +""" + +import inspect + +import pytest + +import asynch + + +class TestApiLevel: + """PEP 249 requires the module to declare its DB-API compliance level.""" + + def test_apilevel_exists(self): + assert hasattr(asynch, "apilevel"), "asynch must define 'apilevel'" + + def test_apilevel_is_string(self): + assert isinstance(asynch.apilevel, str), "'apilevel' must be a string" + + def test_apilevel_value(self): + assert asynch.apilevel == "2.0", "apilevel must be '2.0' for DB-API v2.0" + + +class TestThreadSafety: + """PEP 249 requires the module to declare its thread-safety level.""" + + def test_threadsafety_exists(self): + assert hasattr(asynch, "threadsafety"), "asynch must define 'threadsafety'" + + def test_threadsafety_is_int(self): + assert isinstance(asynch.threadsafety, int), "'threadsafety' must be an int" + + def test_threadsafety_valid_range(self): + assert asynch.threadsafety in (0, 1, 2, 3), ( + f"'threadsafety' must be 0, 1, 2, or 3; got {asynch.threadsafety!r}" + ) + + +class TestParamStyle: + """PEP 249 requires the module to declare its parameter style.""" + + VALID_STYLES = {"qmark", "numeric", "named", "format", "pyformat"} + + def test_paramstyle_exists(self): + assert hasattr(asynch, "paramstyle"), "asynch must define 'paramstyle'" + + def test_paramstyle_is_string(self): + assert isinstance(asynch.paramstyle, str), "'paramstyle' must be a string" + + def test_paramstyle_valid_value(self): + assert asynch.paramstyle in self.VALID_STYLES, ( + f"'paramstyle' must be one of {self.VALID_STYLES}; got {asynch.paramstyle!r}" + ) + + +class TestConnectFactory: + """PEP 249 requires a module-level connect() constructor.""" + + def test_connect_exists(self): + assert hasattr(asynch, "connect"), "asynch must define a 'connect' function" + + def test_connect_is_callable(self): + assert callable(asynch.connect), "'connect' must be callable" + + def test_connect_returns_connection(self): + from asynch.connection import Connection + + conn = asynch.connect() + assert isinstance(conn, Connection), "asynch.connect() must return a Connection object" + + def test_connect_accepts_dsn(self, config): + from asynch.connection import Connection + + conn = asynch.connect(dsn=config.dsn) + assert isinstance(conn, Connection) + + def test_connect_accepts_kwargs(self, config): + from asynch.connection import Connection + + conn = asynch.connect( + host=config.host, + port=config.port, + user=config.user, + password=config.password, + database=config.database, + ) + assert isinstance(conn, Connection) + + +class TestModuleLevelExceptions: + """PEP 249 requires all standard exceptions to be accessible at module level.""" + + REQUIRED_EXCEPTIONS = [ + "Warning", + "Error", + "InterfaceError", + "DatabaseError", + "DataError", + "OperationalError", + "IntegrityError", + "InternalError", + "ProgrammingError", + "NotSupportedError", + ] + + @pytest.mark.parametrize("exc_name", REQUIRED_EXCEPTIONS) + def test_exception_exported(self, exc_name): + assert hasattr(asynch, exc_name), f"asynch must export '{exc_name}' at module level" + + @pytest.mark.parametrize("exc_name", REQUIRED_EXCEPTIONS) + def test_exception_is_class(self, exc_name): + exc = getattr(asynch, exc_name) + assert inspect.isclass(exc), f"asynch.{exc_name} must be a class" + + @pytest.mark.parametrize("exc_name", REQUIRED_EXCEPTIONS) + def test_exception_is_raisable(self, exc_name): + exc_class = getattr(asynch, exc_name) + with pytest.raises(exc_class): + raise exc_class("test message") + + +class TestModuleLevelTypeObjects: + """PEP 249 requires type singleton objects at module level.""" + + REQUIRED_TYPE_OBJECTS = ["STRING", "BINARY", "NUMBER", "DATETIME", "ROWID"] + + @pytest.mark.parametrize("name", REQUIRED_TYPE_OBJECTS) + def test_type_object_exported(self, name): + assert hasattr(asynch, name), f"asynch must export type object '{name}'" + + @pytest.mark.parametrize("name", REQUIRED_TYPE_OBJECTS) + def test_type_object_supports_equality(self, name): + obj = getattr(asynch, name) + # Must support == comparison without raising + result = obj == obj + assert result is True, f"asynch.{name} == asynch.{name} must be True" + + def test_type_objects_are_distinct(self): + """Different type objects should not compare equal to each other.""" + types = [getattr(asynch, n) for n in self.REQUIRED_TYPE_OBJECTS] + for i, a in enumerate(types): + for j, b in enumerate(types): + if i != j: + # They don't need to be unequal if the spec allows overlap, + # but in practice they should be for most ClickHouse types. + # This is a soft check — just verify the comparison doesn't raise. + _ = a == b + + +class TestModuleLevelTypeConstructors: + """PEP 249 requires type constructor functions at module level.""" + + REQUIRED_CONSTRUCTORS = [ + "Date", + "Time", + "Timestamp", + "DateFromTicks", + "TimeFromTicks", + "TimestampFromTicks", + "Binary", + ] + + @pytest.mark.parametrize("name", REQUIRED_CONSTRUCTORS) + def test_constructor_exported(self, name): + assert hasattr(asynch, name), f"asynch must export type constructor '{name}'" + + @pytest.mark.parametrize("name", REQUIRED_CONSTRUCTORS) + def test_constructor_is_callable(self, name): + ctor = getattr(asynch, name) + assert callable(ctor), f"asynch.{name} must be callable" diff --git a/tests/pep249/test_type_objects.py b/tests/pep249/test_type_objects.py new file mode 100644 index 00000000..9e4e58be --- /dev/null +++ b/tests/pep249/test_type_objects.py @@ -0,0 +1,163 @@ +""" +PEP 249 — Type objects and constructor compliance tests. + +Covers: +- Date, Time, Timestamp constructors return correct Python types +- DateFromTicks, TimeFromTicks, TimestampFromTicks work from Unix timestamps +- Binary wraps bytes/strings into a binary-safe object +- STRING, BINARY, NUMBER, DATETIME, ROWID singletons support == comparison +- Type singletons map correctly to ClickHouse type name strings +- Type singletons are exported from asynch module +""" + +import datetime + +import pytest + +import asynch + + +class TestDateConstructor: + def test_returns_date(self): + result = asynch.Date(2024, 1, 15) + assert isinstance(result, datetime.date) + + def test_correct_value(self): + result = asynch.Date(2024, 6, 30) + assert result.year == 2024 + assert result.month == 6 + assert result.day == 30 + + +class TestTimeConstructor: + def test_returns_time(self): + result = asynch.Time(14, 30, 45) + assert isinstance(result, datetime.time) + + def test_correct_value(self): + result = asynch.Time(14, 30, 45) + assert result.hour == 14 + assert result.minute == 30 + assert result.second == 45 + + +class TestTimestampConstructor: + def test_returns_datetime(self): + result = asynch.Timestamp(2024, 1, 15, 14, 30, 45) + assert isinstance(result, datetime.datetime) + + def test_correct_value(self): + result = asynch.Timestamp(2024, 6, 30, 12, 0, 0) + assert result.year == 2024 + assert result.month == 6 + assert result.day == 30 + assert result.hour == 12 + assert result.minute == 0 + assert result.second == 0 + + +class TestTicksConstructors: + # Use a fixed Unix timestamp for deterministic tests + TICKS = 1_704_067_200 # 2024-01-01 00:00:00 UTC + + def test_date_from_ticks_returns_date(self): + result = asynch.DateFromTicks(self.TICKS) + assert isinstance(result, datetime.date) + + def test_time_from_ticks_returns_time(self): + result = asynch.TimeFromTicks(self.TICKS) + assert isinstance(result, datetime.time) + + def test_timestamp_from_ticks_returns_datetime(self): + result = asynch.TimestampFromTicks(self.TICKS) + assert isinstance(result, datetime.datetime) + + def test_ticks_consistency(self): + """DateFromTicks and TimestampFromTicks should agree on the date part.""" + d = asynch.DateFromTicks(self.TICKS) + ts = asynch.TimestampFromTicks(self.TICKS) + assert d == ts.date() + + +class TestBinaryConstructor: + def test_bytes_input(self): + result = asynch.Binary(b"hello") + assert isinstance(result, (bytes, bytearray, memoryview)) + + def test_string_input(self): + result = asynch.Binary("hello") + # Must return a bytes-like object + assert isinstance(result, (bytes, bytearray, memoryview)) + + def test_empty_binary(self): + result = asynch.Binary(b"") + assert len(result) == 0 + + +class TestTypeObjectEquality: + """Type singletons must support == comparison.""" + + def test_string_equals_itself(self): + assert asynch.STRING == asynch.STRING + + def test_number_equals_itself(self): + assert asynch.NUMBER == asynch.NUMBER + + def test_datetime_equals_itself(self): + assert asynch.DATETIME == asynch.DATETIME + + def test_binary_equals_itself(self): + assert asynch.BINARY == asynch.BINARY + + def test_rowid_equals_itself(self): + assert asynch.ROWID == asynch.ROWID + + +class TestTypeObjectClickHouseMapping: + """Type singletons should compare equal to the ClickHouse type name strings they cover.""" + + # STRING + @pytest.mark.parametrize("ch_type", ["String", "FixedString", "UUID", "IPv4", "IPv6"]) + def test_string_covers_type(self, ch_type): + assert asynch.STRING == ch_type, ( + f"asynch.STRING should equal '{ch_type}' (ClickHouse string-like type)" + ) + + # NUMBER + @pytest.mark.parametrize( + "ch_type", + [ + "Int8", + "Int16", + "Int32", + "Int64", + "UInt8", + "UInt16", + "UInt32", + "UInt64", + "Float32", + "Float64", + "Decimal", + ], + ) + def test_number_covers_type(self, ch_type): + assert asynch.NUMBER == ch_type, ( + f"asynch.NUMBER should equal '{ch_type}' (ClickHouse numeric type)" + ) + + # DATETIME + @pytest.mark.parametrize("ch_type", ["Date", "Date32", "DateTime", "DateTime64"]) + def test_datetime_covers_type(self, ch_type): + assert asynch.DATETIME == ch_type, ( + f"asynch.DATETIME should equal '{ch_type}' (ClickHouse date/time type)" + ) + + def test_string_does_not_equal_number_types(self): + assert not (asynch.STRING == "Int32"), ( + "asynch.STRING should not equal a numeric ClickHouse type" + ) + + def test_number_does_not_equal_string_types(self): + assert not (asynch.NUMBER == "String"), ( + "asynch.NUMBER should not equal a string ClickHouse type" + ) diff --git a/tests/sqlalchemy/__init__.py b/tests/sqlalchemy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/sqlalchemy/conftest.py b/tests/sqlalchemy/conftest.py new file mode 100644 index 00000000..7cc42145 --- /dev/null +++ b/tests/sqlalchemy/conftest.py @@ -0,0 +1,119 @@ +""" +Fixtures for SQLAlchemy compatibility tests. + +These tests verify that asynch exposes the interface SQLAlchemy requires. +Some tests use SQLAlchemy directly; those are skipped if sqlalchemy is not installed. +Tests requiring a full ClickHouse-SQLAlchemy dialect (clickhouse-sqlalchemy) +are skipped if that package is not installed. + +Connection settings are inherited from the root conftest via the `config` fixture. +""" + +import pytest + +# --------------------------------------------------------------------------- +# Optional-import guards +# --------------------------------------------------------------------------- + +sqlalchemy = pytest.importorskip( + "sqlalchemy", + reason="sqlalchemy is not installed — skipping SQLAlchemy tests", +) + +# --------------------------------------------------------------------------- +# Table setup +# --------------------------------------------------------------------------- + +SA_TABLE = "test.sa_compat" +SA_DDL = f""" + CREATE TABLE IF NOT EXISTS {SA_TABLE} + ( + id Int32, + name Nullable(String), + score Float64, + created Date + ) + ENGINE = MergeTree + ORDER BY id +""" + + +@pytest.fixture(scope="session", autouse=True) +async def sa_table(config): + """Create the SQLAlchemy compatibility test table once per session.""" + from asynch.connection import Connection + + async with Connection(dsn=config.dsn) as conn: + async with conn.cursor() as cursor: + await cursor.execute("CREATE DATABASE IF NOT EXISTS test") + await cursor.execute(f"DROP TABLE IF EXISTS {SA_TABLE}") + await cursor.execute(SA_DDL) + yield + async with Connection(dsn=config.dsn) as conn: + async with conn.cursor() as cursor: + await cursor.execute(f"DROP TABLE IF EXISTS {SA_TABLE}") + + +@pytest.fixture(autouse=True) +async def truncate_sa(config): + """Truncate the SA compat table before each test.""" + from asynch.connection import Connection + + async with Connection(dsn=config.dsn) as conn: + async with conn.cursor() as cursor: + await cursor.execute(f"TRUNCATE TABLE {SA_TABLE}") + yield + + +@pytest.fixture +async def sa_conn(config): + """Raw asynch connection for tests that work at DB-API level.""" + from asynch.connection import Connection + + async with Connection(dsn=config.dsn) as conn: + yield conn + + +@pytest.fixture +async def sa_cursor(sa_conn): + """Raw asynch cursor.""" + async with sa_conn.cursor() as cursor: + yield cursor + + +# --------------------------------------------------------------------------- +# SQLAlchemy async engine fixture (requires clickhouse-sqlalchemy) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def ch_sa_dialect(): + """Import marker: skip if clickhouse-sqlalchemy is not installed.""" + try: + import clickhouse_sqlalchemy # noqa: F401 + + return clickhouse_sqlalchemy + except ImportError: + pytest.skip("clickhouse-sqlalchemy is not installed") + + +@pytest.fixture(scope="session") +def async_engine(config, ch_sa_dialect): + """Create a SQLAlchemy async engine backed by asynch.""" + from sqlalchemy.ext.asyncio import create_async_engine + + url = ( + f"clickhouse+asynch://{config.user}:{config.password}" + f"@{config.host}:{config.port}/{config.database}" + ) + engine = create_async_engine(url, echo=False) + yield engine + + +@pytest.fixture +async def async_session(async_engine): + """Provide an AsyncSession for ORM tests.""" + from sqlalchemy.ext.asyncio import AsyncSession + + async with AsyncSession(async_engine, expire_on_commit=False) as session: + yield session diff --git a/tests/sqlalchemy/test_core.py b/tests/sqlalchemy/test_core.py new file mode 100644 index 00000000..cf39cb24 --- /dev/null +++ b/tests/sqlalchemy/test_core.py @@ -0,0 +1,183 @@ +""" +SQLAlchemy Core compatibility tests. + +These tests require both sqlalchemy and clickhouse-sqlalchemy to be installed. +If clickhouse-sqlalchemy is not available, all tests in this module are skipped. + +The tests verify that asynch can be used as the underlying driver for +SQLAlchemy Core operations (DDL, DML, queries) via the clickhouse+asynch dialect. + +Install requirements: + pip install sqlalchemy clickhouse-sqlalchemy +""" + +import datetime + +import pytest + +# All tests in this module require clickhouse-sqlalchemy +pytestmark = pytest.mark.usefixtures("ch_sa_dialect") + +SA_TABLE = "test.sa_compat" + + +class TestEngineCreation: + def test_async_engine_created(self, async_engine): + assert async_engine is not None + + async def test_engine_connect(self, async_engine): + from sqlalchemy.ext.asyncio import AsyncConnection + + async with async_engine.connect() as conn: + assert isinstance(conn, AsyncConnection) + + +class TestCoreTextQueries: + """SQLAlchemy Core text() queries via asynch.""" + + async def test_select_constant(self, async_engine): + from sqlalchemy import text + + async with async_engine.connect() as conn: + result = await conn.execute(text("SELECT 1 AS n")) + row = result.fetchone() + assert row is not None + assert row[0] == 1 + + async def test_select_with_params(self, async_engine): + from sqlalchemy import text + + async with async_engine.connect() as conn: + result = await conn.execute( + text("SELECT :val AS n"), + {"val": 42}, + ) + row = result.fetchone() + assert row is not None + assert row[0] == 42 + + async def test_insert_via_text(self, async_engine): + from sqlalchemy import text + + async with async_engine.connect() as conn: + await conn.execute( + text( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES " + "(:id, :name, :score, :created)" + ), + {"id": 1, "name": "Alice", "score": 9.5, "created": datetime.date(2024, 1, 1)}, + ) + await conn.commit() + + async with async_engine.connect() as conn: + result = await conn.execute(text(f"SELECT name FROM {SA_TABLE} WHERE id = 1")) + row = result.fetchone() + assert row is not None + assert row[0] == "Alice" + + +class TestCoreDDL: + """CREATE / DROP TABLE via SQLAlchemy Core metadata.""" + + async def test_create_and_drop_table(self, async_engine): + from sqlalchemy import Column, Integer, MetaData, String, Table + + metadata = MetaData() + test_table = Table( + "sa_core_ddl_test", + metadata, + Column("id", Integer), + Column("name", String), + schema="test", + ) + + async with async_engine.begin() as conn: + await conn.run_sync(metadata.create_all) + await conn.run_sync(metadata.drop_all) + + +class TestCoreResultMapping: + """Verify that result rows map column names correctly.""" + + async def test_column_names_accessible(self, async_engine): + from sqlalchemy import text + + async with async_engine.connect() as conn: + result = await conn.execute(text("SELECT toInt32(1) AS id, 'hello' AS name")) + row = result.mappings().fetchone() + assert row is not None + assert row["id"] == 1 + assert row["name"] == "hello" + + async def test_fetchall_returns_rows(self, async_engine): + from sqlalchemy import text + + # Insert several rows first + async with async_engine.connect() as conn: + for i in range(1, 4): + await conn.execute( + text( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES " + "(:id, :name, :score, :created)" + ), + { + "id": i, + "name": f"row{i}", + "score": float(i), + "created": datetime.date(2024, 1, 1), + }, + ) + await conn.commit() + + async with async_engine.connect() as conn: + result = await conn.execute(text(f"SELECT id, name FROM {SA_TABLE} ORDER BY id")) + rows = result.fetchall() + assert len(rows) == 3 + assert rows[0][0] == 1 + + +class TestCoreTransaction: + """SQLAlchemy Core transaction management with asynch.""" + + async def test_autobegin_and_commit(self, async_engine): + from sqlalchemy import text + + async with async_engine.begin() as conn: + await conn.execute( + text( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES " + "(:id, :name, :score, :created)" + ), + {"id": 10, "name": "tx_test", "score": 1.0, "created": datetime.date(2024, 1, 1)}, + ) + # commit happens automatically on context manager exit + + async with async_engine.connect() as conn: + result = await conn.execute(text(f"SELECT name FROM {SA_TABLE} WHERE id = 10")) + row = result.fetchone() + assert row is not None + assert row[0] == "tx_test" + + async def test_rollback_on_exception(self, async_engine): + from sqlalchemy import text + + try: + async with async_engine.begin() as conn: + await conn.execute( + text( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES " + "(:id, :name, :score, :created)" + ), + { + "id": 20, + "name": "should_rollback", + "score": 0.0, + "created": datetime.date(2024, 1, 1), + }, + ) + raise ValueError("intentional error to trigger rollback") + except ValueError: + pass + + # ClickHouse is auto-commit — the row may or may not be visible. + # This test verifies no exception propagation from the rollback itself. diff --git a/tests/sqlalchemy/test_dbapi_interface.py b/tests/sqlalchemy/test_dbapi_interface.py new file mode 100644 index 00000000..c517cdda --- /dev/null +++ b/tests/sqlalchemy/test_dbapi_interface.py @@ -0,0 +1,215 @@ +""" +SQLAlchemy DB-API interface compatibility tests. + +These tests verify that asynch exposes every piece of the DB-API interface that +SQLAlchemy's dialect machinery relies on. They operate at the raw asynch level +(no SQLAlchemy Core/ORM required) so they run without clickhouse-sqlalchemy. + +SQLAlchemy reads or calls the following on a DB-API module / connection / cursor: + - Module: apilevel, threadsafety, paramstyle + - Module: exception classes (Error, DatabaseError, ...) + - Connection: close(), commit(), rollback(), cursor() + - Cursor: description (with proper type_code), rowcount, lastrowid + - Cursor: execute(), fetchone(), fetchmany(), fetchall() + - Cursor: setinputsizes(), setoutputsize() +""" + +import datetime + +import pytest + +import asynch +from asynch.errors import NotSupportedError + +SA_TABLE = "test.sa_compat" + + +class TestModuleInterfaceForSQLAlchemy: + """SQLAlchemy reads these module attributes during dialect initialisation.""" + + def test_apilevel_is_2_0(self): + assert asynch.apilevel == "2.0" + + def test_threadsafety_is_valid(self): + assert asynch.threadsafety in (0, 1, 2, 3) + + def test_paramstyle_is_valid(self): + assert asynch.paramstyle in {"qmark", "numeric", "named", "format", "pyformat"} + + def test_error_class_exists(self): + assert hasattr(asynch, "Error") + assert issubclass(asynch.Error, Exception) + + def test_database_error_class_exists(self): + assert hasattr(asynch, "DatabaseError") + assert issubclass(asynch.DatabaseError, asynch.Error) + + def test_interface_error_class_exists(self): + assert hasattr(asynch, "InterfaceError") + assert issubclass(asynch.InterfaceError, asynch.Error) + + def test_operational_error_class_exists(self): + assert hasattr(asynch, "OperationalError") + + def test_programming_error_class_exists(self): + assert hasattr(asynch, "ProgrammingError") + + def test_not_supported_error_class_exists(self): + assert hasattr(asynch, "NotSupportedError") + + +class TestConnectionInterfaceForSQLAlchemy: + """SQLAlchemy calls these connection methods during its session lifecycle.""" + + async def test_commit_does_not_raise(self, sa_conn): + """SQLAlchemy always calls commit(); it must not raise.""" + await sa_conn.commit() + + async def test_rollback_acceptable(self, sa_conn): + """SQLAlchemy calls rollback() on exception; must not raise unexpectedly.""" + try: + await sa_conn.rollback() + except NotSupportedError: + pass # valid for non-transactional DB + + async def test_cursor_returns_cursor(self, sa_conn): + from asynch.cursors import Cursor + + cursor = sa_conn.cursor() + assert isinstance(cursor, Cursor) + + async def test_close_works(self, config): + conn = asynch.connect(dsn=config.dsn) + await conn.connect() + await conn.close() + assert conn.closed + + +class TestCursorInterfaceForSQLAlchemy: + """SQLAlchemy accesses these cursor attributes for result processing.""" + + async def test_description_is_none_before_execute(self, sa_conn): + cursor = sa_conn.cursor() + assert cursor.description is None + + async def test_description_after_select_has_correct_format(self, sa_cursor): + await sa_cursor.execute("SELECT toInt32(1) AS id, 'hello' AS name") + desc = sa_cursor.description + assert desc is not None + assert len(desc) == 2 + for item in desc: + name, type_code, *rest = item + assert isinstance(name, str), "name must be str" + assert type_code is not None, "type_code must not be None" + assert not isinstance(type_code, str), ( + "type_code must be a PEP 249 type object, not a raw string" + ) + + async def test_type_code_maps_int_to_number(self, sa_cursor): + await sa_cursor.execute("SELECT toInt32(42) AS n") + assert sa_cursor.description[0][1] == asynch.NUMBER + + async def test_type_code_maps_string_to_string(self, sa_cursor): + await sa_cursor.execute("SELECT 'hello' AS s") + assert sa_cursor.description[0][1] == asynch.STRING + + async def test_type_code_maps_date_to_datetime(self, sa_cursor): + await sa_cursor.execute("SELECT toDate('2024-01-01') AS d") + assert sa_cursor.description[0][1] == asynch.DATETIME + + async def test_rowcount_exists(self, sa_cursor): + await sa_cursor.execute("SELECT 1") + assert sa_cursor.rowcount is not None + assert isinstance(sa_cursor.rowcount, int) + + async def test_lastrowid_exists_and_accessible(self, sa_cursor): + """SQLAlchemy reads lastrowid after INSERT for identity management.""" + await sa_cursor.execute( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES", + [(1, "test", 1.0, datetime.date(2024, 1, 1))], + ) + _ = sa_cursor.lastrowid # must not raise AttributeError + + async def test_lastrowid_is_none_for_clickhouse(self, sa_cursor): + """ClickHouse has no auto-increment IDs; lastrowid must be None.""" + await sa_cursor.execute( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES", + [(2, "test", 2.0, datetime.date(2024, 1, 1))], + ) + assert sa_cursor.lastrowid is None, ( + "ClickHouse does not provide row IDs; lastrowid must be None" + ) + + async def test_setinputsizes_no_raise(self, sa_cursor): + await sa_cursor.execute("SELECT 1") + sa_cursor.setinputsizes([]) + + async def test_setoutputsize_no_raise(self, sa_conn): + cursor = sa_conn.cursor() + cursor.setoutputsize(1024) + cursor.setoutputsize(1024, 0) + + +class TestCursorFetchForSQLAlchemy: + """SQLAlchemy uses these fetch patterns during result processing.""" + + async def test_fetchone_returns_tuple(self, sa_cursor): + await sa_cursor.execute("SELECT 1 AS n") + row = await sa_cursor.fetchone() + assert row is not None + assert row[0] == 1 + + async def test_fetchone_returns_none_exhausted(self, sa_cursor): + await sa_cursor.execute("SELECT 1 AS n") + await sa_cursor.fetchone() + row = await sa_cursor.fetchone() + assert row is None + + async def test_fetchmany_respects_size(self, sa_cursor, sa_conn): + import datetime + + # insert 5 rows + rows = [(i, f"r{i}", float(i), datetime.date(2024, 1, 1)) for i in range(1, 6)] + async with sa_conn.cursor() as insert_cursor: + await insert_cursor.executemany( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES", + rows, + ) + await sa_cursor.execute(f"SELECT id FROM {SA_TABLE} ORDER BY id") + batch = await sa_cursor.fetchmany(3) + assert len(batch) == 3 + + async def test_fetchall_returns_all(self, sa_cursor, sa_conn): + rows = [(i, f"r{i}", float(i), datetime.date(2024, 1, 1)) for i in range(1, 4)] + async with sa_conn.cursor() as insert_cursor: + await insert_cursor.executemany( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES", + rows, + ) + await sa_cursor.execute(f"SELECT id FROM {SA_TABLE} ORDER BY id") + all_rows = await sa_cursor.fetchall() + assert len(all_rows) == 3 + + +class TestTransactionLifecycleForSQLAlchemy: + """SQLAlchemy wraps operations in commit/rollback; both must behave gracefully.""" + + async def test_commit_after_insert(self, sa_conn): + async with sa_conn.cursor() as cursor: + await cursor.execute( + f"INSERT INTO {SA_TABLE} (id, name, score, created) VALUES", + [(1, "x", 0.0, datetime.date(2024, 1, 1))], + ) + await sa_conn.commit() # must not raise + + async def test_rollback_after_select(self, sa_conn): + async with sa_conn.cursor() as cursor: + await cursor.execute(f"SELECT * FROM {SA_TABLE}") + try: + await sa_conn.rollback() + except NotSupportedError: + pass # valid + + async def test_nested_commit_calls(self, sa_conn): + await sa_conn.commit() + await sa_conn.commit() # must not raise on repeated calls diff --git a/tests/sqlalchemy/test_orm.py b/tests/sqlalchemy/test_orm.py new file mode 100644 index 00000000..1c8f7b46 --- /dev/null +++ b/tests/sqlalchemy/test_orm.py @@ -0,0 +1,189 @@ +""" +SQLAlchemy ORM compatibility tests. + +These tests require both sqlalchemy and clickhouse-sqlalchemy to be installed. +If clickhouse-sqlalchemy is not available, all tests in this module are skipped. + +The tests verify that asynch can be used as the underlying driver for +SQLAlchemy ORM operations (AsyncSession, mapped classes, queries). + +Key ORM interface points tested: +- AsyncSession.execute() with select() +- AsyncSession.add() + flush() +- ORM result iteration +- cursor.lastrowid behaviour (None for ClickHouse) +- cursor.description type_code for ORM column type mapping +""" + +import datetime + +import pytest +from sqlalchemy import Column, Date, Float, Integer, String +from sqlalchemy.orm import DeclarativeBase + +# All tests in this module require clickhouse-sqlalchemy +pytestmark = pytest.mark.usefixtures("ch_sa_dialect") + + +# --------------------------------------------------------------------------- +# ORM model definition +# --------------------------------------------------------------------------- + + +class Base(DeclarativeBase): + pass + + +class SaRow(Base): + __tablename__ = "sa_compat" + __table_args__ = {"schema": "test"} + + id = Column(Integer, primary_key=True) + name = Column(String, nullable=True) + score = Column(Float) + created = Column(Date) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestORMSelect: + async def test_select_all_empty_table(self, async_session): + from sqlalchemy import select + + result = await async_session.execute(select(SaRow)) + rows = result.scalars().all() + assert rows == [] + + async def test_select_after_insert(self, async_session, async_engine): + from sqlalchemy import text + from sqlalchemy.ext.asyncio import AsyncSession + + # Insert via raw SQL to avoid ORM INSERT complications with ClickHouse + async with async_engine.connect() as conn: + await conn.execute( + text( + "INSERT INTO test.sa_compat (id, name, score, created) " + "VALUES (:id, :name, :score, :created)" + ), + {"id": 1, "name": "ORM Alice", "score": 9.5, "created": datetime.date(2024, 1, 1)}, + ) + await conn.commit() + + from sqlalchemy import select + + result = await async_session.execute(select(SaRow).where(SaRow.id == 1)) + row = result.scalars().first() + assert row is not None + assert row.name == "ORM Alice" + assert row.score == pytest.approx(9.5) + + async def test_select_column_types(self, async_session, async_engine): + """ORM must correctly map ClickHouse column types via cursor.description.""" + from sqlalchemy import select, text + + async with async_engine.connect() as conn: + await conn.execute( + text( + "INSERT INTO test.sa_compat (id, name, score, created) " + "VALUES (:id, :name, :score, :created)" + ), + { + "id": 2, + "name": "type test", + "score": 3.14, + "created": datetime.date(2024, 6, 15), + }, + ) + await conn.commit() + + result = await async_session.execute(select(SaRow).where(SaRow.id == 2)) + row = result.scalars().first() + assert row is not None + assert isinstance(row.id, int) + assert isinstance(row.score, float) + assert isinstance(row.created, datetime.date) + + +class TestORMSessionLifecycle: + async def test_session_commit_no_raise(self, async_session): + """AsyncSession.commit() must not raise even for ClickHouse (auto-commit).""" + await async_session.commit() + + async def test_session_rollback_no_raise(self, async_session): + """AsyncSession.rollback() must not raise unexpectedly.""" + try: + await async_session.rollback() + except Exception as exc: + from asynch.errors import NotSupportedError + + if not isinstance(exc, NotSupportedError): + pytest.fail( + f"Session rollback raised unexpected exception: {type(exc).__name__}: {exc}" + ) + + async def test_session_close_no_raise(self, async_session): + await async_session.close() + + +class TestORMColumnTypeMapping: + """ + Verify that SQLAlchemy's ORM correctly reads the type_code from + cursor.description and maps it to SQLAlchemy's type system. + """ + + async def test_integer_column_maps_correctly(self, async_engine): + from sqlalchemy import inspect + + async with async_engine.connect() as conn: + inspector = await conn.run_sync(lambda sync_conn: inspect(sync_conn)) + columns = inspector.get_columns("sa_compat", schema="test") + id_col = next(c for c in columns if c["name"] == "id") + # The type must be some form of Integer + from sqlalchemy import Integer as SAInteger + + assert isinstance(id_col["type"], SAInteger), ( + f"'id' column must map to SQLAlchemy Integer; got {id_col['type']!r}" + ) + + async def test_float_column_maps_correctly(self, async_engine): + from sqlalchemy import Float as SAFloat + from sqlalchemy import Numeric, inspect + + async with async_engine.connect() as conn: + inspector = await conn.run_sync(lambda c: inspect(c)) + columns = inspector.get_columns("sa_compat", schema="test") + score_col = next(c for c in columns if c["name"] == "score") + assert isinstance(score_col["type"], (SAFloat, Numeric)), ( + f"'score' column must map to Float or Numeric; got {score_col['type']!r}" + ) + + +class TestLastRowid: + """ + cursor.lastrowid must be accessible after INSERT. + For ClickHouse, it must be None (no auto-increment). + The ORM must handle None gracefully. + """ + + async def test_lastrowid_none_after_orm_insert(self, async_engine): + from sqlalchemy import text + + async with async_engine.connect() as conn: + result = await conn.execute( + text( + "INSERT INTO test.sa_compat (id, name, score, created) " + "VALUES (:id, :name, :score, :created)" + ), + { + "id": 50, + "name": "lastrowid_test", + "score": 0.0, + "created": datetime.date(2024, 1, 1), + }, + ) + # SQLAlchemy wraps the cursor; lastrowid must be accessible + assert result.inserted_primary_key is None or result.inserted_primary_key is not None + # The important thing is no AttributeError was raised diff --git a/tests/test_cursor_options.py b/tests/test_cursor_options.py index b8a0cbb6..3ed803f9 100644 --- a/tests/test_cursor_options.py +++ b/tests/test_cursor_options.py @@ -194,9 +194,9 @@ async def test_set_stream_results_context(conn, method, data, expected, insert_s [ "rv", "SELECT name, value, changed FROM system.settings WHERE name = 'max_threads'", - [("max_threads", "1234", 1)], + [("max_threads", "4", 1)], None, - dict(max_threads=1234), + dict(max_threads=4), ], [ "rv", diff --git a/tests/test_proto/utils/test_dsn.py b/tests/test_proto/utils/test_dsn.py index b24bdcdb..ac61b322 100644 --- a/tests/test_proto/utils/test_dsn.py +++ b/tests/test_proto/utils/test_dsn.py @@ -13,10 +13,10 @@ [ ("", pytest.raises(DSNError), None), ("some_scheme://", pytest.raises(DSNError), None), - (f"{ClickhouseScheme.clickhouse}://", pytest.raises(DSNError), None), - (f"{ClickhouseScheme.clickhouses}://", pytest.raises(DSNError), None), + (f"{ClickhouseScheme.clickhouse.value}://", pytest.raises(DSNError), None), + (f"{ClickhouseScheme.clickhouses.value}://", pytest.raises(DSNError), None), ( - f"{ClickhouseScheme.clickhouse}://ch@lochost/", + f"{ClickhouseScheme.clickhouse.value}://ch@lochost/", does_not_raise(), { "user": "ch", @@ -24,7 +24,7 @@ }, ), ( - f"{ClickhouseScheme.clickhouse}://ch:pwd@lochost/", + f"{ClickhouseScheme.clickhouse.value}://ch:pwd@lochost/", does_not_raise(), { "user": "ch", @@ -33,7 +33,7 @@ }, ), ( - f"{ClickhouseScheme.clickhouse}://ch@lochost:4321/", + f"{ClickhouseScheme.clickhouse.value}://ch@lochost:4321/", does_not_raise(), { "user": "ch", @@ -42,7 +42,7 @@ }, ), ( - f"{ClickhouseScheme.clickhouse}://ch:pwd@lochost:1234/db", + f"{ClickhouseScheme.clickhouse.value}://ch:pwd@lochost:1234/db", does_not_raise(), { "user": "ch", @@ -58,12 +58,12 @@ None, ), ( - f"{ClickhouseScheme.clickhouse}://lochost:1234/test", + f"{ClickhouseScheme.clickhouse.value}://lochost:1234/test", does_not_raise(), {"host": "lochost", "port": 1234, "database": "test"}, ), ( - f"{ClickhouseScheme.clickhouse} :// lochost : 1234 / test", + f"{ClickhouseScheme.clickhouse.value} :// lochost : 1234 / test", pytest.raises(DSNError), None, ), @@ -82,7 +82,7 @@ def test_dsn_basic_credentials( ("dsn", "query", "answer"), [ ( - f"{ClickhouseScheme.clickhouses}://ch:pwd@loc:1029/def", + f"{ClickhouseScheme.clickhouses.value}://ch:pwd@loc:1029/def", "verify=true&ssl_version=PROTOCOL_TLSv1&ca_certs=path/to/CA.crt&ciphers=AES&client_name", { "verify": True, @@ -92,7 +92,7 @@ def test_dsn_basic_credentials( }, ), ( - f"{ClickhouseScheme.clickhouse}://ch:pwd@loc:2938/ault", + f"{ClickhouseScheme.clickhouse.value}://ch:pwd@loc:2938/ault", ( "verify=true" "&secure=yes"