This commit takes informix-db from documentation-only (Phase 0 spike)
to a functional connect() / close() against a real Informix server.
To our knowledge, this is the first pure-socket Informix client in any
language — no CSDK, no JVM, no native libraries.
Layered architecture per the plan, mirroring PyMySQL's shape:
src/informix_db/
__init__.py — PEP 249 surface (connect, exceptions, paramstyle="numeric")
exceptions.py — full PEP 249 hierarchy declared up front
_socket.py — raw socket I/O (read_exact, write_all, timeouts)
_protocol.py — IfxStreamReader / IfxStreamWriter framing primitives
(big-endian, 16-bit-aligned variable payloads,
length-prefixed nul-terminated strings)
_messages.py — SQ_* tags from IfxMessageTypes + ASF/login markers
_auth.py — pluggable auth handlers; plain-password is the
only Phase-1 implementation
connections.py — Connection class: builds the binary login PDU
(SLheader + PFheader byte-for-byte per
PROTOCOL_NOTES.md §3), sends it, parses the
server response, wires up close()
Phase 1 design decisions locked in DECISION_LOG.md:
- paramstyle = "numeric" (matches Informix ESQL/C convention)
- Python >= 3.10
- autocommit defaults to off (PEP 249 implicit)
- License: MIT
- Distribution name: informix-db (verified PyPI-available)
Test coverage: 34 unit tests (codec round-trips against synthetic byte
streams; observed login-PDU values from the spike captures asserted as
exact byte literals) + 6 integration tests (connect, idempotent close,
context manager, bad-password → OperationalError, bad-host →
OperationalError, cursor() raises NotImplementedError).
pytest — runs 34 unit tests, no Docker needed
pytest -m integration — runs 6 integration tests against the
Developer Edition container (pinned by digest
in tests/docker-compose.yml)
pytest -m "" — runs everything
ruff is clean across src/ and tests/.
One bug found during smoke testing: threading.get_ident() can exceed
signed 32-bit on some processes, overflowing struct.pack("!i"). Fixed
the same way the JDBC reference does — clamp to signed 32-bit, fall
back to 0 if out of range. The field is diagnostic only.
One protocol-level observation that AMENDED the JDBC source reading:
the "capability section" in the login PDU is three independently
negotiated 4-byte ints (Cap_1=1, Cap_2=0x3c000000, Cap_3=0), not one
int + 8 reserved zero bytes as my CFR decompile read suggested. The
server echoes them back identically. Trust the wire over the
decompiler.
Phase 1 verification matrix (from PROTOCOL_NOTES.md §12):
- Login byte layout: confirmed (server accepts our pure-Python PDU)
- Disconnection: confirmed (SQ_EXIT round-trip works)
- Framing primitives: confirmed (34 unit tests)
- Error path: bad password → OperationalError, bad host → OperationalError
Phase 2 (Cursor / SELECT / basic types) is the next phase. The hard
unknowns there — exact column-descriptor layout, statement-time error
format — were called out as bounded gaps in Phase 0 and have existing
captures (02-select-1.socat.log, 02-dml-cycle.socat.log) to characterize
against.
84 lines
2.6 KiB
Python
84 lines
2.6 KiB
Python
"""Shared pytest fixtures.
|
|
|
|
The integration tests need a running Informix server on ``localhost:9088``
|
|
with the default Developer Edition credentials (``informix`` / ``in4mix``
|
|
on the ``sysmaster`` database). Run::
|
|
|
|
docker compose -f tests/docker-compose.yml up -d
|
|
|
|
before the integration suite. See ``docs/DECISION_LOG.md`` for the
|
|
pinned image digest and credential rationale.
|
|
|
|
Connection parameters are overridable via env vars
|
|
(``IFX_HOST`` / ``IFX_PORT`` / ``IFX_USER`` / ``IFX_PASSWORD`` /
|
|
``IFX_DATABASE`` / ``IFX_SERVER``) so the same suite runs against a
|
|
non-default instance if you have one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import socket
|
|
from collections.abc import Iterator
|
|
from typing import NamedTuple
|
|
|
|
import pytest
|
|
|
|
|
|
class ConnParams(NamedTuple):
|
|
host: str
|
|
port: int
|
|
user: str
|
|
password: str
|
|
database: str
|
|
server: str
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def conn_params() -> ConnParams:
|
|
"""Connection parameters for the test Informix instance."""
|
|
return ConnParams(
|
|
host=os.environ.get("IFX_HOST", "127.0.0.1"),
|
|
port=int(os.environ.get("IFX_PORT", "9088")),
|
|
user=os.environ.get("IFX_USER", "informix"),
|
|
password=os.environ.get("IFX_PASSWORD", "in4mix"),
|
|
database=os.environ.get("IFX_DATABASE", "sysmaster"),
|
|
server=os.environ.get("IFX_SERVER", "informix"),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _check_integration_server(request: pytest.FixtureRequest, conn_params: ConnParams) -> None:
|
|
"""Skip integration tests cleanly when the server isn't up.
|
|
|
|
Avoids a sea of ``ConnectionRefusedError`` failures masking real bugs
|
|
when the user runs the full suite without ``docker compose up``.
|
|
"""
|
|
if "integration" not in request.config.getoption("-m", default=""):
|
|
return
|
|
try:
|
|
with socket.create_connection((conn_params.host, conn_params.port), timeout=2.0):
|
|
return
|
|
except OSError as e:
|
|
pytest.skip(
|
|
f"Informix server not reachable at {conn_params.host}:{conn_params.port} ({e}). "
|
|
f"Start it with: docker compose -f tests/docker-compose.yml up -d"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def ifx_connection(conn_params: ConnParams) -> Iterator[object]:
|
|
"""Open a fresh Informix connection for one test, then close it."""
|
|
import informix_db
|
|
|
|
conn = informix_db.connect(
|
|
host=conn_params.host, port=conn_params.port,
|
|
user=conn_params.user, password=conn_params.password,
|
|
database=conn_params.database, server=conn_params.server,
|
|
connect_timeout=10.0, read_timeout=10.0,
|
|
)
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|