from __future__ import annotations
import os
from contextlib import contextmanager
import psycopg as pg
from .util import coalesce
from .util import load_config
__all__ = [
"Client",
]
[docs]class Client:
"""
Creates and manages :py:class:`psycopg.Connection` objects
when used as a context manager::
client = Client(...)
with client.connect() as conn
with conn.cursor() as cursor:
...
Alternatively, if you don't need to use the connection directly,
you can also get a cursor::
client = Client(...)
with client.cursor() as cursor:
...
Connection parameters are given as ``kwargs``.
Common options are ``user``, ``dbname``, ``host``, and ``schema``
(a shorthand for setting the ``search_path`` option).
For the full list of available parameters, see
https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
Parameters passed from Python take priority,
but they may also be passed as environment variables
``SQLTRACK_DSN_<PARAM>`` (e.g., ``SQLTRACK_DSN_USER``),
or loaded from a config file, by default ``./sqltrack.conf``.
Experiment and Run objects obtain connections as required.
Nested contexts reuse the same connection (reentrant),
so they can be used to avoid connecting to the
database multiple times over a short period.
E.g., the following snippet will connect only once,
with the caveat that everything happens within the same transaction::
def do_queries(client, ...):
with client.cursor() as cursor:
cursor.execute(...)
...
client = Client(...)
with client.connect():
do_queries(client, ...)
do_queries(client, ...)
do_queries(client, ...)
Parameters:
config_path: Path to config file,
defaults to ``SQLTRACK_CONFIG_PATH`` environment variable,
and finally ``./sqltrack.conf``
kwargs: Connection parameters
"""
def __init__(self, config_path: str = None, **kwargs):
# get config values
config = load_config(coalesce(config_path, os.getenv("SQLTRACK_CONFIG_PATH")))
# update config from kwargs
for k, v in kwargs.items():
if v is not None:
config[k] = v
# user defaults to USER env var
if config.get("user") is None:
config["user"] = os.getenv("USER")
# database defaults to user
if config.get("dbname") is None:
config["dbname"] = config["user"]
# pop schema and put as search path
self.schema = None
if "schema" in config:
schema = config.pop("schema")
self.schema = schema
config["options"] = f"--search_path={schema}"
# the finished DSN
self.dsn = " ".join(f"{k}={pg.sql.quote(v)}" for k, v in config.items())
self._conn = None
[docs] @contextmanager
def connect(self) -> pg.Connection:
"""
Context manager that connects to the DB.
Use in with statement::
with client.connect() as conn:
... connection things ...
with client.cursor() as cursor:
... cursor things ...
... connection things ...
The connection is closed and any changes comitted
when the with block ends.
Nested contexts reuse the same connection (reentrant),
so they can be used to avoid connecting to the
database multiple times over a short period.
E.g., the following snippet will connect only once,
with the caveat that everything happens within the same transaction::
def do_queries(client, ...):
with client.cursor() as cursor:
cursor.execute(...)
...
client = Client(...)
with client.connect():
do_queries(client, ...)
do_queries(client, ...)
do_queries(client, ...)
"""
if self._conn is not None:
yield self._conn
return
self._conn = pg.connect(self.dsn)
try:
with self._conn:
yield self._conn
finally:
self._conn = None
[docs] def commit(self):
"""
Convenience function to call commit on the DB connection.
Raises :py:class:`RuntimeError` when not connected.
"""
if self._conn is None:
raise RuntimeError("not connected")
self._conn.commit()
[docs] def rollback(self):
"""
Convenience function to call rollback on the DB connection.
Raises :py:class:`RuntimeError` when not connected.
"""
if self._conn is None:
raise RuntimeError("not connected")
self._conn.rollback()
[docs] @contextmanager
def cursor(self) -> pg.Cursor:
"""
Connect to the DB and return a cursor.
Use in with statement::
with client.cursor() as cursor:
... cursor things ...
The connection is closed and any changes comitted
when the with block ends.
"""
with self.connect() as conn:
yield conn.cursor()