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:
parent
6b59816f9a
commit
9b1fd8af2c
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
40
README.md
40
README.md
@ -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
98
pyproject.toml
Normal 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
104
src/informix_db/__init__.py
Normal 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
58
src/informix_db/_auth.py
Normal 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]
|
||||||
184
src/informix_db/_messages.py
Normal file
184
src/informix_db/_messages.py
Normal 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
|
||||||
224
src/informix_db/_protocol.py
Normal file
224
src/informix_db/_protocol.py
Normal 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
112
src/informix_db/_socket.py
Normal 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()
|
||||||
375
src/informix_db/connections.py
Normal file
375
src/informix_db/connections.py
Normal 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
|
||||||
67
src/informix_db/exceptions.py
Normal file
67
src/informix_db/exceptions.py
Normal 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
0
tests/__init__.py
Normal file
83
tests/conftest.py
Normal file
83
tests/conftest.py
Normal 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
23
tests/docker-compose.yml
Normal 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
163
tests/test_protocol.py
Normal 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
86
tests/test_smoke.py
Normal 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
184
uv.lock
generated
Normal 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" },
|
||||||
|
]
|
||||||
Loading…
x
Reference in New Issue
Block a user