informix-db/pyproject.toml
Ryan Malloy 6afdbcabb3 Phase 27: Wire lock + async cancellation eviction (2026.05.05.1)
Closes Hamilton audit Critical #2 (concurrency / wire lock) and
High #3 (async cancellation evicts cleanly). Phase 26 fixed what
gets returned to the pool; Phase 27 fixes what can interleave on
the wire while it's running.

What changed:

connections.py:
* Added Connection._wire_lock = threading.RLock(). Wrapped commit(),
  rollback(), fast_path_call() under the lock.
* _ensure_transaction documents the lock as a precondition AND
  asserts ownership at runtime (_wire_lock._is_owned()) so a future
  caller adding a third call site fails loudly.
* close() tries to acquire wire lock with 0.5s timeout before
  SQ_EXIT; skips polite exit and force-closes if busy.

cursors.py:
* execute() body extracted into _execute_under_wire_lock() and
  called under the lock.
* executemany() body wrapped inline.
* _sfetch_at() wrapped - covers all scrollable fetch_* methods
  that delegate to it.
* close() locks the CLOSE+RELEASE for scrollable cursors.

pool.py:
* release() acquires conn._wire_lock with 5s timeout before rollback.
  On timeout: log WARNING, evict connection. Constant
  _RELEASE_WIRE_LOCK_TIMEOUT for tunability.

aio.py:
* AsyncConnectionPool.connection() now catches CancelledError /
  TimeoutError separately and routes to broken=True. Combined with
  the wire lock, asyncio.wait_for around aio DB calls is now safe.
* Updated docstring; mirrored in docs/USAGE.md.

Margaret Hamilton review surfaced three actionable conditions, all
addressed before tagging:
* Cancellation test used contextlib.suppress - could pass without
  exercising the cancellation path on a fast runner. Switched to
  pytest.raises so the test fails if timeout doesn't fire.
* _ensure_transaction precondition documented but unchecked at
  runtime. Added assert self._wire_lock._is_owned() guard.
* Connection.close() was unsynchronized. Now tries 0.5s acquire
  before SQ_EXIT.

Two new regression tests in tests/test_pool.py:
* test_concurrent_threads_on_one_connection_dont_interleave_pdus
  (without lock: garbled results / hangs)
* test_async_wait_for_cancellation_evicts_connection
  (asserts pool size shrinks; cancellation actually fires)

72 unit + 228 integration + 28 benchmark = 328 tests; ruff clean.

Hamilton verdict: PRODUCTION READY WITH CAVEATS (was) -> CAVEATS
NARROWED FURTHER (now). 0 critical, 2 high remaining (cursor
finalizers + bare-except in error drain) - both Phase 28 scope.
2026-05-05 03:40:39 -06:00

108 lines
3.4 KiB
TOML

[project]
name = "informix-db"
version = "2026.05.05.1"
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", "asyncio", "async"]
classifiers = [
"Development Status :: 4 - Beta",
"Framework :: AsyncIO",
"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"]
asyncio_mode = "auto" # pytest-asyncio: auto-detect ``async def`` tests
addopts = [
"-ra", # short summary for non-passing
"--strict-markers",
"--strict-config",
"-m", "not integration and not benchmark", # default: unit-only. Override with: pytest -m integration / -m benchmark
]
markers = [
"integration: requires a running Informix container (docker compose up); skipped by default",
"benchmark: pytest-benchmark performance test; skipped by default. Run with `make bench`.",
]
[dependency-groups]
dev = [
"pytest-asyncio>=1.3.0",
"pytest-benchmark>=5.2.3",
]