More docstrings plus configured autodoc, refs #311

This commit is contained in:
Simon Willison 2021-08-10 09:57:59 -07:00
commit b95b6b63f6
5 changed files with 158 additions and 20 deletions

View file

@ -20,4 +20,4 @@ help:
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
livehtml: livehtml:
sphinx-autobuild -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0) sphinx-autobuild -a -b html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0) --watch ../sqlite_utils

View file

@ -30,7 +30,8 @@ from subprocess import Popen, PIPE
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = ["sphinx.ext.extlinks"] extensions = ["sphinx.ext.extlinks", "sphinx.ext.autodoc"]
autodoc_member_order = "bysource"
extlinks = { extlinks = {
"issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#"), "issue": ("https://github.com/simonw/sqlite-utils/issues/%s", "#"),

View file

@ -32,6 +32,7 @@ Contents
installation installation
cli cli
python-api python-api
reference
contributing contributing
changelog changelog

55
docs/reference.rst Normal file
View file

@ -0,0 +1,55 @@
===========
Reference
===========
.. contents:: :local:
.. _reference_db_database:
sqlit_utils.db.Database
=======================
.. autoclass:: sqlite_utils.db.Database
:members:
:undoc-members:
:show-inheritance:
:special-members: __getitem__
:exclude-members: use_counts_table
.. _reference_db_queryable:
sqlit_utils.db.Queryable
========================
.. autoclass:: sqlite_utils.db.Queryable
:members:
:undoc-members:
.. _reference_db_table:
sqlit_utils.db.Table
====================
.. autoclass:: sqlite_utils.db.Table
:members:
:undoc-members:
:show-inheritance:
.. _reference_db_view:
sqlit_utils.db.View
===================
.. autoclass:: sqlite_utils.db.View
:members:
:undoc-members:
:show-inheritance:
.. _reference_db_other:
Other
=====
.. autoclass:: sqlite_utils.db.Column
.. autoclass:: sqlite_utils.db.ColumnDetails

View file

@ -21,7 +21,7 @@ import re
from sqlite_fts4 import rank_bm25 # type: ignore from sqlite_fts4 import rank_bm25 # type: ignore
import sys import sys
import textwrap import textwrap
from typing import Generator, Iterable, Union, Optional, List from typing import Callable, Dict, Generator, Iterable, Union, Optional, List, Tuple
import uuid import uuid
SQLITE_MAX_VARS = 999 SQLITE_MAX_VARS = 999
@ -49,7 +49,7 @@ USING\s+(?P<using>\w+) # e.g. USING FTS5
try: try:
import pandas as pd # type: ignore import pandas as pd # type: ignore
except ImportError: except ImportError:
pd = None pd = None # type: ignore
try: try:
import numpy as np # type: ignore import numpy as np # type: ignore
@ -59,6 +59,9 @@ except ImportError:
Column = namedtuple( Column = namedtuple(
"Column", ("cid", "name", "type", "notnull", "default_value", "is_pk") "Column", ("cid", "name", "type", "notnull", "default_value", "is_pk")
) )
Column.__doc__ = (
"Describes a SQLite column, returned by the :attr:`.Table.columns` property"
)
ColumnDetails = namedtuple( ColumnDetails = namedtuple(
"ColumnDetails", "ColumnDetails",
( (
@ -72,6 +75,7 @@ ColumnDetails = namedtuple(
"least_common", "least_common",
), ),
) )
ColumnDetails.__doc__ = "Column analytics"
ForeignKey = namedtuple( ForeignKey = namedtuple(
"ForeignKey", ("table", "column", "other_table", "other_column") "ForeignKey", ("table", "column", "other_table", "other_column")
) )
@ -133,26 +137,32 @@ if pd:
class AlterError(Exception): class AlterError(Exception):
"Error altering table"
pass pass
class NoObviousTable(Exception): class NoObviousTable(Exception):
"Could not tell which table this operation refers to"
pass pass
class BadPrimaryKey(Exception): class BadPrimaryKey(Exception):
"Table does not have a single obvious primary key"
pass pass
class NotFoundError(Exception): class NotFoundError(Exception):
"Record not found"
pass pass
class PrimaryKeyRequired(Exception): class PrimaryKeyRequired(Exception):
"Primary key needs to be specified"
pass pass
class InvalidColumns(Exception): class InvalidColumns(Exception):
"Specified columns do not exist"
pass pass
@ -176,17 +186,32 @@ CREATE TABLE IF NOT EXISTS [{}](
class Database: class Database:
"""
Wrapper for a SQLite database connection that adds a variety of useful utility methods.
- ``filename_or_conn`` - String path to a file, or a ``pathlib.Path`` object, or a
``sqlite3`` connection
- ``memory`` - set to ``True`` to create an in-memory database
- ``recreate`` - set to ``True`` to delete and recreate a file database (**dangerous**)
- ``recursive_triggers`` - defaults to ``True``, which sets ``PRAGMA recursive_triggers=on;`` -
set to ``False`` to avoid setting this pragma
- ``tracer``` - set a tracer function (``print`` works for this) which will be called with
``sql, parameters`` every time a SQL query is executed
- ``use_counts_table`` - set to ``True`` to use a cached counts table, if available. See
:ref:`python_api_cached_table_counts`.
"""
_counts_table_name = "_counts" _counts_table_name = "_counts"
use_counts_table = False use_counts_table = False
def __init__( def __init__(
self, self,
filename_or_conn=None, filename_or_conn=None,
memory=False, memory: bool = False,
recreate=False, recreate: bool = False,
recursive_triggers=True, recursive_triggers: bool = True,
tracer=None, tracer: Callable = None,
use_counts_table=False, use_counts_table: bool = False,
): ):
assert (filename_or_conn is not None and not memory) or ( assert (filename_or_conn is not None and not memory) or (
filename_or_conn is None and memory filename_or_conn is None and memory
@ -203,11 +228,24 @@ class Database:
self._tracer = tracer self._tracer = tracer
if recursive_triggers: if recursive_triggers:
self.execute("PRAGMA recursive_triggers=on;") self.execute("PRAGMA recursive_triggers=on;")
self._registered_functions = set() self._registered_functions: set = set()
self.use_counts_table = use_counts_table self.use_counts_table = use_counts_table
@contextlib.contextmanager @contextlib.contextmanager
def tracer(self, tracer=None): def tracer(self, tracer: Callable = None):
"""
Context manager to temporarily set a tracer function - all executed SQL queries will
be passed to this.
The tracer function should accept two arguments: ``sql`` and ``parameters``
Example usage::
with db.tracer(print):
db["creatures"].insert({"name": "Cleo"})
See :ref:`python_api_tracing`.
"""
prev_tracer = self._tracer prev_tracer = self._tracer
self._tracer = tracer or print self._tracer = tracer or print
try: try:
@ -215,13 +253,31 @@ class Database:
finally: finally:
self._tracer = prev_tracer self._tracer = prev_tracer
def __getitem__(self, table_name): def __getitem__(self, table_name: str):
"""
``db[table_name]`` returns a :class:`.Table` object for the table with the specified name.
If the table does not exist yet it will be created the first time data is inserted into it.
"""
return self.table(table_name) return self.table(table_name)
def __repr__(self): def __repr__(self):
return "<Database {}>".format(self.conn) return "<Database {}>".format(self.conn)
def register_function(self, fn=None, deterministic=None, replace=False): def register_function(
self, fn: Callable, deterministic: bool = False, replace: bool = False
):
"""
``fn`` will be made available as a function within SQL, with the same name and number
of arguments. Can be used as a decorator::
@db.register
def upper(value):
return str(value).upper()
- ``deterministic`` - set ``True`` for functions that always returns the same output for a given input
- ``replace`` - set ``True`` to replace an existing function with the same name - otherwise throw an error
"""
def register(fn): def register(fn):
name = fn.__name__ name = fn.__name__
arity = len(inspect.signature(fn).parameters) arity = len(inspect.signature(fn).parameters)
@ -240,9 +296,15 @@ class Database:
register(fn) register(fn)
def register_fts4_bm25(self): def register_fts4_bm25(self):
"Register the ``rank_bm25(match_info)`` function used for calculating relevance with SQLite FTS4"
self.register_function(rank_bm25, deterministic=True) self.register_function(rank_bm25, deterministic=True)
def attach(self, alias, filepath): def attach(self, alias: str, filepath: Union[str, pathlib.Path]):
"""
Attach another SQLite database file to this connection with the specified alias, equivalent to::
ATTACH DATABASE 'filepath.db' AS alias
"""
attach_sql = """ attach_sql = """
ATTACH DATABASE '{}' AS [{}]; ATTACH DATABASE '{}' AS [{}];
""".format( """.format(
@ -251,6 +313,7 @@ class Database:
self.execute(attach_sql) self.execute(attach_sql)
def execute(self, sql, parameters=None): def execute(self, sql, parameters=None):
"Execute SQL query and return a ``sqlite3.Cursor``"
if self._tracer: if self._tracer:
self._tracer(sql, parameters) self._tracer(sql, parameters)
if parameters is not None: if parameters is not None:
@ -259,15 +322,18 @@ class Database:
return self.conn.execute(sql) return self.conn.execute(sql)
def executescript(self, sql): def executescript(self, sql):
"Execute multiple SQL statements separated by ; and return the ``sqlite3.Cursor``"
if self._tracer: if self._tracer:
self._tracer(sql, None) self._tracer(sql, None)
return self.conn.executescript(sql) return self.conn.executescript(sql)
def table(self, table_name, **kwargs): def table(self, table_name, **kwargs):
"Return a table object, optionally configured with default options"
klass = View if table_name in self.view_names() else Table klass = View if table_name in self.view_names() else Table
return klass(self, table_name, **kwargs) return klass(self, table_name, **kwargs)
def quote(self, value): def quote(self, value):
"Apply SQLite string quoting to a value, including wrappping it in single quotes"
# Normally we would use .execute(sql, [params]) for escaping, but # Normally we would use .execute(sql, [params]) for escaping, but
# occasionally that isn't available - most notable when we need # occasionally that isn't available - most notable when we need
# to include a "... DEFAULT 'value'" in a column definition. # to include a "... DEFAULT 'value'" in a column definition.
@ -278,6 +344,7 @@ class Database:
).fetchone()[0] ).fetchone()[0]
def table_names(self, fts4=False, fts5=False): def table_names(self, fts4=False, fts5=False):
"A list of string table names in this database"
where = ["type = 'table'"] where = ["type = 'table'"]
if fts4: if fts4:
where.append("sql like '%USING FTS4%'") where.append("sql like '%USING FTS4%'")
@ -287,6 +354,7 @@ class Database:
return [r[0] for r in self.execute(sql).fetchall()] return [r[0] for r in self.execute(sql).fetchall()]
def view_names(self): def view_names(self):
"A list of string view names in this database"
return [ return [
r[0] r[0]
for r in self.execute( for r in self.execute(
@ -295,15 +363,18 @@ class Database:
] ]
@property @property
def tables(self): def tables(self) -> List[Table]:
"A list of Table objects in this database"
return [self[name] for name in self.table_names()] return [self[name] for name in self.table_names()]
@property @property
def views(self): def views(self) -> List[str]:
"A list of View objects in this database"
return [self[name] for name in self.view_names()] return [self[name] for name in self.view_names()]
@property @property
def triggers(self): def triggers(self) -> List[Trigger]:
"A list of ``(name, table_name, sql)`` tuples representing triggers in this database"
return [ return [
Trigger(*r) Trigger(*r)
for r in self.execute( for r in self.execute(
@ -313,11 +384,12 @@ class Database:
@property @property
def triggers_dict(self): def triggers_dict(self):
"Returns {trigger_name: sql} dictionary" "A {trigger_name: sql} dictionary for triggers in this database"
return {trigger.name: trigger.sql for trigger in self.triggers} return {trigger.name: trigger.sql for trigger in self.triggers}
@property @property
def schema(self): def schema(self):
"SQL schema of this database"
sqls = [] sqls = []
for row in self.execute( for row in self.execute(
"select sql from sqlite_master where sql is not null" "select sql from sqlite_master where sql is not null"
@ -330,13 +402,16 @@ class Database:
@property @property
def journal_mode(self): def journal_mode(self):
"Current journal_mode of this database"
return self.execute("PRAGMA journal_mode;").fetchone()[0] return self.execute("PRAGMA journal_mode;").fetchone()[0]
def enable_wal(self): def enable_wal(self):
"Set journal_mode to 'wal' to enable Write-Ahead Log mode"
if self.journal_mode != "wal": if self.journal_mode != "wal":
self.execute("PRAGMA journal_mode=wal;") self.execute("PRAGMA journal_mode=wal;")
def disable_wal(self): def disable_wal(self):
"Set journal_mode to 'delete' to disable Write-Ahead Log mode"
if self.journal_mode != "delete": if self.journal_mode != "delete":
self.execute("PRAGMA journal_mode=delete;") self.execute("PRAGMA journal_mode=delete;")
@ -597,7 +672,13 @@ class Database:
candidates.append(table.name) candidates.append(table.name)
return candidates return candidates
def add_foreign_keys(self, foreign_keys): def add_foreign_keys(self, foreign_keys: Iterable[Tuple[str, str, str, str]]):
"""
Add multiple foreign keys at once.
``foreign_keys`` should be a list of ``(table, column, other_table, other_column)``
tuples, see :ref:`python_api_add_foreign_keys`.
"""
# foreign_keys is a list of explicit 4-tuples # foreign_keys is a list of explicit 4-tuples
assert all( assert all(
len(fk) == 4 and isinstance(fk, (list, tuple)) for fk in foreign_keys len(fk) == 4 and isinstance(fk, (list, tuple)) for fk in foreign_keys
@ -633,7 +714,7 @@ class Database:
) )
# Construct SQL for use with "UPDATE sqlite_master SET sql = ? WHERE name = ?" # Construct SQL for use with "UPDATE sqlite_master SET sql = ? WHERE name = ?"
table_sql = {} table_sql: Dict[str, str] = {}
for table, column, other_table, other_column in foreign_keys_to_create: for table, column, other_table, other_column in foreign_keys_to_create:
old_sql = table_sql.get(table, self[table].schema) old_sql = table_sql.get(table, self[table].schema)
extra_sql = ",\n FOREIGN KEY([{column}]) REFERENCES [{other_table}]([{other_column}])\n".format( extra_sql = ",\n FOREIGN KEY([{column}]) REFERENCES [{other_table}]([{other_column}])\n".format(