1 Commits
Author SHA1 Message Date
dsql a358a417b4 release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 21:21:00 -04:00
3 changed files with 58 additions and 47 deletions

No files matched your search

+3 -3
View File
@@ -11,18 +11,18 @@ wire-compatible and share the driver, so this covers both.
`requirements.txt`: `requirements.txt`:
``` ```
mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v1.2.0 mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.6
``` ```
Direct: Direct:
```bash ```bash
pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v1.2.0" pip install "mysql @ git+ssh://git@git.rethinkstudios.io/rethink-public/mysql.git@v0.1.6"
``` ```
Pulls `aiomysql` (which pulls `PyMySQL`). Pulls `aiomysql` (which pulls `PyMySQL`).
Drop the `@v1.2.0` suffix from the line above to install the latest unpinned. Drop the `@v0.1.6` suffix from the line above to install the latest unpinned.
## The two-layer API ## The two-layer API
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mysql" name = "mysql"
version = "1.2.0" version = "1.0.0"
description = "async mysql/mariadb wrapper over aiomysql: two-layer API (friendly verbs + raw escape hatch), fail-loud, config-free" description = "async mysql/mariadb wrapper over aiomysql: two-layer API (friendly verbs + raw escape hatch), fail-loud, config-free"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+54 -43
View File
@@ -17,16 +17,12 @@ a literal `%` in layer-2 SQL text must be written `%%` only when params are pass
1 handles this for you. 1 handles this for you.
errors are FAIL LOUD (unlike the mongo lib's swallow-and-default): every method catches errors are FAIL LOUD (unlike the mongo lib's swallow-and-default): every method catches
the driver error and re-raises it - the raised exception IS the signal, and the caller the driver error, logs via getLogger(__name__), and re-raises. a None/[] return is only
(which alone knows whether a failure is fatal or routine) decides and logs. the wrapped ever a real result, never a swallowed failure. for anything not wrapped, use `.pool`.
method itself logs only at DEBUG (with the traceback) so a failure isn't reported twice
(raise XOR log). a None/[] return is only ever a real result, never a swallowed failure.
for anything not wrapped, use `.pool`.
""" """
import asyncio import asyncio
import logging import logging
from contextlib import asynccontextmanager
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
import aiomysql import aiomysql
@@ -139,7 +135,7 @@ class MysqlDB:
await cur.execute("SELECT 1") await cur.execute("SELECT 1")
await cur.fetchone() await cur.fetchone()
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.connect() failed", exc_info=True) log.exception("mysql.connect() failed")
if pool is not None: if pool is not None:
pool.close() pool.close()
await pool.wait_closed() await pool.wait_closed()
@@ -170,7 +166,7 @@ class MysqlDB:
self._pool.close() self._pool.close()
await self._pool.wait_closed() await self._pool.wait_closed()
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.close()", exc_info=True) log.exception("mysql.close()")
raise raise
finally: finally:
self._pool = None self._pool = None
@@ -199,20 +195,18 @@ class MysqlDB:
async def _run(self, query: str, params: Optional[Sequence] = None): async def _run(self, query: str, params: Optional[Sequence] = None):
"""execute a statement on a pooled cursor; return (rowcount, lastrowid) """execute a statement on a pooled cursor; return (rowcount, lastrowid)
params are passed to aiomysql as-is: layer 1 always hands a list (even empty) so params go through `_norm_params` (see it for the no-substitution/str-bytes handling)
`query % args` runs and collapses `_quote_ident`'s `%%`; layer 2 normalizes first
via `_norm_params`
""" """
async with self.pool.acquire() as conn: async with self.pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
await cur.execute(query, params) await cur.execute(query, _norm_params(params))
return cur.rowcount, cur.lastrowid return cur.rowcount, cur.lastrowid
async def _fetchall(self, query: str, params: Optional[Sequence] = None) -> List[dict]: async def _fetchall(self, query: str, params: Optional[Sequence] = None) -> List[dict]:
"""execute a query and return all rows as dicts""" """execute a query and return all rows as dicts"""
async with self.pool.acquire() as conn: async with self.pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
await cur.execute(query, params) await cur.execute(query, _norm_params(params))
rows = await cur.fetchall() rows = await cur.fetchall()
return list(rows) return list(rows)
@@ -220,7 +214,7 @@ class MysqlDB:
"""execute a query and return the first row dict, or None""" """execute a query and return the first row dict, or None"""
async with self.pool.acquire() as conn: async with self.pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
await cur.execute(query, params) await cur.execute(query, _norm_params(params))
return await cur.fetchone() return await cur.fetchone()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -233,34 +227,34 @@ class MysqlDB:
lastrowid, or reach for the raw pool. lastrowid, or reach for the raw pool.
""" """
try: try:
rowcount, _ = await self._run(query, _norm_params(params)) rowcount, _ = await self._run(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.execute(): %s", query, exc_info=True) log.exception("mysql.execute(): %s", query)
raise raise
return rowcount return rowcount
async def fetch(self, query: str, params: Optional[Sequence] = None) -> List[dict]: async def fetch(self, query: str, params: Optional[Sequence] = None) -> List[dict]:
"""run a query and return all rows as plain dicts (empty list = no rows)""" """run a query and return all rows as plain dicts (empty list = no rows)"""
try: try:
return await self._fetchall(query, _norm_params(params)) return await self._fetchall(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.fetch(): %s", query, exc_info=True) log.exception("mysql.fetch(): %s", query)
raise raise
async def fetchone(self, query: str, params: Optional[Sequence] = None) -> Optional[dict]: async def fetchone(self, query: str, params: Optional[Sequence] = None) -> Optional[dict]:
"""run a query and return the first row as a dict, or None if no rows""" """run a query and return the first row as a dict, or None if no rows"""
try: try:
return await self._fetchone(query, _norm_params(params)) return await self._fetchone(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.fetchone(): %s", query, exc_info=True) log.exception("mysql.fetchone(): %s", query)
raise raise
async def fetchval(self, query: str, params: Optional[Sequence] = None) -> Any: async def fetchval(self, query: str, params: Optional[Sequence] = None) -> Any:
"""run a query and return the first column of the first row, or None""" """run a query and return the first column of the first row, or None"""
try: try:
row = await self._fetchone(query, _norm_params(params)) row = await self._fetchone(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.fetchval(): %s", query, exc_info=True) log.exception("mysql.fetchval(): %s", query)
raise raise
if not row: if not row:
return None return None
@@ -272,7 +266,7 @@ class MysqlDB:
commits on clean exit, rolls back and re-raises on any error. `conn` is a raw commits on clean exit, rolls back and re-raises on any error. `conn` is a raw
aiomysql connection (autocommit disabled for the block). aiomysql connection (autocommit disabled for the block).
""" """
return _transaction(self.pool) return _Transaction(self.pool)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# layer 1 - friendly portable verbs (identical to the psql lib) # layer 1 - friendly portable verbs (identical to the psql lib)
@@ -282,7 +276,7 @@ class MysqlDB:
try: try:
await self._run(f"CREATE DATABASE IF NOT EXISTS {_quote_ident_literal(name)}") await self._run(f"CREATE DATABASE IF NOT EXISTS {_quote_ident_literal(name)}")
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.create_database(%s)", name, exc_info=True) log.exception("mysql.create_database(%s)", name)
raise raise
async def create_table(self, name: str, schema: Dict[str, str]) -> None: async def create_table(self, name: str, schema: Dict[str, str]) -> None:
@@ -298,7 +292,7 @@ class MysqlDB:
try: try:
await self._run(f"CREATE TABLE IF NOT EXISTS {_quote_ident_literal(name)} ({cols})") await self._run(f"CREATE TABLE IF NOT EXISTS {_quote_ident_literal(name)} ({cols})")
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.create_table(%s)", name, exc_info=True) log.exception("mysql.create_table(%s)", name)
raise raise
async def drop(self, name: str, *, table: bool = True) -> None: async def drop(self, name: str, *, table: bool = True) -> None:
@@ -307,7 +301,7 @@ class MysqlDB:
try: try:
await self._run(f"DROP {kind} IF EXISTS {_quote_ident_literal(name)}") await self._run(f"DROP {kind} IF EXISTS {_quote_ident_literal(name)}")
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.drop(%s)", name, exc_info=True) log.exception("mysql.drop(%s)", name)
raise raise
async def insert(self, table: str, values: Dict[str, Any]) -> int: async def insert(self, table: str, values: Dict[str, Any]) -> int:
@@ -324,7 +318,7 @@ class MysqlDB:
try: try:
_, lastrowid = await self._run(query, list(values.values())) _, lastrowid = await self._run(query, list(values.values()))
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.insert(%s)", table, exc_info=True) log.exception("mysql.insert(%s)", table)
raise raise
return lastrowid return lastrowid
@@ -339,7 +333,7 @@ class MysqlDB:
try: try:
return await self._fetchall(query, params) return await self._fetchall(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.get(%s)", table, exc_info=True) log.exception("mysql.get(%s)", table)
raise raise
async def get_one(self, table: str, conditions: Optional[Dict[str, Any]] = None) -> Optional[dict]: async def get_one(self, table: str, conditions: Optional[Dict[str, Any]] = None) -> Optional[dict]:
@@ -349,7 +343,7 @@ class MysqlDB:
try: try:
return await self._fetchone(query, params) return await self._fetchone(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.get_one(%s)", table, exc_info=True) log.exception("mysql.get_one(%s)", table)
raise raise
async def delete(self, table: str, conditions: Optional[Dict[str, Any]] = None) -> int: async def delete(self, table: str, conditions: Optional[Dict[str, Any]] = None) -> int:
@@ -362,7 +356,7 @@ class MysqlDB:
try: try:
rowcount, _ = await self._run(query, params) rowcount, _ = await self._run(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.delete(%s)", table, exc_info=True) log.exception("mysql.delete(%s)", table)
raise raise
return rowcount return rowcount
@@ -373,7 +367,7 @@ class MysqlDB:
try: try:
row = await self._fetchone(query, params) row = await self._fetchone(query, params)
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.exists(%s)", table, exc_info=True) log.exception("mysql.exists(%s)", table)
raise raise
return bool(_first_value(row)) if row else False return bool(_first_value(row)) if row else False
@@ -396,27 +390,44 @@ class MysqlDB:
try: try:
rowcount, _ = await self._run(query, list(values.values())) rowcount, _ = await self._run(query, list(values.values()))
except _DRIVER_ERRORS: except _DRIVER_ERRORS:
log.debug("mysql.upsert(%s)", table, exc_info=True) log.exception("mysql.upsert(%s)", table)
raise raise
return rowcount return rowcount
@asynccontextmanager class _Transaction:
async def _transaction(pool: aiomysql.Pool): """async ctx mgr: acquire a pooled connection + run a transaction on it
"""acquire a pooled connection and run the block in a transaction on it
begin() disables autocommit for the block; commit on clean exit, rollback + re-raise autocommit is disabled for the block so the statements commit together on clean
on any error. the acquire context releases the connection on every path - including exit (or roll back on error).
a failed begin(), so a failed start never burns a pool slot
""" """
async with pool.acquire() as conn:
await conn.begin() def __init__(self, pool: aiomysql.Pool):
self._pool = pool
self._conn = None
async def __aenter__(self):
self._conn = await self._pool.acquire()
try: try:
yield conn await self._conn.begin()
except BaseException: except BaseException:
await conn.rollback() # __aexit__ never runs if __aenter__ raises, so begin() failing after acquire
# would otherwise leak the pooled connection - release it here instead.
await self._pool.release(self._conn)
self._conn = None
raise raise
await conn.commit() return self._conn
async def __aexit__(self, exc_type, exc, tb) -> None:
try:
if exc_type is None:
await self._conn.commit()
else:
await self._conn.rollback()
finally:
# release() returns a task (schedules a _wakeup); await it to avoid leaving
# it un-awaited each transaction, matching psql's and aiomysql's own idiom.
await self._pool.release(self._conn)
def _where(conditions: Optional[Dict[str, Any]]) -> tuple: def _where(conditions: Optional[Dict[str, Any]]) -> tuple: