Phase 1: pure-Python SQLI login works end-to-end

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.
This commit is contained in:
Ryan Malloy 2026-05-02 19:10:24 -06:00
parent 6b59816f9a
commit 9b1fd8af2c
16 changed files with 1812 additions and 12 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ryan Malloy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -4,22 +4,40 @@ Pure-Python driver for IBM Informix IDS, speaking the SQLI wire protocol over ra
## Status ## Status
🚧 **Phase 0 — Spike.** Characterizing the SQLI wire protocol. No library code yet. 🟢 **Phase 1 complete.** `connect()` / `close()` work end-to-end against a real Informix server. Cursor / execute / fetch land in Phase 2.
The protocol has never been published byte-for-byte by IBM. Every existing Informix driver in every language wraps either IBM's CSDK or the JDBC JAR. This project closes that gap. To our knowledge this is the **first pure-socket Informix driver in any language** — every other Informix driver (`IfxPy`, the legacy `informixdb`, ODBC bridges, Perl `DBD::Informix`) wraps either IBM's CSDK or the JDBC JAR.
## Phase 0 deliverables ## Quick start
- [`docs/PROTOCOL_NOTES.md`](docs/PROTOCOL_NOTES.md) — byte-level wire-format reference, derived from packet captures + JDBC decompilation ```python
import informix_db
with informix_db.connect(
host="127.0.0.1", port=9088,
user="informix", password="in4mix",
database="sysmaster", server="informix",
) as conn:
# cursor() / execute() / fetchone() arrive in Phase 2
pass
```
## Test against the official Informix dev container
```bash
docker compose -f tests/docker-compose.yml up -d # IBM Developer Edition, pinned by digest
uv sync --extra dev
uv run pytest # 34 unit tests (no Docker needed)
uv run pytest -m integration # 6 integration tests (needs the container)
```
## Phase 0 artifacts (still useful — they ARE the public reference)
- [`docs/PROTOCOL_NOTES.md`](docs/PROTOCOL_NOTES.md) — byte-level wire-format reference, derived from packet captures + JDBC decompilation, validated against a real server
- [`docs/JDBC_NOTES.md`](docs/JDBC_NOTES.md) — index into the decompiled IBM JDBC driver's wire-protocol classes - [`docs/JDBC_NOTES.md`](docs/JDBC_NOTES.md) — index into the decompiled IBM JDBC driver's wire-protocol classes
- [`docs/DECISION_LOG.md`](docs/DECISION_LOG.md) — running rationale for protocol / auth / type decisions - [`docs/DECISION_LOG.md`](docs/DECISION_LOG.md) — running rationale for protocol / auth / type decisions
- `docs/CAPTURES/*.pcap` — annotated packet captures of reference exchanges - [`docs/CAPTURES/`](docs/CAPTURES) — socat hex-dump captures of three reference scenarios (connect, SELECT, full DML cycle)
- [`tests/reference/RefClient.java`](tests/reference/RefClient.java) — re-runnable JDBC ground-truth client for capturing fresh traces
If Phase 0's exit criteria are met, library implementation begins in Phase 1.
## Test target
`icr.io/informix/informix-developer-database` (port 9088, native SQLI). See [`tests/docker-compose.yml`](tests/docker-compose.yml) once Phase 1 lands.
## License ## License

98
pyproject.toml Normal file
View File

@ -0,0 +1,98 @@
[project]
name = "informix-db"
version = "2026.05.02"
description = "Pure-Python driver for IBM Informix IDS — speaks the SQLI wire protocol over raw sockets. No CSDK, no JVM, no native libraries."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
requires-python = ">=3.10"
keywords = ["informix", "database", "sqli", "db-api", "pep-249"]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Database",
"Topic :: Database :: Front-Ends",
"Typing :: Typed",
]
dependencies = []
[project.urls]
Homepage = "https://github.com/rsp2k/informix-db"
Documentation = "https://github.com/rsp2k/informix-db/tree/main/docs"
Issues = "https://github.com/rsp2k/informix-db/issues"
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.6",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/informix_db"]
[tool.hatch.build.targets.sdist]
# Defense in depth: exclude operator-private and dev-only artifacts from the sdist
# (the wheel doesn't ship these by default, but the sdist would).
# See ~/.claude/rules/python.md for the full pre-publish PII audit playbook.
exclude = [
"CLAUDE.md", # operator-private context
".env", ".env.local", ".env.*",
".mcp.json", # may contain local filesystem paths
"build/", # decompiled JDBC, downloaded JARs
"audits/",
"docs/CAPTURES/", # spike artifacts; tests can re-capture against the dev container
"tests/reference/", # Java reference client — spike infra
".pytest_cache/", ".ruff_cache/", ".mypy_cache/",
"dist/", "*.egg-info/",
]
[tool.ruff]
line-length = 100
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"RUF", # ruff-specific
]
ignore = [
"E501", # line too long — handled by formatter
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["B011"] # allow assert False in tests
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = [
"-ra", # short summary for non-passing
"--strict-markers",
"--strict-config",
"-m", "not integration", # default: unit-only. Override with: pytest -m integration
]
markers = [
"integration: requires a running Informix container (docker compose up); skipped by default",
]

104
src/informix_db/__init__.py Normal file
View File

@ -0,0 +1,104 @@
"""informix-db — pure-Python driver for IBM Informix IDS over the SQLI wire protocol.
PEP 249 (DB-API 2.0) module surface. Phase 1 implements the connection
lifecycle (open + login + close) only; cursor/execute/fetch land in Phase 2.
Quick start::
import informix_db
conn = informix_db.connect(
host="127.0.0.1", port=9088,
user="informix", password="in4mix",
database="sysmaster", server="informix",
)
conn.close()
For the full design, see ``docs/PROTOCOL_NOTES.md`` and
``docs/DECISION_LOG.md``.
"""
from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
from .connections import Connection
from .exceptions import (
DatabaseError,
DataError,
Error,
IntegrityError,
InterfaceError,
InternalError,
NotSupportedError,
OperationalError,
ProgrammingError,
Warning,
)
# PEP 249 module-level globals
apilevel = "2.0"
threadsafety = 1 # threads may share the module but not connections
paramstyle = "numeric" # locked in DECISION_LOG.md — matches Informix ESQL/C
try:
__version__ = version("informix-db")
except PackageNotFoundError:
# Editable install or running uninstalled; fall back to a sentinel.
__version__ = "0.0.0+local"
__all__ = [
"Connection",
"DataError",
"DatabaseError",
"Error",
"IntegrityError",
"InterfaceError",
"InternalError",
"NotSupportedError",
"OperationalError",
"ProgrammingError",
"Warning",
"__version__",
"apilevel",
"connect",
"paramstyle",
"threadsafety",
]
def connect(
host: str,
port: int = 9088,
*,
user: str,
password: str | None,
database: str | None = None,
server: str = "informix",
connect_timeout: float | None = None,
read_timeout: float | None = None,
keepalive: bool = False,
client_locale: str = "en_US.8859-1",
env: dict[str, str] | None = None,
autocommit: bool = False,
) -> Connection:
"""Open a connection to an Informix server.
The ``server`` argument is the Informix DBSERVERNAME the listener
identifies itself as (default ``"informix"`` matches the IBM
Developer Edition Docker image). It's distinct from ``host`` (which
is the network address) and from ``database`` (which is the
database name to open after login).
``database`` may be ``None`` to log in without selecting a database;
the server still requires a successful login for this to work.
"""
return Connection(
host=host, port=port,
user=user, password=password,
database=database, server=server,
connect_timeout=connect_timeout, read_timeout=read_timeout,
keepalive=keepalive,
client_locale=client_locale, env=env,
autocommit=autocommit,
)

58
src/informix_db/_auth.py Normal file
View File

@ -0,0 +1,58 @@
"""Authentication handlers for the SQLI binary login PDU.
Auth in modern Informix has several mechanisms (plain password, password
obfuscation, PAM challenge/response, GSSAPI/Kerberos, trusted context).
For Phase 1 we ship the simplest one plain password and expose a
pluggable shape so later phases add new methods without touching
``connections.py``.
Each handler contributes the username/password section to the login PDU.
The rest of the PDU (markers, capabilities, env vars, process info) is
assembled by ``connections.py`` and is auth-method-independent.
"""
from __future__ import annotations
from collections.abc import Callable
from ._protocol import IfxStreamWriter
# Type alias: an auth handler appends its credentials section to the writer.
AuthHandler = Callable[[IfxStreamWriter, str, str | None], None]
def write_plain_password(
writer: IfxStreamWriter,
username: str,
password: str | None,
) -> None:
"""Write the username + password section of the login PDU.
Layout (matches ``Connection.encodeAscBinary`` lines that emit
username and password):
``[short username.len+1][bytes username][nul]``
then either ``[short 0]`` (no password) or
``[short password.len+1][bytes password][nul]``.
Plain password is sent inline; the server compares it directly to its
user database. There is no salt, no hash, no challenge round-trip.
Use TLS (Phase 6+) if the network is untrusted.
"""
writer.write_string_with_nul(username)
if password is None:
writer.write_short(0)
else:
writer.write_string_with_nul(password)
# Dispatch table — Phase 6+ adds: "obfuscate" (SHA-256+nonstd-base64),
# "pam" (challenge/response), "gssapi" (Kerberos).
HANDLERS: dict[str, AuthHandler] = {
"plain": write_plain_password,
}
def get_handler(method: str) -> AuthHandler:
"""Look up an auth handler by name. Raises KeyError if unknown."""
return HANDLERS[method]

View File

@ -0,0 +1,184 @@
"""SQLI wire-protocol message-type constants.
Two distinct namespaces live here:
* ``SQ_*`` the post-login SQLI message-type tags from
``com.informix.jdbc.IfxMessageTypes``. Each one is a 16-bit big-endian
short on the wire. See ``docs/PROTOCOL_NOTES.md`` §5 for categorization
by purpose.
* ``ASF_*``, ``SL_*``, ``PF_*`` the connection-establishment markers
used inside the binary login PDU body, defined locally in
``com.informix.asf.Connection`` (NOT in ``IfxMessageTypes``). These
also appear on the wire as 16-bit shorts but they're a separate
namespace whose values may overlap numerically with ``SQ_*`` constants.
"""
from __future__ import annotations
from enum import IntEnum
class MessageType(IntEnum):
"""SQLI post-login message-type tags. Wire format: 2 bytes, big-endian."""
# --- Statement execution ---
SQ_COMMAND = 1
SQ_PREPARE = 2
SQ_CURNAME = 3
SQ_ID = 4
SQ_BIND = 5
SQ_OPEN = 6
SQ_EXECUTE = 7
SQ_DESCRIBE = 8
SQ_NFETCH = 9
SQ_CLOSE = 10
SQ_RELEASE = 11
# --- Per-PDU framing ---
SQ_EOT = 12 # end-of-transmission / flush marker; ends every PDU
SQ_ERR = 13 # error response from the server
SQ_TUPLE = 14 # one row of result data
SQ_DONE = 15 # statement / result-set completion
# --- Transactions ---
SQ_CMMTWORK = 19
SQ_RBWORK = 20
SQ_SVPOINT = 21
SQ_BEGIN = 35
SQ_DBOPEN = 36
SQ_DBCLOSE = 37
SQ_DBLIST = 26
# --- Connection lifecycle (post-login) ---
SQ_VERSION = 53
SQ_EXIT = 56 # client-initiated session terminate (also returned as ack)
SQ_INFO = 81 # info request, with sub-codes (see ``InfoSubtype`` below)
SQ_CONNECT = 112
SQ_SETCONN = 113
SQ_DISCONNECT = 114
SQ_PROTOCOLS = 126
# --- Auth (server-initiated challenge/response, e.g. PAM) ---
SQ_ACCEPT = 127
SQ_ACK = 128
SQ_CHALLENGE = 129
SQ_RESPONSE = 130
# --- Savepoints (newer) ---
SQ_SQLISETSVPT = 137
SQ_SQLIRELSVPT = 138
SQ_SQLIRBACKSVPT = 139
# --- XA (distributed transactions) — Phase 6+ ---
SQ_XROLLBACK = 65
SQ_XCLOSE = 66
SQ_XCOMMIT = 67
SQ_XEND = 68
SQ_XFORGET = 69
SQ_XPREPARE = 70
SQ_XRECOVER = 71
SQ_XSTART = 72
SQ_XERR = 73
SQ_XASTATE = 74
SQ_XOPEN = 82
# --- BLOB / LOB — Phase 6+ ---
SQ_FETCHBLOB = 38
SQ_BLOB = 39
SQ_BBIND = 41
SQ_SBBIND = 52
SQ_FILE_READ = 106
SQ_FILE_WRITE = 107
# --- RPC sub-protocol (range 200-205) — Phase 6+ ---
SQ_INVOKE = 200
SQ_REPLY = 201
SQ_EXCEPTION = 202
SQ_VERSION_REQ = 203
SQ_VERSION_REPLY = 204
SQ_AMFPARAM = 205
class InfoSubtype(IntEnum):
"""Sub-codes for the ``SQ_INFO`` message body."""
INFO_DONE = 0
INFO_REQUEST = 1
INFO_VERSION = 2
INFO_TYPE = 3
INFO_CAPABILITY = 4
INFO_DB = 5
INFO_ENV = 6
class LoginMarker(IntEnum):
"""Login-PDU section markers from ``com.informix.asf.Connection``.
These appear inside the binary login PDU as 16-bit shorts and tag
the structural sub-blocks (association header, capability section,
env vars, process info, etc.). Numerically distinct from ``MessageType``;
do not mix the two.
See ``docs/PROTOCOL_NOTES.md`` §3 for the full byte-by-byte layout.
"""
SQ_ASSOC = 100 # start of "association" record
SQ_ASCBINARY = 101 # binary-format indicator
SQ_ASCINITRESP = 102 # init response (error block follows in some paths)
SQ_ASCDBLIST = 103
SQ_ASCINITREQ = 104 # init request marker (paired with ASF_XCONNECT=11)
SQ_ASCENV = 106 # environment-vars sub-block
SQ_ASCPINFO = 107 # process-info sub-block
SQ_ASCBPARMS = 108 # binary parameters block
SQ_ASSOCBIND = 110
SQ_ASSOCRESP = 111
SQ_ASCMISC_60 = 116 # misc / AppName section
SQ_ASCEOT = 127 # end-of-PDU marker for the login
class SLHeader(IntEnum):
"""SL header constants for the 6-byte login envelope."""
# slType byte (offset 2 of SLheader)
SLTYPE_CONREQ = 1 # client → server: connection request
SLTYPE_CONACC = 2 # server → client: connection accepted
SLTYPE_CONREJ = 3 # server → client: connection rejected
SLTYPE_REDIRECT = 13 # server → client: redirect to another node
# slAttribute byte (offset 3 of SLheader): protocol version
PF_PROT_SQLI_0600 = 60 # SQLI protocol family 6.00 (current)
PF_PROT_SQLI_WITH_CSS = 61 # variant with column-storage support
class StmtOptions(IntEnum):
"""Bit flags packed into the login-PDU stmtoptions int."""
ASF_AMBIG_SEOL = 3 # bits 0+1 (always set)
ASF_GRPREF = 0x02000000 # bit 25 — sqlhosts group reference
ASF_TRUSTCTXT = 0x04000000 # bit 26 — trusted context
# Hardcoded constant strings emitted in the binary login PDU.
# These are NOT free parameters — they identify our wire client to the server
# and must match what IBM's JDBC driver sends, byte-for-byte.
APPL_TYPE = b"sqlexec\x00\x00\x00\x00\x00" # 12 bytes, fixed-width nul-padded
APPL_ID = b"sqli" # 4 bytes (length-prefix says 5 because of trailing nul)
PROT_SQLIOL = b"ol\x00\x00\x00\x00\x00\x00" # 8 bytes; SQLI/OL protocol identifier
NET_TLITCP = b"tlitcp\x00\x00" # 8 bytes; "TLI over TCP" network type
FLOAT_TYPE = b"IEEEM" # 5 bytes; declares we want IEEE 754 float encoding
CLIENT_VERSION = b"9.280" # 5 bytes; hardcoded by JDBC, NOT a real version
CLIENT_SERIAL = b"RDS#R000000" # 11 bytes; hardcoded marketing/license artifact
# Buffer / size constants from com.informix.asf.Connection
MAX_BUFF_SIZE = 32768
MIN_BUFF_SIZE = 140
STREAM_BUF_SIZE = 4096
PFCONREQ_BUF_SIZE = 2048
SL_HEADER_SIZE = 6
# UTYPE_INTERNET — declares we're connecting over IP (vs. shared-memory etc.)
UTYPE_INTERNET = 1
# ASF_XCONNECT — paired with SQ_ASCINITREQ in the login PDU init-request marker
ASF_XCONNECT = 11

View File

@ -0,0 +1,224 @@
"""SQLI wire-protocol framing primitives.
Mirrors ``com.informix.asf.IfxDataInputStream`` and ``IfxDataOutputStream``
from the IBM JDBC reference. Same endianness (big-endian / network byte
order), same length-prefix conventions, same 16-bit alignment requirement
for variable-length payloads.
The byte layout produced by ``IfxStreamWriter`` MUST match the captured
JDBC reference PDU byte-for-byte for the framing primitives, modulo
caller-supplied content (username, hostname, cwd). Tests in
``tests/test_protocol.py`` assert this against ``docs/CAPTURES/``.
"""
from __future__ import annotations
import struct
from io import BytesIO
from typing import BinaryIO
# Struct format strings — '!' forces big-endian (network byte order).
_FMT_SHORT = struct.Struct("!h") # signed 16-bit
_FMT_INT = struct.Struct("!i") # signed 32-bit
_FMT_LONG = struct.Struct("!q") # signed 64-bit
_FMT_FLOAT = struct.Struct("!f") # IEEE 754 single
_FMT_DOUBLE = struct.Struct("!d") # IEEE 754 double
class ProtocolError(Exception):
"""Raised when wire bytes can't be parsed (truncated stream, bad framing)."""
# ---------------------------------------------------------------------------
# Writer
# ---------------------------------------------------------------------------
class IfxStreamWriter:
"""Buffered byte writer for assembling SQLI PDUs.
Wraps a ``BinaryIO`` (typically a ``BytesIO`` for PDU assembly, then
flushed to the socket as one ``send()`` call). Mirrors the
``IfxDataOutputStream`` API names so cross-references against the
decompiled JDBC source stay obvious.
"""
def __init__(self, sink: BinaryIO):
self._sink = sink
# -- raw bytes ---------------------------------------------------------
def write_byte(self, value: int) -> None:
"""Write a single byte. ``value`` must fit in 0..255."""
self._sink.write(bytes((value & 0xFF,)))
def write_bytes(self, data: bytes) -> None:
"""Write ``data`` verbatim, no length prefix, no padding."""
self._sink.write(data)
def write_padded(self, data: bytes) -> None:
"""Write ``data`` then a 0x00 pad byte if its length is odd.
Mirrors ``IfxDataOutputStream.writePadded(byte[])``. The 16-bit
alignment requirement is mandatory for variable-length payloads
(string/decimal/datetime/interval/BLOB) missing it desyncs the
next short read on the receiving side.
"""
self._sink.write(data)
if len(data) & 1:
self._sink.write(b"\x00")
# -- fixed-width integers ---------------------------------------------
def write_short(self, value: int) -> None:
"""Write a 2-byte big-endian signed short."""
self._sink.write(_FMT_SHORT.pack(value))
def write_int(self, value: int) -> None:
"""Write a 4-byte big-endian signed int."""
self._sink.write(_FMT_INT.pack(value))
def write_long_bigint(self, value: int) -> None:
"""Write an 8-byte big-endian signed long (BIGINT type wire format)."""
self._sink.write(_FMT_LONG.pack(value))
def write_real(self, value: float) -> None:
"""Write a 4-byte IEEE 754 single (Informix REAL / SMALLFLOAT)."""
self._sink.write(_FMT_FLOAT.pack(value))
def write_double(self, value: float) -> None:
"""Write an 8-byte IEEE 754 double (Informix FLOAT / DOUBLE PRECISION)."""
self._sink.write(_FMT_DOUBLE.pack(value))
# -- length-prefixed strings ------------------------------------------
def write_length_prefixed(self, payload: bytes) -> None:
"""Write ``[short len(payload)][payload]`` with no padding.
Mirrors a raw ``writeShort + writeBytes`` pair. The length is the
byte count of ``payload`` exactly.
"""
self.write_short(len(payload))
self._sink.write(payload)
def write_string_with_nul(self, text: str, encoding: str = "iso-8859-1") -> None:
"""Write ``[short len+1][bytes][nul]`` for a string + nul terminator.
This is the format used throughout the login PDU for free-text
fields (username, password, dbname, env-var names/values, etc).
The length-prefix declares "content + 1" because the trailing nul
terminator counts toward the on-wire byte count.
Default encoding is Latin-1 to match Informix's default GLS
(``CLIENT_LOCALE=en_US.8859-1``). Phase 6+ adds UTF-8 / GLS
multibyte support via ``writeBytesConstant`` style routines.
"""
encoded = text.encode(encoding)
self.write_short(len(encoded) + 1)
self._sink.write(encoded)
self._sink.write(b"\x00")
# ---------------------------------------------------------------------------
# Reader
# ---------------------------------------------------------------------------
class IfxStreamReader:
"""Streaming byte reader for parsing SQLI PDU responses.
Wraps a ``BinaryIO`` (typically a ``BufferedReader`` over the socket).
Mirrors the ``IfxDataInputStream`` API names. All reads are exact
a partial read raises ``ProtocolError``.
"""
def __init__(self, source: BinaryIO):
self._source = source
# -- raw bytes ---------------------------------------------------------
def read_exact(self, n: int) -> bytes:
"""Read exactly ``n`` bytes or raise ``ProtocolError`` on EOF."""
chunks: list[bytes] = []
remaining = n
while remaining > 0:
chunk = self._source.read(remaining)
if not chunk:
raise ProtocolError(
f"unexpected EOF: wanted {n} bytes, got {n - remaining}"
)
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def read_byte(self) -> int:
"""Read one byte and return it as an int (0..255)."""
return self.read_exact(1)[0]
def skip(self, n: int) -> None:
"""Discard the next ``n`` bytes."""
self.read_exact(n)
def read_padded(self, n: int) -> bytes:
"""Read ``n`` bytes; if ``n`` is odd, also consume one pad byte."""
data = self.read_exact(n)
if n & 1:
self.read_exact(1)
return data
# -- fixed-width integers ---------------------------------------------
def read_short(self) -> int:
"""Read a 2-byte big-endian signed short."""
return _FMT_SHORT.unpack(self.read_exact(2))[0]
def read_int(self) -> int:
"""Read a 4-byte big-endian signed int."""
return _FMT_INT.unpack(self.read_exact(4))[0]
def read_long_bigint(self) -> int:
"""Read an 8-byte big-endian signed long (BIGINT)."""
return _FMT_LONG.unpack(self.read_exact(8))[0]
def read_real(self) -> float:
"""Read a 4-byte IEEE 754 single."""
return _FMT_FLOAT.unpack(self.read_exact(4))[0]
def read_double(self) -> float:
"""Read an 8-byte IEEE 754 double."""
return _FMT_DOUBLE.unpack(self.read_exact(8))[0]
# -- length-prefixed strings ------------------------------------------
def read_length_prefixed(self) -> bytes:
"""Read ``[short length][length bytes]`` and return the payload."""
length = self.read_short()
if length < 0:
raise ProtocolError(f"negative length prefix: {length}")
return self.read_exact(length)
def read_string_with_nul(self, encoding: str = "iso-8859-1") -> str:
"""Read ``[short len+1][bytes][nul]`` and return the decoded string."""
length = self.read_short()
if length < 0:
raise ProtocolError(f"negative string length: {length}")
if length == 0:
return ""
raw = self.read_exact(length)
# Strip trailing nul if present (length includes the nul byte).
if raw.endswith(b"\x00"):
raw = raw[:-1]
return raw.decode(encoding)
def make_pdu_writer() -> tuple[IfxStreamWriter, BytesIO]:
"""Convenience: create a writer backed by a fresh in-memory buffer.
Returns ``(writer, buffer)``. The caller writes through the writer,
then sends ``buffer.getvalue()`` over the socket as one ``write_all``
call. Assembling PDUs into BytesIO before flushing means the socket
sees one well-formed PDU per ``send()`` rather than a stream of small
writes that Nagle could fragment unhelpfully.
"""
buf = BytesIO()
return IfxStreamWriter(buf), buf

112
src/informix_db/_socket.py Normal file
View File

@ -0,0 +1,112 @@
"""Raw socket I/O wrapper.
Thin shim over Python's ``socket.socket`` providing exact-N reads and
exact-N writes with timeout handling. Internal-only the public Connection
class composes one of these with the protocol layer.
Mirrors PyMySQL's ``Connection._read_bytes`` / ``_write_bytes`` shape.
TCP_NODELAY is enabled by default to match IBM JDBC's behavior
(see ``com.informix.asf.Connection.openSocket``).
"""
from __future__ import annotations
import contextlib
import socket
from .exceptions import InterfaceError, OperationalError
class IfxSocket:
"""Owns a connected TCP socket and provides exact-N read/write."""
def __init__(
self,
host: str,
port: int,
connect_timeout: float | None = None,
read_timeout: float | None = None,
keepalive: bool = False,
):
self._host = host
self._port = port
self._read_timeout = read_timeout
self._sock: socket.socket | None = None
try:
sock = socket.create_connection((host, port), timeout=connect_timeout)
except OSError as e:
raise OperationalError(
f"cannot connect to Informix at {host}:{port}: {e}"
) from e
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if keepalive:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
if read_timeout is not None:
sock.settimeout(read_timeout)
else:
# connect_timeout was set on create_connection; clear it now.
sock.settimeout(None)
self._sock = sock
@property
def closed(self) -> bool:
return self._sock is None
def write_all(self, data: bytes) -> None:
"""Write ``data`` in full or raise on partial send."""
if self._sock is None:
raise InterfaceError("socket is closed")
try:
self._sock.sendall(data)
except OSError as e:
self._force_close()
raise OperationalError(f"write failed: {e}") from e
def read_exact(self, n: int) -> bytes:
"""Read exactly ``n`` bytes or raise on EOF / timeout."""
if self._sock is None:
raise InterfaceError("socket is closed")
chunks: list[bytes] = []
remaining = n
while remaining > 0:
try:
chunk = self._sock.recv(remaining)
except OSError as e:
self._force_close()
raise OperationalError(f"read failed: {e}") from e
if not chunk:
self._force_close()
raise OperationalError(
f"server closed connection mid-read "
f"(wanted {n} bytes, got {n - remaining})"
)
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def close(self) -> None:
"""Close the socket. Idempotent and never raises."""
if self._sock is None:
return
with contextlib.suppress(OSError):
self._sock.shutdown(socket.SHUT_RDWR)
with contextlib.suppress(OSError):
self._sock.close()
self._sock = None
def _force_close(self) -> None:
"""Best-effort close used in error paths."""
if self._sock is None:
return
with contextlib.suppress(OSError):
self._sock.close()
self._sock = None
def __enter__(self) -> IfxSocket:
return self
def __exit__(self, *_exc: object) -> None:
self.close()

View File

@ -0,0 +1,375 @@
"""SQLI connection management — login PDU assembly, send, parse, close.
This is the Phase 1 minimum: open socket, send the binary login PDU,
parse the server response, expose ``close()`` (which sends ``SQ_EXIT``).
``cursor()`` is a stub it lands in Phase 2.
The login PDU layout is documented byte-by-byte in
``docs/PROTOCOL_NOTES.md`` §3 and validated against the captured JDBC
reference in ``docs/CAPTURES/01-connect-only.socat.log``.
"""
from __future__ import annotations
import os
import socket as socket_mod
import struct
import threading
from io import BytesIO
from pathlib import Path
from . import _auth
from ._messages import (
APPL_ID,
APPL_TYPE,
ASF_XCONNECT,
CLIENT_SERIAL,
CLIENT_VERSION,
FLOAT_TYPE,
NET_TLITCP,
PROT_SQLIOL,
UTYPE_INTERNET,
LoginMarker,
MessageType,
SLHeader,
StmtOptions,
)
from ._protocol import IfxStreamReader, IfxStreamWriter, ProtocolError, make_pdu_writer
from ._socket import IfxSocket
from .exceptions import InterfaceError, OperationalError
# Default capability bits the JDBC reference sends. Captured from
# 01-connect-only.socat.log: Cap_1=1, Cap_2=0x3c000000, Cap_3=0.
# Server echoes these back. Their exact bit semantics are unmapped; for
# now we send what JDBC sends so the server treats us as a known peer.
_DEFAULT_CAP_1 = 1
_DEFAULT_CAP_2 = 0x3C000000
_DEFAULT_CAP_3 = 0
# Default environment variables sent in the login PDU (SQ_ASCENV section).
# These match what the JDBC driver sends for a vanilla en_US.8859-1
# connection. Anything missing makes the server fall back to defaults.
_DEFAULT_ENV: dict[str, str] = {
"DBPATH": ".",
"CLIENT_LOCALE": "en_US.8859-1",
"CLNT_PAM_CAPABLE": "1",
"DBDATE": "Y4MD-",
"IFX_UPDDESC": "1",
"NODEFDAC": "no",
}
class Connection:
"""A SQLI session. Owns one TCP socket and the post-login state.
Constructed via :func:`informix_db.connect`, not directly.
"""
def __init__(
self,
host: str,
port: int,
user: str,
password: str | None,
database: str | None,
server: str,
*,
connect_timeout: float | None = None,
read_timeout: float | None = None,
keepalive: bool = False,
client_locale: str = "en_US.8859-1",
env: dict[str, str] | None = None,
autocommit: bool = False, # honored from Phase 3 onward
):
self._host = host
self._port = port
self._user = user
self._database = database
self._server = server
self._client_locale = client_locale
self._autocommit = autocommit
self._closed = False
self._lock = threading.Lock()
# Build the env-var dict sent in the login PDU.
self._env = dict(_DEFAULT_ENV)
self._env["CLIENT_LOCALE"] = client_locale
if env:
self._env.update(env)
self._sock = IfxSocket(
host, port,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
keepalive=keepalive,
)
try:
login_pdu = self._build_login_pdu(user, password)
self._sock.write_all(login_pdu)
self._parse_login_response()
except Exception:
self._sock.close()
self._closed = True
raise
# -- public API surface (PEP 249-shaped, minimal in Phase 1) -----------
@property
def closed(self) -> bool:
return self._closed
def cursor(self):
"""Return a Cursor. NOT IMPLEMENTED in Phase 1."""
raise NotImplementedError(
"Cursor is implemented in Phase 2; "
"Phase 1 only validates connect() / close()."
)
def commit(self) -> None:
"""No-op in Phase 1 (transactions land in Phase 3)."""
if self._closed:
raise InterfaceError("connection is closed")
def rollback(self) -> None:
"""No-op in Phase 1 (transactions land in Phase 3)."""
if self._closed:
raise InterfaceError("connection is closed")
def close(self) -> None:
"""Send SQ_EXIT and tear down the socket. Idempotent."""
with self._lock:
if self._closed:
return
self._closed = True
try:
self._send_exit()
finally:
self._sock.close()
def __enter__(self) -> Connection:
return self
def __exit__(self, *_exc: object) -> None:
self.close()
# -- login PDU assembly ------------------------------------------------
def _build_login_pdu(self, user: str, password: str | None) -> bytes:
"""Assemble the full client→server login PDU.
Returns the SLheader (6 bytes) prepended to the PFheader payload.
Layout per PROTOCOL_NOTES.md §3.
"""
# Build the PFheader (variable-size body).
pf_writer, pf_buf = make_pdu_writer()
self._write_pf_payload(pf_writer, user, password)
pf_bytes = pf_buf.getvalue()
# Prepend the SLheader (6 bytes: total length, type, attr, opts).
sl_writer, sl_buf = make_pdu_writer()
sl_writer.write_short(len(pf_bytes) + 6) # total PDU size incl. header
sl_writer.write_byte(SLHeader.SLTYPE_CONREQ) # 1 = connection request
sl_writer.write_byte(SLHeader.PF_PROT_SQLI_0600) # 60 = protocol version
sl_writer.write_short(0) # slOptions (0 in vanilla connect)
return sl_buf.getvalue() + pf_bytes
def _write_pf_payload(
self,
w: IfxStreamWriter,
user: str,
password: str | None,
) -> None:
"""Write the PFheader (binary login body), per PROTOCOL_NOTES §3b."""
# Association markers
w.write_short(LoginMarker.SQ_ASSOC)
w.write_short(LoginMarker.SQ_ASCBINARY)
w.write_int(61) # observed magic (probably PF_PROT_SQLI_WITH_CSS)
# Float-type identifier
w.write_short(len(FLOAT_TYPE) + 1)
w.write_bytes(FLOAT_TYPE)
w.write_byte(0)
# Binary parameters block start
w.write_short(LoginMarker.SQ_ASCBPARMS)
w.write_bytes(APPL_TYPE) # 12 bytes "sqlexec\0\0\0\0\0", no length prefix
# Client version (hardcoded "9.280")
w.write_short(len(CLIENT_VERSION) + 1)
w.write_bytes(CLIENT_VERSION)
w.write_byte(0)
# Client serial (hardcoded "RDS#R000000")
w.write_short(len(CLIENT_SERIAL) + 1)
w.write_bytes(CLIENT_SERIAL)
w.write_byte(0)
# Application ID
w.write_short(len(APPL_ID) + 1)
w.write_bytes(APPL_ID)
w.write_byte(0)
# Three negotiated capability ints (Cap_1, Cap_2, Cap_3)
w.write_int(_DEFAULT_CAP_1)
w.write_int(_DEFAULT_CAP_2)
w.write_int(_DEFAULT_CAP_3)
# ?? section: short 1 (purpose unknown; observed in capture)
w.write_short(1)
# Username + password — delegated to the auth handler
_auth.write_plain_password(w, user, password)
# Protocol & network identifiers
w.write_bytes(PROT_SQLIOL) # "ol\0\0\0\0\0\0"
w.write_int(61) # observed magic
w.write_bytes(NET_TLITCP) # "tlitcp\0\0"
w.write_int(UTYPE_INTERNET) # 1
# Init-request marker block
w.write_short(LoginMarker.SQ_ASCINITREQ)
w.write_short(ASF_XCONNECT)
w.write_int(StmtOptions.ASF_AMBIG_SEOL) # 3 in vanilla connect
# Server name
w.write_string_with_nul(self._server)
# Database (optional)
if self._database is None:
w.write_short(0)
else:
w.write_string_with_nul(self._database)
# 4 reserved/empty option slots (8 bytes total)
for _ in range(4):
w.write_short(0)
# Environment vars
w.write_short(LoginMarker.SQ_ASCENV)
w.write_short(len(self._env))
for name, value in self._env.items():
w.write_string_with_nul(name)
w.write_string_with_nul(value)
# Process info
w.write_short(LoginMarker.SQ_ASCPINFO)
w.write_int(0) # reserved
w.write_int(os.getpid() & 0x7FFFFFFF)
# threading.get_ident() can exceed signed 32-bit on long-running
# processes; the JDBC reference catches NumberFormatException and
# falls back to 0. We do the same — the field is diagnostic only.
tid = threading.get_ident()
w.write_int(tid if 0 <= tid <= 0x7FFFFFFF else 0)
hostname = socket_mod.gethostname() or "unknown"
w.write_string_with_nul(hostname)
w.write_short(0) # reserved
cwd = str(Path.cwd()) or ""
w.write_string_with_nul(cwd)
# AppName section (SQ_ASCMISC_60)
appname = f"informix-db@pid{os.getpid()}"
w.write_short(LoginMarker.SQ_ASCMISC_60)
w.write_short(10 + len(appname) + 1)
w.write_int(0)
w.write_int(0)
w.write_string_with_nul(appname)
# End-of-PDU marker
w.write_short(LoginMarker.SQ_ASCEOT)
# -- response parsing -------------------------------------------------
def _parse_login_response(self) -> None:
"""Read and parse the server's login response.
Server returns either an SLTYPE_CONACC (success, with server
version + capabilities) or an SLTYPE_CONREJ (rejection, with
error block). We decode just enough to distinguish success from
failure for Phase 1; the full response decode (server version,
capabilities, etc.) lands as it becomes useful.
"""
# First two bytes: total response length (including this field)
length_bytes = self._sock.read_exact(2)
total_length = struct.unpack("!h", length_bytes)[0]
if total_length < 6:
raise ProtocolError(
f"login response too short: {total_length} bytes"
)
# Read the rest of the SLheader + payload
rest = self._sock.read_exact(total_length - 2)
reader = IfxStreamReader(BytesIO(rest))
sl_type = reader.read_byte()
sl_attribute = reader.read_byte() # noqa: F841 — read for stream alignment
sl_options = reader.read_short() # noqa: F841
if sl_type == SLHeader.SLTYPE_CONREJ:
# Rejection — pull out the error message if we can
self._raise_from_rejection(reader)
elif sl_type == SLHeader.SLTYPE_REDIRECT:
raise OperationalError(
"server sent a connection redirect; this driver doesn't "
"follow redirects yet (Phase 6+)"
)
elif sl_type != SLHeader.SLTYPE_CONACC:
raise ProtocolError(f"unknown SLType in login response: {sl_type}")
# SLTYPE_CONACC — connection accepted. We don't (yet) decode the
# full server-side metadata. Phase 1 just needs to know "we got in".
def _raise_from_rejection(self, reader: IfxStreamReader) -> None:
"""Best-effort decode of the connection-rejection error block.
Per PROTOCOL_NOTES.md §3c-d. We try to extract the SQLCODE and
message, but if the layout drifts we raise a generic
OperationalError with whatever bytes we read.
"""
try:
# Skip the SQ_ASSOC + SQ_ASCBINARY markers and the int 61 magic
reader.skip(2 + 2 + 4)
# Then there's a length-prefixed block we skip
sub_length = reader.read_short()
reader.skip(sub_length)
# Then SQ_ASCBPARMS marker
marker = reader.read_short()
if marker != LoginMarker.SQ_ASCBPARMS:
raise OperationalError(
"server rejected the connection (no decodable error block)"
)
# Skip 12 bytes of fixed-position metadata, then the version
# string, serial, applid, capabilities — we don't need any of
# that on the failure path, so we just bail out with a clear
# message. Phase 5 expands this to actually find the SQ_ASCINITRESP
# block and pull svcError/osError/Warnings/errMsg.
raise OperationalError("server rejected the connection")
except ProtocolError as e:
raise OperationalError(f"server rejected the connection: {e}") from e
# -- disconnection ----------------------------------------------------
def _send_exit(self) -> None:
"""Send the bare ``[short SQ_EXIT=56]`` disconnect message.
Per PROTOCOL_NOTES.md §8. Server echoes back ``SQ_EXIT`` or
``SQ_EOT``; we read and discard. Errors are swallowed because
we're already tearing down.
"""
try:
self._sock.write_all(struct.pack("!h", MessageType.SQ_EXIT))
# Read the ack — server may interleave one or more SQ_XACTSTAT
# records, so loop until we see SQ_EXIT or SQ_EOT.
for _ in range(8): # bounded loop just in case
tag = struct.unpack("!h", self._sock.read_exact(2))[0]
if tag in (MessageType.SQ_EXIT, MessageType.SQ_EOT):
return
# SQ_XACTSTAT (99) carries 6 bytes of status info we don't need.
if tag == 99:
self._sock.read_exact(6)
continue
# Unknown ack; bail out — we're closing anyway.
return
except (OperationalError, InterfaceError, OSError, ProtocolError):
# Already closing; nothing to do but suppress.
return

View File

@ -0,0 +1,67 @@
"""PEP 249 (DB-API 2.0) exception hierarchy.
The full tree is declared up front so the API surface is stable from Phase 1
onward, even though only ``InterfaceError`` and ``OperationalError`` are
raised this phase. Later phases progressively populate the rest as the
relevant code paths land (DML in Phase 3 IntegrityError; prepared
statements in Phase 4 ProgrammingError; etc.).
"""
from __future__ import annotations
class Warning(Exception):
"""PEP 249 Warning — base for non-fatal informational events.
Yes, this shadows the builtin ``Warning``. PEP 249 mandates the name.
"""
class Error(Exception):
"""PEP 249 Error — base for all driver exceptions other than ``Warning``."""
class InterfaceError(Error):
"""Driver/interface-level problem (bad arguments, closed connection used)."""
class DatabaseError(Error):
"""Server-side problem reported via the SQLI error response."""
class DataError(DatabaseError):
"""Data-related error (out-of-range value, division by zero, bad cast)."""
class OperationalError(DatabaseError):
"""Server-side operational problem (auth failure, network drop, deadlock)."""
class IntegrityError(DatabaseError):
"""Constraint violation (FK, unique, NOT NULL, check)."""
class InternalError(DatabaseError):
"""Server reports an internal/inconsistent state."""
class ProgrammingError(DatabaseError):
"""Bad SQL syntax, missing object, parameter count mismatch."""
class NotSupportedError(DatabaseError):
"""Operation not supported by this driver or by the connected server."""
def error_from_server_response(
sqlstate: str | None,
sqlcode: int,
message: str,
) -> Error:
"""Map a server error response to the appropriate PEP 249 exception class.
Phase 5 fills in the SQLSTATE-class dispatch (08xxx OperationalError,
23xxx IntegrityError, 42xxx ProgrammingError, etc.). For Phase 1 we
return a generic ``DatabaseError`` so the call site is stable now.
"""
return DatabaseError(f"[SQLSTATE={sqlstate} SQLCODE={sqlcode}] {message}")

0
tests/__init__.py Normal file
View File

83
tests/conftest.py Normal file
View File

@ -0,0 +1,83 @@
"""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()

23
tests/docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
# Informix Developer Edition pinned by digest — captured 2026-05-02 in DECISION_LOG.md.
# Pin by digest, not :latest, to keep the test suite reproducible across machines.
# To start: make ifx-up (or: docker compose -f tests/docker-compose.yml up -d)
# To stop: make ifx-down (or: docker compose -f tests/docker-compose.yml down)
services:
ifx:
container_name: informix-db-test
image: icr.io/informix/informix-developer-database@sha256:8202d69ba5674df4b13140d5121dd11b7b26b28dc60119b7e8f87e533e538ba1
privileged: true
environment:
LICENSE: accept
SIZE: small
ports:
- "9088:9088"
healthcheck:
# Listener is up when port 9088 accepts a TCP connection. The
# underlying engine takes ~60-180s to fully initialize on first start;
# the healthcheck retries cover that window.
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9088 && exec 3<&- && exec 3>&-"]
interval: 5s
timeout: 3s
retries: 60
start_period: 30s

163
tests/test_protocol.py Normal file
View File

@ -0,0 +1,163 @@
"""Unit tests for ``_protocol.py`` framing primitives.
These run without a database they assert that the encoders produce
exactly the bytes IBM's JDBC reference would produce, and that the
decoders round-trip cleanly. The byte expectations come from the
captured login PDU in ``docs/CAPTURES/01-connect-only.socat.log``.
"""
from __future__ import annotations
from io import BytesIO
import pytest
from informix_db._protocol import IfxStreamReader, IfxStreamWriter, ProtocolError
def make_writer() -> tuple[IfxStreamWriter, BytesIO]:
buf = BytesIO()
return IfxStreamWriter(buf), buf
# ---------------------------------------------------------------------------
# Endianness
# ---------------------------------------------------------------------------
class TestEndianness:
"""All multi-byte integers MUST be big-endian (network byte order)."""
def test_short_is_big_endian(self) -> None:
w, buf = make_writer()
w.write_short(0x1234)
assert buf.getvalue() == b"\x12\x34"
def test_int_is_big_endian(self) -> None:
w, buf = make_writer()
w.write_int(0x12345678)
assert buf.getvalue() == b"\x12\x34\x56\x78"
def test_long_bigint_is_big_endian(self) -> None:
w, buf = make_writer()
w.write_long_bigint(0x123456789ABCDEF0)
assert buf.getvalue() == b"\x12\x34\x56\x78\x9a\xbc\xde\xf0"
def test_observed_capability_int_matches(self) -> None:
"""Cap_2 from the captured PDU is 0x3c000000 — verify our encoder hits it."""
w, buf = make_writer()
w.write_int(0x3C000000)
assert buf.getvalue() == b"\x3c\x00\x00\x00"
# ---------------------------------------------------------------------------
# Padding
# ---------------------------------------------------------------------------
class TestPadding:
"""Variable-length payloads must be padded to even byte alignment."""
def test_even_length_no_padding(self) -> None:
w, buf = make_writer()
w.write_padded(b"\x01\x02")
assert buf.getvalue() == b"\x01\x02"
def test_odd_length_pads_with_nul(self) -> None:
w, buf = make_writer()
w.write_padded(b"\x01\x02\x03")
assert buf.getvalue() == b"\x01\x02\x03\x00"
def test_empty_no_padding(self) -> None:
w, buf = make_writer()
w.write_padded(b"")
assert buf.getvalue() == b""
# ---------------------------------------------------------------------------
# Length-prefixed strings
# ---------------------------------------------------------------------------
class TestStringEncoding:
"""``write_string_with_nul`` must produce ``[short len+1][bytes][nul]``."""
def test_simple_ascii(self) -> None:
"""The username 'informix' from the captured login: 9 bytes (8+nul)."""
w, buf = make_writer()
w.write_string_with_nul("informix")
assert buf.getvalue() == b"\x00\x09informix\x00"
def test_password_in4mix_matches_capture(self) -> None:
"""The password 'in4mix' from the captured login: 7 bytes (6+nul)."""
w, buf = make_writer()
w.write_string_with_nul("in4mix")
assert buf.getvalue() == b"\x00\x07in4mix\x00"
def test_empty_string(self) -> None:
w, buf = make_writer()
w.write_string_with_nul("")
# Empty string: length=1 (just the nul), content=just the nul.
assert buf.getvalue() == b"\x00\x01\x00"
# ---------------------------------------------------------------------------
# Reader round-trips
# ---------------------------------------------------------------------------
class TestRoundTrip:
"""Every encoder must round-trip through its matching decoder."""
@pytest.mark.parametrize("value", [0, 1, -1, 32767, -32768, 0x1234])
def test_short_round_trip(self, value: int) -> None:
w, buf = make_writer()
w.write_short(value)
r = IfxStreamReader(BytesIO(buf.getvalue()))
assert r.read_short() == value
@pytest.mark.parametrize("value", [0, 1, -1, 0x7FFFFFFF, -0x80000000, 0x3C000000])
def test_int_round_trip(self, value: int) -> None:
w, buf = make_writer()
w.write_int(value)
r = IfxStreamReader(BytesIO(buf.getvalue()))
assert r.read_int() == value
@pytest.mark.parametrize("value", [0, 1, -1, 0x7FFFFFFFFFFFFFFF, -0x8000000000000000])
def test_long_bigint_round_trip(self, value: int) -> None:
w, buf = make_writer()
w.write_long_bigint(value)
r = IfxStreamReader(BytesIO(buf.getvalue()))
assert r.read_long_bigint() == value
@pytest.mark.parametrize("text", ["", "informix", "in4mix", "hello world"])
def test_string_with_nul_round_trip(self, text: str) -> None:
w, buf = make_writer()
w.write_string_with_nul(text)
r = IfxStreamReader(BytesIO(buf.getvalue()))
assert r.read_string_with_nul() == text
# ---------------------------------------------------------------------------
# Failure modes
# ---------------------------------------------------------------------------
class TestFailureModes:
"""Truncated or malformed streams must raise ``ProtocolError``, not silently misbehave."""
def test_short_eof_raises(self) -> None:
r = IfxStreamReader(BytesIO(b"\x12")) # 1 byte, want 2
with pytest.raises(ProtocolError, match="unexpected EOF"):
r.read_short()
def test_int_eof_raises(self) -> None:
r = IfxStreamReader(BytesIO(b"\x00\x00\x00")) # 3 bytes, want 4
with pytest.raises(ProtocolError, match="unexpected EOF"):
r.read_int()
def test_negative_string_length_rejected(self) -> None:
# A short with the high bit set parses as negative; should fail fast.
r = IfxStreamReader(BytesIO(b"\xff\xff"))
with pytest.raises(ProtocolError, match="negative string length"):
r.read_string_with_nul()

86
tests/test_smoke.py Normal file
View File

@ -0,0 +1,86 @@
"""Phase 1 integration smoke tests — connect, auth-fail, network-fail.
Marked ``integration`` so the default ``pytest`` invocation skips them.
Run with ``pytest -m integration`` after ``docker compose up`` (or with
the container already running).
"""
from __future__ import annotations
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
def test_connect_and_close(conn_params: ConnParams) -> None:
"""The happy path: connect with valid creds, then close cleanly."""
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,
)
assert conn.closed is False
conn.close()
assert conn.closed is True
def test_close_is_idempotent(conn_params: ConnParams) -> None:
"""``close()`` must be safely callable multiple times."""
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,
)
conn.close()
conn.close() # must not raise
assert conn.closed is True
def test_context_manager(conn_params: ConnParams) -> None:
"""``with`` block closes on exit."""
with 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,
) as conn:
assert conn.closed is False
assert conn.closed is True
def test_bad_password_raises_operational_error(conn_params: ConnParams) -> None:
"""Auth failure must be ``OperationalError``, not a generic socket error."""
with pytest.raises(informix_db.OperationalError):
informix_db.connect(
host=conn_params.host, port=conn_params.port,
user=conn_params.user, password="definitely-the-wrong-password",
database=conn_params.database, server=conn_params.server,
connect_timeout=5.0, read_timeout=5.0,
)
def test_bad_host_raises_operational_error(conn_params: ConnParams) -> None:
"""Network-level failure must also be ``OperationalError``."""
# Pick an unused port on loopback.
with pytest.raises(informix_db.OperationalError, match="cannot connect"):
informix_db.connect(
host="127.0.0.1", port=1, # IANA-reserved, nothing listens
user="x", password="x", database="x", server="x",
connect_timeout=2.0,
)
def test_cursor_not_yet_implemented(conn_params: ConnParams) -> None:
"""Phase 1 declares ``cursor()`` as NotImplementedError; Phase 2 lands it."""
with 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,
) as conn, pytest.raises(NotImplementedError, match="Phase 2"):
conn.cursor()

184
uv.lock generated Normal file
View File

@ -0,0 +1,184 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "informix-db"
version = "2026.5.2"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
]
provides-extras = ["dev"]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "ruff"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]