feat: indie status page MVP -- FastAPI + SQLite

- 8 DB models (services, incidents, monitors, subscribers, etc.)
- Full CRUD API for services, incidents, monitors
- Public status page with live data
- Incident detail page with timeline
- API key authentication
- Uptime monitoring scheduler
- 13 tests passing
- TECHNICAL_DESIGN.md with full spec
This commit is contained in:
IndieStatusBot 2026-04-25 05:00:00 +00:00
commit 902133edd3
4655 changed files with 1342691 additions and 0 deletions

View file

@ -0,0 +1,44 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
"""asyncio bridge to the standard sqlite3 module"""
from sqlite3 import ( # pylint: disable=redefined-builtin
DatabaseError,
Error,
IntegrityError,
NotSupportedError,
OperationalError,
paramstyle,
ProgrammingError,
register_adapter,
register_converter,
Row,
sqlite_version,
sqlite_version_info,
Warning,
)
__author__ = "Amethyst Reese"
from .__version__ import __version__
from .core import connect, Connection, Cursor
__all__ = [
"__version__",
"paramstyle",
"register_adapter",
"register_converter",
"sqlite_version",
"sqlite_version_info",
"connect",
"Connection",
"Cursor",
"Row",
"Warning",
"Error",
"DatabaseError",
"IntegrityError",
"ProgrammingError",
"OperationalError",
"NotSupportedError",
]

View file

@ -0,0 +1,7 @@
"""
This file is automatically generated by attribution.
Do not edit manually. Get more info at https://attribution.omnilib.dev
"""
__version__ = "0.22.1"

View file

@ -0,0 +1,56 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
from collections.abc import Coroutine, Generator
from contextlib import AbstractAsyncContextManager
from functools import wraps
from typing import Any, Callable, TypeVar
from .cursor import Cursor
_T = TypeVar("_T")
class Result(AbstractAsyncContextManager[_T], Coroutine[Any, Any, _T]):
__slots__ = ("_coro", "_obj")
def __init__(self, coro: Coroutine[Any, Any, _T]):
self._coro = coro
self._obj: _T
def send(self, value) -> None:
return self._coro.send(value)
def throw(self, typ, val=None, tb=None) -> None:
if val is None:
return self._coro.throw(typ)
if tb is None:
return self._coro.throw(typ, val)
return self._coro.throw(typ, val, tb)
def close(self) -> None:
return self._coro.close()
def __await__(self) -> Generator[Any, None, _T]:
return self._coro.__await__()
async def __aenter__(self) -> _T:
self._obj = await self._coro
return self._obj
async def __aexit__(self, exc_type, exc, tb) -> None:
if isinstance(self._obj, Cursor):
await self._obj.close()
def contextmanager(
method: Callable[..., Coroutine[Any, Any, _T]],
) -> Callable[..., Result[_T]]:
@wraps(method)
def wrapper(self, *args, **kwargs) -> Result[_T]:
return Result(method(self, *args, **kwargs))
return wrapper

View file

@ -0,0 +1,468 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
"""
Core implementation of aiosqlite proxies
"""
import asyncio
import logging
import sqlite3
from collections.abc import AsyncIterator, Generator, Iterable
from functools import partial
from pathlib import Path
from queue import Empty, Queue, SimpleQueue
from threading import Thread
from typing import Any, Callable, Literal, Optional, Union
from warnings import warn
from .context import contextmanager
from .cursor import Cursor
__all__ = ["connect", "Connection", "Cursor"]
AuthorizerCallback = Callable[[int, str, str, str, str], int]
LOG = logging.getLogger("aiosqlite")
IsolationLevel = Optional[Literal["DEFERRED", "IMMEDIATE", "EXCLUSIVE"]]
def set_result(fut: asyncio.Future, result: Any) -> None:
"""Set the result of a future if it hasn't been set already."""
if not fut.done():
fut.set_result(result)
def set_exception(fut: asyncio.Future, e: BaseException) -> None:
"""Set the exception of a future if it hasn't been set already."""
if not fut.done():
fut.set_exception(e)
_STOP_RUNNING_SENTINEL = object()
_TxQueue = SimpleQueue[tuple[Optional[asyncio.Future], Callable[[], Any]]]
def _connection_worker_thread(tx: _TxQueue):
"""
Execute function calls on a separate thread.
:meta private:
"""
while True:
# Continues running until all queue items are processed,
# even after connection is closed (so we can finalize all
# futures)
future, function = tx.get()
try:
LOG.debug("executing %s", function)
result = function()
if future:
future.get_loop().call_soon_threadsafe(set_result, future, result)
LOG.debug("operation %s completed", function)
if result is _STOP_RUNNING_SENTINEL:
break
except BaseException as e: # noqa B036
LOG.debug("returning exception %s", e)
if future:
future.get_loop().call_soon_threadsafe(set_exception, future, e)
class Connection:
def __init__(
self,
connector: Callable[[], sqlite3.Connection],
iter_chunk_size: int,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
self._running = True
self._connection: Optional[sqlite3.Connection] = None
self._connector = connector
self._tx: _TxQueue = SimpleQueue()
self._iter_chunk_size = iter_chunk_size
self._thread = Thread(target=_connection_worker_thread, args=(self._tx,))
if loop is not None:
warn(
"aiosqlite.Connection no longer uses the `loop` parameter",
DeprecationWarning,
)
def __del__(self):
if self._connection is None:
return
warn(
(
f"{self!r} was deleted before being closed. "
"Please use 'async with' or '.close()' to close the connection properly."
),
ResourceWarning,
stacklevel=1,
)
# Don't try to be creative here, the event loop may have already been closed.
# Simply stop the worker thread, and let the underlying sqlite3 connection
# be finalized by its own __del__.
self.stop()
def stop(self) -> Optional[asyncio.Future]:
"""Stop the background thread. Prefer `async with` or `await close()`"""
self._running = False
def close_and_stop():
if self._connection is not None:
self._connection.close()
self._connection = None
return _STOP_RUNNING_SENTINEL
try:
future = asyncio.get_event_loop().create_future()
except Exception:
future = None
self._tx.put_nowait((future, close_and_stop))
return future
@property
def _conn(self) -> sqlite3.Connection:
if self._connection is None:
raise ValueError("no active connection")
return self._connection
def _execute_insert(self, sql: str, parameters: Any) -> Optional[sqlite3.Row]:
cursor = self._conn.execute(sql, parameters)
cursor.execute("SELECT last_insert_rowid()")
return cursor.fetchone()
def _execute_fetchall(self, sql: str, parameters: Any) -> Iterable[sqlite3.Row]:
cursor = self._conn.execute(sql, parameters)
return cursor.fetchall()
async def _execute(self, fn, *args, **kwargs):
"""Queue a function with the given arguments for execution."""
if not self._running or not self._connection:
raise ValueError("Connection closed")
function = partial(fn, *args, **kwargs)
future = asyncio.get_event_loop().create_future()
self._tx.put_nowait((future, function))
return await future
async def _connect(self) -> "Connection":
"""Connect to the actual sqlite database."""
if self._connection is None:
try:
future = asyncio.get_event_loop().create_future()
self._tx.put_nowait((future, self._connector))
self._connection = await future
except BaseException:
self.stop()
self._connection = None
raise
return self
def __await__(self) -> Generator[Any, None, "Connection"]:
self._thread.start()
return self._connect().__await__()
async def __aenter__(self) -> "Connection":
return await self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
await self.close()
@contextmanager
async def cursor(self) -> Cursor:
"""Create an aiosqlite cursor wrapping a sqlite3 cursor object."""
return Cursor(self, await self._execute(self._conn.cursor))
async def commit(self) -> None:
"""Commit the current transaction."""
await self._execute(self._conn.commit)
async def rollback(self) -> None:
"""Roll back the current transaction."""
await self._execute(self._conn.rollback)
async def close(self) -> None:
"""Complete queued queries/cursors and close the connection."""
if self._connection is None:
return
try:
await self._execute(self._conn.close)
except Exception:
LOG.info("exception occurred while closing connection")
raise
finally:
self._connection = None
future = self.stop()
if future:
await future
@contextmanager
async def execute(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> Cursor:
"""Helper to create a cursor and execute the given query."""
if parameters is None:
parameters = []
cursor = await self._execute(self._conn.execute, sql, parameters)
return Cursor(self, cursor)
@contextmanager
async def execute_insert(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> Optional[sqlite3.Row]:
"""Helper to insert and get the last_insert_rowid."""
if parameters is None:
parameters = []
return await self._execute(self._execute_insert, sql, parameters)
@contextmanager
async def execute_fetchall(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> Iterable[sqlite3.Row]:
"""Helper to execute a query and return all the data."""
if parameters is None:
parameters = []
return await self._execute(self._execute_fetchall, sql, parameters)
@contextmanager
async def executemany(
self, sql: str, parameters: Iterable[Iterable[Any]]
) -> Cursor:
"""Helper to create a cursor and execute the given multiquery."""
cursor = await self._execute(self._conn.executemany, sql, parameters)
return Cursor(self, cursor)
@contextmanager
async def executescript(self, sql_script: str) -> Cursor:
"""Helper to create a cursor and execute a user script."""
cursor = await self._execute(self._conn.executescript, sql_script)
return Cursor(self, cursor)
async def interrupt(self) -> None:
"""Interrupt pending queries."""
return self._conn.interrupt()
async def create_function(
self, name: str, num_params: int, func: Callable, deterministic: bool = False
) -> None:
"""
Create user-defined function that can be later used
within SQL statements. Must be run within the same thread
that query executions take place so instead of executing directly
against the connection, we defer this to `run` function.
If ``deterministic`` is true, the created function is marked as deterministic,
which allows SQLite to perform additional optimizations. This flag is supported
by SQLite 3.8.3 or higher, ``NotSupportedError`` will be raised if used with
older versions.
"""
await self._execute(
self._conn.create_function,
name,
num_params,
func,
deterministic=deterministic,
)
@property
def in_transaction(self) -> bool:
return self._conn.in_transaction
@property
def isolation_level(self) -> Optional[str]:
return self._conn.isolation_level
@isolation_level.setter
def isolation_level(self, value: IsolationLevel) -> None:
self._conn.isolation_level = value
@property
def row_factory(self) -> Optional[type]:
return self._conn.row_factory
@row_factory.setter
def row_factory(self, factory: Optional[type]) -> None:
self._conn.row_factory = factory
@property
def text_factory(self) -> Callable[[bytes], Any]:
return self._conn.text_factory
@text_factory.setter
def text_factory(self, factory: Callable[[bytes], Any]) -> None:
self._conn.text_factory = factory
@property
def total_changes(self) -> int:
return self._conn.total_changes
async def enable_load_extension(self, value: bool) -> None:
await self._execute(self._conn.enable_load_extension, value) # type: ignore
async def load_extension(self, path: str):
await self._execute(self._conn.load_extension, path) # type: ignore
async def set_progress_handler(
self, handler: Callable[[], Optional[int]], n: int
) -> None:
await self._execute(self._conn.set_progress_handler, handler, n)
async def set_trace_callback(self, handler: Callable) -> None:
await self._execute(self._conn.set_trace_callback, handler)
async def set_authorizer(
self, authorizer_callback: Optional[AuthorizerCallback]
) -> None:
"""
Set an authorizer callback to control database access.
The authorizer callback is invoked for each SQL statement that is prepared,
and controls whether specific operations are permitted.
Example::
import sqlite3
def restrict_drops(action_code, arg1, arg2, db_name, trigger_name):
# Deny all DROP operations
if action_code == sqlite3.SQLITE_DROP_TABLE:
return sqlite3.SQLITE_DENY
# Allow everything else
return sqlite3.SQLITE_OK
await conn.set_authorizer(restrict_drops)
See ``sqlite3`` documentation for details:
https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.set_authorizer
:param authorizer_callback: An optional callable that receives five arguments:
- ``action_code`` (int): The action to be authorized (e.g., ``SQLITE_READ``)
- ``arg1`` (str): First argument, meaning depends on ``action_code``
- ``arg2`` (str): Second argument, meaning depends on ``action_code``
- ``db_name`` (str): Database name (e.g., ``"main"``, ``"temp"``)
- ``trigger_name`` (str): Name of trigger or view that is doing the access,
or ``None``
The callback should return:
- ``SQLITE_OK`` (0): Allow the operation
- ``SQLITE_DENY`` (1): Deny the operation, raise ``sqlite3.DatabaseError``
- ``SQLITE_IGNORE`` (2): Treat operation as no-op
Pass ``None`` to remove the authorizer.
"""
await self._execute(self._conn.set_authorizer, authorizer_callback)
async def iterdump(self) -> AsyncIterator[str]:
"""
Return an async iterator to dump the database in SQL text format.
Example::
async for line in db.iterdump():
...
"""
dump_queue: Queue = Queue()
def dumper():
try:
for line in self._conn.iterdump():
dump_queue.put_nowait(line)
dump_queue.put_nowait(None)
except Exception:
LOG.exception("exception while dumping db")
dump_queue.put_nowait(None)
raise
fut = self._execute(dumper)
task = asyncio.ensure_future(fut)
while True:
try:
line: Optional[str] = dump_queue.get_nowait()
if line is None:
break
yield line
except Empty:
if task.done():
LOG.warning("iterdump completed unexpectedly")
break
await asyncio.sleep(0.01)
await task
async def backup(
self,
target: Union["Connection", sqlite3.Connection],
*,
pages: int = 0,
progress: Optional[Callable[[int, int, int], None]] = None,
name: str = "main",
sleep: float = 0.250,
) -> None:
"""
Make a backup of the current database to the target database.
Takes either a standard sqlite3 or aiosqlite Connection object as the target.
"""
if isinstance(target, Connection):
target = target._conn
await self._execute(
self._conn.backup,
target,
pages=pages,
progress=progress,
name=name,
sleep=sleep,
)
def connect(
database: Union[str, Path],
*,
iter_chunk_size=64,
loop: Optional[asyncio.AbstractEventLoop] = None,
**kwargs: Any,
) -> Connection:
"""Create and return a connection proxy to the sqlite database."""
if loop is not None:
warn(
"aiosqlite.connect() no longer uses the `loop` parameter",
DeprecationWarning,
)
def connector() -> sqlite3.Connection:
if isinstance(database, str):
loc = database
elif isinstance(database, bytes):
loc = database.decode("utf-8")
else:
loc = str(database)
return sqlite3.connect(loc, **kwargs)
return Connection(connector, iter_chunk_size)

View file

@ -0,0 +1,110 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import sqlite3
from collections.abc import AsyncIterator, Iterable
from typing import Any, Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .core import Connection
class Cursor:
def __init__(self, conn: "Connection", cursor: sqlite3.Cursor) -> None:
self.iter_chunk_size = conn._iter_chunk_size
self._conn = conn
self._cursor = cursor
def __aiter__(self) -> AsyncIterator[sqlite3.Row]:
"""The cursor proxy is also an async iterator."""
return self._fetch_chunked()
async def _fetch_chunked(self):
while True:
rows = await self.fetchmany(self.iter_chunk_size)
if not rows:
return
for row in rows:
yield row
async def _execute(self, fn, *args, **kwargs):
"""Execute the given function on the shared connection's thread."""
return await self._conn._execute(fn, *args, **kwargs)
async def execute(
self, sql: str, parameters: Optional[Iterable[Any]] = None
) -> "Cursor":
"""Execute the given query."""
if parameters is None:
parameters = []
await self._execute(self._cursor.execute, sql, parameters)
return self
async def executemany(
self, sql: str, parameters: Iterable[Iterable[Any]]
) -> "Cursor":
"""Execute the given multiquery."""
await self._execute(self._cursor.executemany, sql, parameters)
return self
async def executescript(self, sql_script: str) -> "Cursor":
"""Execute a user script."""
await self._execute(self._cursor.executescript, sql_script)
return self
async def fetchone(self) -> Optional[sqlite3.Row]:
"""Fetch a single row."""
return await self._execute(self._cursor.fetchone)
async def fetchmany(self, size: Optional[int] = None) -> Iterable[sqlite3.Row]:
"""Fetch up to `cursor.arraysize` number of rows."""
args: tuple[int, ...] = ()
if size is not None:
args = (size,)
return await self._execute(self._cursor.fetchmany, *args)
async def fetchall(self) -> Iterable[sqlite3.Row]:
"""Fetch all remaining rows."""
return await self._execute(self._cursor.fetchall)
async def close(self) -> None:
"""Close the cursor."""
await self._execute(self._cursor.close)
@property
def rowcount(self) -> int:
return self._cursor.rowcount
@property
def lastrowid(self) -> Optional[int]:
return self._cursor.lastrowid
@property
def arraysize(self) -> int:
return self._cursor.arraysize
@arraysize.setter
def arraysize(self, value: int) -> None:
self._cursor.arraysize = value
@property
def description(self) -> tuple[tuple[str, None, None, None, None, None, None], ...]:
return self._cursor.description
@property
def row_factory(self) -> Optional[Callable[[sqlite3.Cursor, sqlite3.Row], object]]:
return self._cursor.row_factory
@row_factory.setter
def row_factory(self, factory: Optional[type]) -> None:
self._cursor.row_factory = factory
@property
def connection(self) -> sqlite3.Connection:
return self._cursor.connection
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View file

@ -0,0 +1,4 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
from .smoke import SmokeTest

View file

@ -0,0 +1,7 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import unittest
if __name__ == "__main__":
unittest.main(module="aiosqlite.tests", verbosity=2)

View file

@ -0,0 +1,29 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import logging
import sys
def setup_logger():
log = logging.getLogger("")
log.setLevel(logging.INFO)
logging.addLevelName(logging.ERROR, "E")
logging.addLevelName(logging.WARNING, "W")
logging.addLevelName(logging.INFO, "I")
logging.addLevelName(logging.DEBUG, "V")
date_fmt = r"%H:%M:%S"
verbose_fmt = (
"%(asctime)s,%(msecs)d %(levelname)s "
"%(module)s:%(funcName)s():%(lineno)d "
"%(message)s"
)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter(verbose_fmt, date_fmt))
log.addHandler(handler)
return log

View file

@ -0,0 +1,221 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
"""
Simple perf tests for aiosqlite and the asyncio run loop.
"""
import sqlite3
import string
import tempfile
import time
from unittest import IsolatedAsyncioTestCase as TestCase
import aiosqlite
from .smoke import setup_logger
TEST_DB = ":memory:"
TARGET = 2.0
RESULTS = {}
def timed(fn, name=None):
"""
Decorator for perf testing a block of async code.
Expects the wrapped function to return an async generator.
The generator should do setup, then yield when ready to start perf testing.
The decorator will then pump the generator repeatedly until the target
time has been reached, then close the generator and print perf results.
"""
name = name or fn.__name__
async def wrapper(*args, **kwargs):
gen = fn(*args, **kwargs)
await gen.asend(None)
count = 0
before = time.time()
while True:
count += 1
value = time.time() - before < TARGET
try:
if value:
await gen.asend(value)
else:
await gen.aclose()
break
except StopAsyncIteration:
break
except Exception as e:
print(f"exception occurred: {e}")
return
duration = time.time() - before
RESULTS[name] = (count, duration)
return wrapper
class PerfTest(TestCase):
@classmethod
def setUpClass(cls):
print(f"Running perf tests for at least {TARGET:.1f}s each...")
setup_logger()
@classmethod
def tearDownClass(cls):
print(f"\n{'Perf Test':<25} Iterations Duration {'Rate':>11}")
for name in sorted(RESULTS):
count, duration = RESULTS[name]
rate = count / duration
name = name.replace("test_", "")
print(f"{name:<25} {count:>10} {duration:>7.1f}s {rate:>9.1f}/s")
@timed
async def test_connection_memory(self):
while True:
yield
async with aiosqlite.connect(TEST_DB):
pass
@timed
async def test_connection_file(self):
with tempfile.NamedTemporaryFile(delete=False) as tf:
path = tf.name
tf.close()
async with aiosqlite.connect(path) as db:
await db.execute(
"create table perf (i integer primary key asc, k integer)"
)
await db.execute("insert into perf (k) values (2), (3)")
await db.commit()
while True:
yield
async with aiosqlite.connect(path):
pass
@timed
async def test_atomics(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.execute("insert into perf (k) values (2), (3)")
await db.commit()
while True:
yield
async with db.execute("select last_insert_rowid()") as cursor:
await cursor.fetchone()
@timed
async def test_inserts(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.commit()
while True:
yield
await db.execute("insert into perf (k) values (1), (2), (3)")
await db.commit()
@timed
async def test_inserts_authorized(self):
def deny_drops(action_code, arg1, arg2, db_name, trigger_name):
if action_code == sqlite3.SQLITE_DROP_TABLE:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.set_authorizer(deny_drops)
await db.commit()
while True:
yield
await db.execute("insert into perf (k) values (1), (2), (3)")
await db.commit()
@timed
async def test_insert_ids(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.commit()
while True:
yield
cursor = await db.execute("insert into perf (k) values (1)")
await cursor.execute("select last_insert_rowid()")
await cursor.fetchone()
await db.commit()
@timed
async def test_insert_macro_ids(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
await db.commit()
while True:
yield
await db.execute_insert("insert into perf (k) values (1)")
await db.commit()
@timed
async def test_select(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
for i in range(100):
await db.execute("insert into perf (k) values (%d)" % (i,))
await db.commit()
while True:
yield
cursor = await db.execute("select i, k from perf")
assert len(await cursor.fetchall()) == 100
@timed
async def test_select_macro(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute("create table perf (i integer primary key asc, k integer)")
for i in range(100):
await db.execute("insert into perf (k) values (%d)" % (i,))
await db.commit()
while True:
yield
assert len(await db.execute_fetchall("select i, k from perf")) == 100
async def test_iterable_cursor_perf(self):
async with aiosqlite.connect(TEST_DB) as db:
await db.execute(
"create table ic_perf ("
"i integer primary key asc, k integer, a integer, b integer, c char(16))"
)
for batch in range(128): # add 128k rows
r_start = batch * 1024
await db.executemany(
"insert into ic_perf (k, a, b, c) values(?, 1, 2, ?)",
[
*[
(i, string.ascii_lowercase)
for i in range(r_start, r_start + 1024)
]
],
)
await db.commit()
async def test_perf(chunk_size: int):
while True:
async with db.execute("SELECT * FROM ic_perf") as cursor:
cursor.iter_chunk_size = chunk_size
async for _ in cursor:
yield
for chunk_size in [2**i for i in range(4, 11)]:
await timed(test_perf, f"iterable_cursor @ {chunk_size}")(chunk_size)

View file

@ -0,0 +1,537 @@
# Copyright Amethyst Reese
# Licensed under the MIT license
import asyncio
import sqlite3
import sys
from pathlib import Path
from sqlite3 import OperationalError
from tempfile import TemporaryDirectory
from threading import Thread
from unittest import IsolatedAsyncioTestCase, SkipTest
from unittest.mock import patch
import aiosqlite
from .helpers import setup_logger
class SmokeTest(IsolatedAsyncioTestCase):
@classmethod
def setUpClass(cls):
setup_logger()
def setUp(self):
td = TemporaryDirectory()
self.addCleanup(td.cleanup)
self.db = Path(td.name).resolve() / "test.db"
async def test_connection_await(self):
db = await aiosqlite.connect(self.db)
self.assertIsInstance(db, aiosqlite.Connection)
async with db.execute("select 1, 2") as cursor:
rows = await cursor.fetchall()
self.assertEqual(rows, [(1, 2)])
await db.close()
async def test_connection_context(self):
async with aiosqlite.connect(self.db) as db:
self.assertIsInstance(db, aiosqlite.Connection)
async with db.execute("select 1, 2") as cursor:
rows = await cursor.fetchall()
self.assertEqual(rows, [(1, 2)])
async def test_connection_locations(self):
TEST_DB = self.db.as_posix()
class Fake: # pylint: disable=too-few-public-methods
def __str__(self):
return TEST_DB
locs = (Path(TEST_DB), TEST_DB, TEST_DB.encode(), Fake())
async with aiosqlite.connect(locs[0]) as db:
await db.execute("create table foo (i integer, k integer)")
await db.execute("insert into foo (i, k) values (1, 5)")
await db.commit()
cursor = await db.execute("select * from foo")
rows = await cursor.fetchall()
for loc in locs:
async with aiosqlite.connect(loc) as db:
cursor = await db.execute("select * from foo")
self.assertEqual(await cursor.fetchall(), rows)
async def test_multiple_connections(self):
async with aiosqlite.connect(self.db) as db:
await db.execute(
"create table multiple_connections "
"(i integer primary key asc, k integer)"
)
async def do_one_conn(i):
async with aiosqlite.connect(self.db) as db:
await db.execute("insert into multiple_connections (k) values (?)", [i])
await db.commit()
await asyncio.gather(*[do_one_conn(i) for i in range(10)])
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select * from multiple_connections")
rows = await cursor.fetchall()
assert len(rows) == 10
async def test_multiple_queries(self):
async with aiosqlite.connect(self.db) as db:
await db.execute(
"create table multiple_queries "
"(i integer primary key asc, k integer)"
)
await asyncio.gather(
*[
db.execute("insert into multiple_queries (k) values (?)", [i])
for i in range(10)
]
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select * from multiple_queries")
rows = await cursor.fetchall()
assert len(rows) == 10
async def test_iterable_cursor(self):
async with aiosqlite.connect(self.db) as db:
cursor = await db.cursor()
await cursor.execute(
"create table iterable_cursor " "(i integer primary key asc, k integer)"
)
await cursor.executemany(
"insert into iterable_cursor (k) values (?)", [[i] for i in range(10)]
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select * from iterable_cursor")
rows = []
async for row in cursor:
rows.append(row)
assert len(rows) == 10
async def test_multi_loop_usage(self):
results = {}
def runner(k, conn):
async def query():
async with conn.execute("select * from foo") as cursor:
rows = await cursor.fetchall()
self.assertEqual(len(rows), 2)
return rows
with self.subTest(k):
loop = asyncio.new_event_loop()
rows = loop.run_until_complete(query())
loop.close()
results[k] = rows
async with aiosqlite.connect(":memory:") as db:
await db.execute("create table foo (id int, name varchar)")
await db.execute(
"insert into foo values (?, ?), (?, ?)", (1, "Sally", 2, "Janet")
)
await db.commit()
threads = [Thread(target=runner, args=(k, db)) for k in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(len(results), 4)
for rows in results.values():
self.assertEqual(len(rows), 2)
async def test_context_cursor(self):
async with aiosqlite.connect(self.db) as db:
async with db.cursor() as cursor:
await cursor.execute(
"create table context_cursor "
"(i integer primary key asc, k integer)"
)
await cursor.executemany(
"insert into context_cursor (k) values (?)",
[[i] for i in range(10)],
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
async with db.execute("select * from context_cursor") as cursor:
rows = []
async for row in cursor:
rows.append(row)
assert len(rows) == 10
async def test_cursor_return_self(self):
async with aiosqlite.connect(self.db) as db:
cursor = await db.cursor()
result = await cursor.execute(
"create table test_cursor_return_self (i integer, k integer)"
)
self.assertEqual(result, cursor, "cursor execute returns itself")
result = await cursor.executemany(
"insert into test_cursor_return_self values (?, ?)", [(1, 1), (2, 2)]
)
self.assertEqual(result, cursor)
result = await cursor.executescript(
"insert into test_cursor_return_self values (3, 3);"
"insert into test_cursor_return_self values (4, 4);"
"insert into test_cursor_return_self values (5, 5);"
)
self.assertEqual(result, cursor)
async def test_connection_properties(self):
async with aiosqlite.connect(self.db) as db:
self.assertEqual(db.total_changes, 0)
async with db.cursor() as cursor:
self.assertFalse(db.in_transaction)
await cursor.execute(
"create table test_properties "
"(i integer primary key asc, k integer, d text)"
)
await cursor.execute(
"insert into test_properties (k, d) values (1, 'hi')"
)
self.assertTrue(db.in_transaction)
await db.commit()
self.assertFalse(db.in_transaction)
self.assertEqual(db.total_changes, 1)
self.assertIsNone(db.row_factory)
self.assertEqual(db.text_factory, str)
async with db.cursor() as cursor:
await cursor.execute("select * from test_properties")
row = await cursor.fetchone()
self.assertIsInstance(row, tuple)
self.assertEqual(row, (1, 1, "hi"))
with self.assertRaises(TypeError):
_ = row["k"]
async with db.cursor() as cursor:
cursor.row_factory = aiosqlite.Row
self.assertEqual(cursor.row_factory, aiosqlite.Row)
await cursor.execute("select * from test_properties")
row = await cursor.fetchone()
self.assertIsInstance(row, aiosqlite.Row)
self.assertEqual(row[1], 1)
self.assertEqual(row[2], "hi")
self.assertEqual(row["k"], 1)
self.assertEqual(row["d"], "hi")
db.row_factory = aiosqlite.Row
db.text_factory = bytes
self.assertEqual(db.row_factory, aiosqlite.Row)
self.assertEqual(db.text_factory, bytes)
async with db.cursor() as cursor:
await cursor.execute("select * from test_properties")
row = await cursor.fetchone()
self.assertIsInstance(row, aiosqlite.Row)
self.assertEqual(row[1], 1)
self.assertEqual(row[2], b"hi")
self.assertEqual(row["k"], 1)
self.assertEqual(row["d"], b"hi")
async def test_fetch_all(self):
async with aiosqlite.connect(self.db) as db:
await db.execute(
"create table test_fetch_all (i integer primary key asc, k integer)"
)
await db.execute(
"insert into test_fetch_all (k) values (10), (24), (16), (32)"
)
await db.commit()
async with aiosqlite.connect(self.db) as db:
cursor = await db.execute("select k from test_fetch_all where k < 30")
rows = await cursor.fetchall()
self.assertEqual(rows, [(10,), (24,), (16,)])
async def test_enable_load_extension(self):
"""Assert that after enabling extension loading, they can be loaded"""
async with aiosqlite.connect(self.db) as db:
try:
await db.enable_load_extension(True)
await db.load_extension("test")
except OperationalError as e:
assert "not authorized" not in e.args
except AttributeError as e:
raise SkipTest(
"python was not compiled with sqlite3 "
"extension support, so we can't test it"
) from e
async def test_set_progress_handler(self):
"""
Assert that after setting a progress handler returning 1, DB operations are aborted
"""
async with aiosqlite.connect(self.db) as db:
await db.set_progress_handler(lambda: 1, 1)
with self.assertRaises(OperationalError):
await db.execute(
"create table test_progress_handler (i integer primary key asc, k integer)"
)
async def test_create_function(self):
"""Assert that after creating a custom function, it can be used"""
def no_arg():
return "no arg"
def one_arg(num):
return num * 2
async with aiosqlite.connect(self.db) as db:
await db.create_function("no_arg", 0, no_arg)
await db.create_function("one_arg", 1, one_arg)
async with db.execute("SELECT no_arg();") as res:
row = await res.fetchone()
self.assertEqual(row[0], "no arg")
async with db.execute("SELECT one_arg(10);") as res:
row = await res.fetchone()
self.assertEqual(row[0], 20)
async def test_create_function_deterministic(self):
"""Assert that after creating a deterministic custom function, it can be used.
https://sqlite.org/deterministic.html
"""
def one_arg(num):
return num * 2
async with aiosqlite.connect(self.db) as db:
await db.create_function("one_arg", 1, one_arg, deterministic=True)
await db.execute("create table foo (id int, bar int)")
# Non-deterministic functions cannot be used in indexes
await db.execute("create index t on foo(one_arg(bar))")
async def test_set_trace_callback(self):
statements = []
def callback(statement: str):
statements.append(statement)
async with aiosqlite.connect(self.db) as db:
await db.set_trace_callback(callback)
await db.execute("select 10")
self.assertIn("select 10", statements)
async def test_set_authorizer_deny_drops(self):
"""Test authorizer that denies DROP operations"""
def deny_drops(action_code, arg1, arg2, db_name, trigger_name):
if action_code == sqlite3.SQLITE_DROP_TABLE:
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
async with aiosqlite.connect(self.db) as db:
await db.set_authorizer(deny_drops)
# Other operations should succeed
await db.execute("CREATE TABLE test_drop (id INTEGER)")
await db.execute("INSERT INTO test_drop VALUES (1)")
await db.execute("SELECT * FROM test_drop")
# DROP should fail
with self.assertRaises(sqlite3.DatabaseError):
await db.execute("DROP TABLE test_drop")
if sys.version_info >= (3, 11):
# Disabling the authorizer re-enables DROP
await db.set_authorizer(None)
await db.execute("DROP TABLE test_drop")
async def test_set_authorizer_exception_propagation(self):
"""Test that exceptions raised in authorizer callback are caught by SQLite"""
def raise_exception(action_code, arg1, arg2, db_name, trigger_name):
raise ValueError("Test exception from authorizer")
async with aiosqlite.connect(self.db) as db:
await db.set_authorizer(raise_exception)
with self.assertRaises(sqlite3.DatabaseError):
await db.execute("CREATE TABLE test_exception (id INTEGER)")
async def test_connect_error(self):
bad_db = Path("/something/that/shouldnt/exist.db")
with self.assertRaisesRegex(OperationalError, "unable to open database"):
async with aiosqlite.connect(bad_db) as db:
self.assertIsNone(db) # should never be reached
with self.assertRaisesRegex(OperationalError, "unable to open database"):
await aiosqlite.connect(bad_db)
async def test_connect_base_exception(self):
# Check if connect task is cancelled, thread is properly closed.
def _raise_cancelled_error(*_, **__):
raise asyncio.CancelledError("I changed my mind")
connection = aiosqlite.Connection(lambda: sqlite3.connect(":memory:"), 64)
with (
patch.object(sqlite3, "connect", side_effect=_raise_cancelled_error),
self.assertRaisesRegex(asyncio.CancelledError, "I changed my mind"),
):
async with connection:
...
# Terminate the thread here if the test fails to have a clear error.
if connection._running:
connection.stop()
raise AssertionError("connection thread was not stopped")
async def test_iterdump(self):
async with aiosqlite.connect(":memory:") as db:
await db.execute("create table foo (i integer, k charvar(250))")
await db.executemany(
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
)
lines = [line async for line in db.iterdump()]
self.assertEqual(
lines,
[
"BEGIN TRANSACTION;",
"CREATE TABLE foo (i integer, k charvar(250));",
"INSERT INTO \"foo\" VALUES(1,'hello');",
"INSERT INTO \"foo\" VALUES(2,'world');",
"COMMIT;",
],
)
async def test_cursor_on_closed_connection(self):
db = await aiosqlite.connect(self.db)
cursor = await db.execute("select 1, 2")
await db.close()
with self.assertRaisesRegex(ValueError, "Connection closed"):
await cursor.fetchall()
with self.assertRaisesRegex(ValueError, "Connection closed"):
await cursor.fetchall()
async def test_cursor_on_closed_connection_loop(self):
db = await aiosqlite.connect(self.db)
cursor = await db.execute("select 1, 2")
tasks = []
for i in range(100):
if i == 50:
tasks.append(asyncio.ensure_future(db.close()))
tasks.append(asyncio.ensure_future(cursor.fetchall()))
for task in tasks:
try:
await task
except sqlite3.ProgrammingError:
pass
async def test_close_blocking_until_transaction_queue_empty(self):
db = await aiosqlite.connect(self.db)
# Insert transactions into the
# transaction queue '_tx'
for i in range(1000):
await db.execute(f"select 1, {i}")
# Wait for all transactions to complete
await db.close()
# Check no more transaction pending
self.assertEqual(db._tx.empty(), True)
async def test_close_twice(self):
db = await aiosqlite.connect(self.db)
await db.close()
# no error
await db.close()
async def test_backup_aiosqlite(self):
def progress(a, b, c):
print(a, b, c)
async with (
aiosqlite.connect(":memory:") as db1,
aiosqlite.connect(":memory:") as db2,
):
await db1.execute("create table foo (i integer, k charvar(250))")
await db1.executemany(
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
)
await db1.commit()
with self.assertRaisesRegex(OperationalError, "no such table: foo"):
await db2.execute("select * from foo")
await db1.backup(db2, progress=progress)
async with db2.execute("select * from foo") as cursor:
rows = await cursor.fetchall()
self.assertEqual(rows, [(1, "hello"), (2, "world")])
async def test_backup_sqlite(self):
async with aiosqlite.connect(":memory:") as db1:
with sqlite3.connect(":memory:") as db2:
await db1.execute("create table foo (i integer, k charvar(250))")
await db1.executemany(
"insert into foo values (?, ?)", [(1, "hello"), (2, "world")]
)
await db1.commit()
with self.assertRaisesRegex(OperationalError, "no such table: foo"):
db2.execute("select * from foo")
await db1.backup(db2)
cursor = db2.execute("select * from foo")
rows = cursor.fetchall()
self.assertEqual(rows, [(1, "hello"), (2, "world")])
async def test_emits_warning_when_left_open(self):
db = await aiosqlite.connect(":memory:")
with self.assertWarnsRegex(
ResourceWarning, r".*was deleted before being closed.*"
):
del db
async def test_stop_without_close(self):
db = await aiosqlite.connect(":memory:")
await db.stop()
def test_stop_after_event_loop_closed(self):
db = None
async def inner():
nonlocal db
db = await aiosqlite.connect(":memory:")
loop = asyncio.new_event_loop()
loop.run_until_complete(inner())
loop.close()
db.stop()