3
0
Fork 0

First commit, basic config (with db) and app handler

this has 100% test coverage and i intend to keep it that way.  api
docs have a good start but still need narrative.  several more things
must be added before i can seriously consider incorporating into
rattail but this seemed a good save point
This commit is contained in:
Lance Edgar 2023-10-28 17:48:37 -05:00
commit 5c3c42d6b3
36 changed files with 3322 additions and 0 deletions

View file

@ -0,0 +1,39 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - base package for Wutta Framework
There is just one function exposed in the root namespace:
:func:`~wuttjamaican.conf.make_config()`
Typical usage is something like::
import wuttjamaican as wj
config = wj.make_config(appname='poser')
app = config.get_app()
"""
from ._version import __version__
from .conf import make_config

View file

@ -0,0 +1,3 @@
# -*- coding: utf-8; -*-
__version__ = '0.1.0'

98
src/wuttjamaican/app.py Normal file
View file

@ -0,0 +1,98 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - app handler
"""
from wuttjamaican.util import load_entry_points
class AppHandler:
"""
Base class and default implementation for top-level app handler.
aka. "the handler to handle all handlers"
aka. "one handler to bind them all"
There is normally no need to create one of these yourself; rather
you should call :meth:`~wuttjamaican.conf.WuttaConfig.get_app()`
on the config object if you need the app handler.
:param config: Config object for the app. This should be an
instance of :class:`~wuttjamaican.conf.WuttaConfig`.
"""
def __init__(self, config):
self.config = config
self.handlers = {}
def make_session(self, **kwargs):
"""
Creates a new SQLAlchemy session for the app DB. By default
this will create a new :class:`~wuttjamaican.db.sess.Session`
instance.
:returns: SQLAlchemy session for the app DB.
"""
from .db import Session
return Session(**kwargs)
def short_session(self, **kwargs):
"""
Returns a context manager for a short-lived database session.
This is a convenience wrapper around
:class:`~wuttjamaican.db.sess.short_session`.
If caller does not specify ``factory`` nor ``config`` params,
this method will provide a default factory in the form of
:meth:`make_session`.
"""
from .db import short_session
if 'factory' not in kwargs and 'config' not in kwargs:
kwargs['factory'] = self.make_session
return short_session(**kwargs)
def get_setting(self, session, name, **kwargs):
"""
Get a setting value from the DB.
This does *not* consult the config object directly to
determine the setting value; it always queries the DB.
Default implementation is just a convenience wrapper around
:func:`~wuttjamaican.db.conf.get_setting()`.
:param session: App DB session.
:param name: Name of the setting to get.
:returns: Setting value as string, or ``None``.
"""
from .db import get_setting
return get_setting(session, name)

649
src/wuttjamaican/conf.py Normal file
View file

@ -0,0 +1,649 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - app configuration
"""
import configparser
import importlib
import logging
import os
import sys
import tempfile
import config as configuration
from wuttjamaican.util import (load_entry_points, load_object,
parse_bool, parse_list,
UNSPECIFIED)
from wuttjamaican.exc import ConfigurationError
log = logging.getLogger(__name__)
class WuttaConfig:
"""
Configuration class for Wutta Framework
A single instance of this class is typically created on app
startup, by calling :func:`make_config()`.
The global config object is mainly responsible for providing
config values to the app, via :meth:`get()` and similar methods.
The config object may have more than one place to look when
finding values. This can vary somewhat but often the priority for
lookup is like:
* settings table in the DB
* one or more INI files
* "defaults" provided by app logic
:param files: List of file paths from which to read config values.
:param appname: This string forms the basis of certain other
things, namely some of the config keys which will be checked to
determine default behavior of the config object itself (if they
are not specified via constructor).
:param usedb: Flag indicating whether config values should ever be
looked up from the DB. Note that you can override this when
calling :meth:`get()`.
:param preferdb: Flag indicating whether values from DB should be
preferred over the values from INI files or app defaults. Note
that you can override this when calling :meth:`get()`.
Attributes available on the config instance:
.. attribute:: configuration
Reference to the
:class:`python-configuration:config.ConfigurationSet` instance
which houses the full set of config values which are kept in
memory. This does *not* contain settings from DB, but *does*
contain :attr:`defaults` as well as values read from INI files.
.. attribute:: defaults
Reference to the
:class:`python-configuration:config.Configuration` instance
containing config *default* values. This is exposed in case
it's useful, but in practice you should not update it directly;
instead use :meth:`setdefault()`.
.. attribute:: files_read
List of all INI config files which were read on app startup.
These are listed in the same order as they were read. This
sequence also reflects priority for value lookups, i.e. the
first file with the value wins.
"""
def __init__(
self,
files=[],
defaults={},
appname='wutta',
usedb=None,
preferdb=None,
configure_logging=None,
):
self.appname = appname
configs = []
# read all files requested
self.files_read = []
for path in files:
self._load_ini_configs(path, configs, require=True)
log.debug("config files were: %s", self.files_read)
# add config for use w/ setdefault()
self.defaults = configuration.Configuration(defaults)
configs.append(self.defaults)
# master config set
self.configuration = configuration.ConfigurationSet(*configs)
# establish logging
if configure_logging is None:
configure_logging = self.get_bool(f'{self.appname}.config.configure_logging',
default=False, usedb=False)
if configure_logging:
self._configure_logging()
# usedb flag
self.usedb = usedb
if self.usedb is None:
self.usedb = self.get_bool(f'{self.appname}.config.usedb',
default=False, usedb=False)
# preferdb flag
self.preferdb = preferdb
if self.usedb and self.preferdb is None:
self.preferdb = self.get_bool(f'{self.appname}.config.preferdb',
default=False, usedb=False)
# configure main app DB if applicable, or disable usedb flag
try:
from .db import Session, get_engines
except ImportError:
if self.usedb:
log.warning("config created with `usedb = True`, but can't import "
"DB module(s), so setting `usedb = False` instead",
exc_info=True)
self.usedb = False
self.preferdb = False
else:
self.appdb_engines = get_engines(self, f'{self.appname}.db')
self.appdb_engine = self.appdb_engines.get('default')
Session.configure(bind=self.appdb_engine)
log.debug("config files read: %s", self.files_read)
def _load_ini_configs(self, path, configs, require=True):
path = os.path.abspath(path)
# try to load config from the given path
try:
config = configuration.config_from_ini(path, read_from_file=True)
except FileNotFoundError:
if not require:
log.warning("INI config file not found: %s", path)
return
raise
# ok add that one to the mix
configs.append(config)
self.files_read.append(path)
# need parent folder of that path, for %(here)s interpolation
here = os.path.dirname(path)
# bring in any "required" files
requires = config.get(f'{self.appname}.config.require')
if requires:
for path in parse_list(requires):
path = path % {'here': here}
self._load_ini_configs(path, configs, require=True)
# bring in any "included" files
includes = config.get(f'{self.appname}.config.include')
if includes:
for path in parse_list(includes):
path = path % {'here': here}
self._load_ini_configs(path, configs, require=False)
def setdefault(
self,
key,
value):
"""
Establish a default config value for the given key.
Note that there is only *one* default value per key. If
multiple calls are made with the same key, the first will set
the default and subsequent calls have no effect.
:returns: The current config value, *outside of the DB*. For
various reasons this method may not be able to lookup
settings from the DB, e.g. during app init. So it can only
determine the value per INI files + config defaults.
"""
# set default value, if not already set
self.defaults.setdefault(key, value)
# get current value, sans db
return self.get(key, usedb=False)
def get(
self,
key,
default=UNSPECIFIED,
require=False,
message=None,
usedb=None,
preferdb=None,
session=None,
):
"""
Retrieve a string value from config.
.. warning::
While the point of this method is to return a *string*
value, it is possible for a key to be present in config
which corresponds to a "subset" of the config, and not a
simple value. For instance with this config file:
.. code-block:: ini
[foo]
bar = 1
bar.baz = 2
If you invoke ``config.get('foo.bar')`` the return value
is somewhat ambiguous. At first glance it should return
``'1'`` - but just as valid would be to return the dict::
{'baz': '2'}
And similarly, if you invoke ``config.get('foo')`` then
the return value "should be" the dict::
{'bar': '1',
'bar.baz': '2'}
Despite all that ambiguity, again the whole point of this
method is to return a *string* value, only. Therefore in
any case where the return value "should be" a dict, per
logic described above, this method will *ignore* that and
simply return ``None`` (or rather the ``default`` value).
It is important also to understand that in fact, there is
no "real" ambiguity per se, but rather a dict (subset)
would always get priority over a simple string value. So
in the first example above, ``config.get('foo.bar')`` will
always return the ``default`` value. The string value
``'1'`` will never be returned since the dict/subset
overshadows it, and this method will only return the
default value in lieu of any dict.
:param key: String key for which value should be returned.
:param default: Default value to be returned, if config does
not contain the key. If no default is specified, ``None``
will be assumed.
:param require: If set, an error will be raised if config does
not contain the key. If not set, default value is returned
(which may be ``None``).
Note that it is an error to specify a default value if you
also specify ``require=True``.
:param message: Optional first part of message to be used,
when raising a "value not found" error. If not specified,
a default error message will be generated.
:param usedb: Flag indicating whether config values should be
looked up from the DB. The default for this param is
``None``, in which case the :attr:`usedb` flag determines
the behavior.
:param preferdb: Flag indicating whether config values from DB
should be preferred over values from INI files and/or app
defaults. The default for this param is ``None``, in which
case the :attr:`preferdb` flag determines the behavior.
:param session: Optional SQLAlchemy session to use for DB lookups.
NOTE: This param is not yet implemented; currently ignored.
:returns: Value as string.
"""
if require and default is not UNSPECIFIED:
raise ValueError("must not specify default value when require=True")
# should we use/prefer db?
if usedb is None:
usedb = self.usedb
if usedb and preferdb is None:
preferdb = self.preferdb
# read from db first if so requested
if usedb and preferdb:
value = self.get_from_db(key, session=session)
if value is not None:
return value
# read from defaults + INI files
value = self.configuration.get(key)
if value is not None:
# nb. if the "value" corresponding to the given key is in
# fact a subset/dict of more config values, then we must
# "ignore" that. so only return the value if it is *not*
# such a config subset.
if not isinstance(value, configuration.Configuration):
return value
# read from db last if so requested
if usedb and not preferdb:
value = self.get_from_db(key, session=session)
if value is not None:
return value
# raise error if required value not found
if require:
message = message or "missing or invalid config"
raise ConfigurationError(f"{message}; please set config value for: {key}")
# give the default value if specified
if default is not UNSPECIFIED:
return default
def get_from_db(self, key, session=None):
"""
Retrieve a config value from database settings table.
This is a convenience wrapper around
:meth:`~wuttjamaican.app.AppHandler.get_setting()`.
"""
app = self.get_app()
with app.short_session(session=session) as s:
return app.get_setting(s, key)
def require(self, *args, **kwargs):
"""
Retrieve a value from config, or raise error if no value can
be found. This is just a shortcut, so these work the same::
config.get('foo', require=True)
config.require('foo')
"""
kwargs['require'] = True
return self.get(*args, **kwargs)
def get_bool(self, *args, **kwargs):
"""
Retrieve a boolean value from config.
Accepts same params as :meth:`get()` but if a value is found,
it will be coerced to boolean via
:func:`~wuttjamaican.util.parse_bool()`.
"""
value = self.get(*args, **kwargs)
return parse_bool(value)
def get_dict(self, prefix):
"""
Retrieve a particular group of values, as a dictionary.
Please note, this will only return values from INI files +
defaults. It will *not* return values from DB settings. In
other words it assumes ``usedb=False``.
For example given this config file:
.. code-block:: ini
[wutta.db]
keys = default, host
default.url = sqlite:///tmp/default.sqlite
host.url = sqlite:///tmp/host.sqlite
host.pool_pre_ping = true
One can get the "dict" for SQLAlchemy engine config via::
config.get_dict('wutta.db')
And the dict would look like::
{'keys': 'default, host',
'default.url': 'sqlite:///tmp/default.sqlite',
'host.url': 'sqlite:///tmp/host.sqlite',
'host.pool_pre_ping': 'true'}
:param prefix: String prefix corresponding to a subsection of
the config.
:returns: Dictionary containing the config subsection.
"""
try:
values = self.configuration[prefix]
except KeyError:
return {}
return values.as_dict()
def _configure_logging(self):
"""
This will save the current config parser defaults to a
temporary file, and use this file to configure Python's
standard logging module.
"""
# write current values to file suitable for logging auto-config
path = self._write_logging_config_file()
try:
logging.config.fileConfig(path, disable_existing_loggers=False)
except configparser.NoSectionError as error:
log.warning("tried to configure logging, but got NoSectionError: %s", error)
else:
log.debug("configured logging")
finally:
os.remove(path)
def _write_logging_config_file(self):
# load all current values into configparser
parser = configparser.RawConfigParser()
for section, values in self.configuration.items():
parser.add_section(section)
for option, value in values.items():
parser.set(section, option, value)
# write INI file and return path
fd, path = tempfile.mkstemp(suffix='.conf')
os.close(fd)
with open(path, 'wt') as f:
parser.write(f)
return path
def get_app(self):
"""
Returns the global :class:`~wuttjamaican.app.AppHandler`
instance, creating it if necessary.
"""
if not hasattr(self, 'app'):
spec = self.get(f'{self.appname}.app.handler', usedb=False,
default='wuttjamaican.app:AppHandler')
factory = load_object(spec)
self.app = factory(self)
return self.app
def generic_default_files(appname):
"""
Returns a list of default file paths which might be used for
making a config object. This function does not check if the paths
actually exist.
:param appname: App name to be used as basis for default filenames.
:returns: List of default file paths.
"""
if sys.platform == 'win32':
# use pywin32 to fetch official defaults
try:
from win32com.shell import shell, shellcon
except ImportError:
return []
return [
# e.g. C:\..?? TODO: what is the user-specific path on win32?
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_APPDATA), appname, f'{appname}.conf'),
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_APPDATA), f'{appname}.conf'),
# e.g. C:\ProgramData\wutta\wutta.conf
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_COMMON_APPDATA), appname, f'{appname}.conf'),
os.path.join(shell.SHGetSpecialFolderPath(
0, shellcon.CSIDL_COMMON_APPDATA), f'{appname}.conf'),
]
# default paths for *nix
return [
f'{sys.prefix}/app/{appname}.conf',
os.path.expanduser(f'~/.{appname}/{appname}.conf'),
os.path.expanduser(f'~/.{appname}.conf'),
f'/usr/local/etc/{appname}/{appname}.conf',
f'/usr/local/etc/{appname}.conf',
f'/etc/{appname}/{appname}.conf',
f'/etc/{appname}.conf',
]
def make_config(
files=None,
plus_files=None,
appname='wutta',
env_files_name=None,
env_plus_files_name=None,
env=None,
default_files=None,
usedb=None,
preferdb=None,
extend=True,
extension_entry_points=None):
"""
Returns a new config object (presumably for global use),
initialized per the given parameters and (usually) further
modified by all registered config extensions.
:param files: Config file path(s) to be loaded. If not specified,
then some "default" behavior will be attempted. (This will
check for env var or fallback to system default paths. Or you
can override all that by specifying some path(s) here.)
:param plus_files: Additional config path(s) to be loaded. You
may specify a "config tweak" file(s) here, and leave ``files``
empty, to get "defaults plus tweak" behavior.
:param appname: Optional "app name" to use as basis for other
things - namely, constructing the default config file paths
etc. For instance the default ``appname`` value is ``'wutta'``
which leads to default env vars like ``WUTTA_CONFIG_FILES``.
:param env_files_name: Name of the environment variable to read,
if ``files`` is not specified. The default is
``WUTTA_CONFIG_FILES`` unless you override ``appname``.
:param env_plus_files_name: Name of the environment variable to
read, if ``plus_files`` is not specified. The default is
``WUTTA_CONFIG_PLUS_FILES`` unless you override ``appname``.
:param env: Optional override for the ``os.environ`` dict.
:param default_files: Optional way to identify the "default" file
path(s), if neither ``files`` nor ``env_files_name`` yield
anything. This can be a list of potential file paths, or a
callable which returns such a list. If a callable, it should
accept a single ``appname`` arg.
:param usedb: Passed to the :class:`WuttaConfig` constructor.
:param preferdb: Passed to the :class:`WuttaConfig` constructor.
:param extend: Whether to "auto-extend" the config with all
registered extensions.
As a general rule, ``make_config()`` should only be called
once, upon app startup. This is because some of the config
extensions may do things which should only happen one time.
However if ``extend=False`` is specified, then no extensions
are invoked, so this may be done multiple times.
(Why anyone would need this, is another question..maybe only
useful for tests.)
:param extension_entry_points: Name of the ``setuptools`` entry
points section, used to identify registered config extensions.
The default is ``wutta.config.extensions`` unless you override
``appname``.
"""
if env is None:
env = os.environ
# first identify any "primary" config files
if files is None:
if not env_files_name:
env_files_name = f'{appname.upper()}_CONFIG_FILES'
files = env.get(env_files_name)
if files is not None:
files = files.split(os.pathsep)
elif default_files:
if callable(default_files):
files = default_files(appname) or []
elif isinstance(default_files, str):
files = [default_files]
else:
files = list(default_files)
else:
files = []
for path in generic_default_files(appname):
if os.path.exists(path):
files.append(path)
elif isinstance(files, str):
files = [files]
else:
files = list(files)
# then identify any "plus" (config tweak) files
if plus_files is None:
if not env_plus_files_name:
env_plus_files_name = f'{appname.upper()}_CONFIG_PLUS_FILES'
plus_files = env.get(env_plus_files_name)
if plus_files is not None:
plus_files = plus_files.split(os.pathsep)
else:
plus_files = []
elif isinstance(plus_files, str):
plus_files = [plus_files]
else:
plus_files = list(plus_files)
# combine all files
files.extend(plus_files)
# make config object
config = WuttaConfig(files, appname=appname,
usedb=usedb, preferdb=preferdb)
# maybe extend config object
if extend:
if not extension_entry_points:
extension_entry_points = f'{appname}.config.extensions'
# apply all registered extensions
# TODO: maybe let config disable some extensions?
extensions = load_entry_points(extension_entry_points)
for extension in extensions.values():
log.debug("applying config extension: %s", extension.key)
extension().configure(config)
return config

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - app database
"""
from .sess import Session, short_session
from .conf import get_setting, get_engines

149
src/wuttjamaican/db/conf.py Normal file
View file

@ -0,0 +1,149 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - database configuration
"""
from collections import OrderedDict
import sqlalchemy as sa
from wuttjamaican.util import load_object, parse_bool, parse_list
def engine_from_config(configuration, prefix='sqlalchemy.', **kwargs):
"""
Construct a new DB engine from configuration.
This is a wrapper around upstream
:func:`sqlalchemy:sqlalchemy.engine_from_config()`.
The purpose of the customization is to allow certain attributes of
the engine to be driven by config, whereas the upstream function
is more limited in that regard. The following in particular:
* ``poolclass``
* ``pool_pre_ping``
If these options are present in the configuration dictionary, they
will be coerced to appropriate Python equivalents and then passed
as kwargs to the upstream function.
An example config file leveraging this feature:
.. code-block:: ini
[wutta.db]
sqlalchemy.url = sqlite:///tmp/default.sqlite
sqlalchemy.poolclass = sqlalchemy.pool:NullPool
sqlalchemy.pool_pre_ping = true
Note that if present, the ``poolclass`` value must be a "spec"
string, as required by :func:`~wuttjamaican.util.load_object()`.
"""
config_dict = dict(configuration)
# convert 'poolclass' arg to actual class
key = f'{prefix}poolclass'
if key in config_dict:
kwargs.setdefault('poolclass', load_object(config_dict[key]))
del config_dict[key]
# convert 'pool_pre_ping' arg to boolean
key = f'{prefix}pool_pre_ping'
if key in config_dict:
kwargs.setdefault('pool_pre_ping', parse_bool(config_dict[key]))
del config_dict[key]
engine = sa.engine_from_config(config_dict, prefix, **kwargs)
return engine
def get_engines(config, prefix):
"""
Construct and return all database engines defined for a given
config prefix.
For instance if you have a config file with:
.. code-block:: ini
[wutta.db]
keys = default, host
default.url = sqlite:///tmp/default.sqlite
host.url = sqlite:///tmp/host.sqlite
And then you call this function to get those DB engines::
get_engines(config, 'wutta.db')
The result of that will be like::
{'default': Engine(bind='sqlite:///tmp/default.sqlite'),
'host': Engine(bind='sqlite:///tmp/host.sqlite')}
:param config: App config object.
:param prefix: Prefix for the config "section" which contains DB
connection info.
:returns: A dictionary of SQLAlchemy engines, with keys matching
those found in config.
"""
keys = config.get(f'{prefix}.keys', usedb=False)
if keys:
keys = parse_list(keys)
else:
keys = ['default']
engines = OrderedDict()
cfg = config.get_dict(prefix)
for key in keys:
key = key.strip()
try:
engines[key] = engine_from_config(cfg, prefix=f'{key}.')
except KeyError:
if key == 'default':
try:
engines[key] = engine_from_config(cfg, prefix='sqlalchemy.')
except KeyError:
pass
return engines
def get_setting(session, name):
"""
Get a setting value from the DB.
Note that this assumes (for now?) the DB contains a table named
``setting`` with ``(name, value)`` columns.
:param session: App DB session.
:param name: Name of the setting to get.
:returns: Setting value as string, or ``None``.
"""
sql = sa.text("select value from setting where name = :name")
return session.execute(sql, params={'name': name}).scalar()

104
src/wuttjamaican/db/sess.py Normal file
View file

@ -0,0 +1,104 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - database sessions
.. class:: Session
SQLAlchemy session class used for all (normal) app database
connections.
See the upstream :class:`sqlalchemy:sqlalchemy.orm.Session` docs
for more info.
"""
from sqlalchemy import orm
Session = orm.sessionmaker()
class short_session:
"""
Context manager for a short-lived database session.
A canonical use case for this is when the config object needs to
grab a single setting value from the DB, but it does not have an
active DB session to do it. This context manager is used to
produce the session, and close it when finished. For example::
with short_session(config) as s:
result = s.query("select something from somewhere").scalar()
How it goes about producing the session instance will depend on
which of the following 3 params are given (explained below):
* ``config``
* ``factory``
* ``session``
Note that it is also okay if you provide *none* of the above
params, in which case the main :class:`Session` class will be used
as the factory.
:param config: Optional app config object. If a new session must
be created, the config will be consulted to determine the
factory which is used to create the new session.
:param factory: Optional factory to use when making a new session.
If specified, this will override the ``config`` mechanism.
:param session: Optional SQLAlchemy session instance. If a valid
session is provided here, it will be used instead of creating a
new/temporary session.
:param commit: Whether the temporary session should be committed
before it is closed. This flag has no effect if a valid
``session`` instance is provided, since no temporary session
will be created.
"""
def __init__(self, config=None, factory=None, session=None, commit=False):
self.config = config
self.factory = factory
self.session = session
self.private = not bool(session)
self.commit = commit
def __enter__(self):
if not self.session:
if not self.factory:
if self.config:
app = self.config.get_app()
self.factory = app.make_session
else:
self.factory = Session
self.session = self.factory()
return self.session
def __exit__(self, exc_type, exc_value, traceback):
if self.private:
if self.commit:
self.session.commit()
self.session.close()
self.session = None

37
src/wuttjamaican/exc.py Normal file
View file

@ -0,0 +1,37 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - exceptions
"""
class WuttaError(Exception):
"""
Base class for all exceptions specific to Wutta Framework.
"""
class ConfigurationError(WuttaError):
"""
Generic class for configuration errors.
"""

154
src/wuttjamaican/util.py Normal file
View file

@ -0,0 +1,154 @@
# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
WuttJamaican - utilities
"""
import importlib
import logging
import shlex
log = logging.getLogger(__name__)
# nb. this is used as default kwarg value in some places, to
# distinguish passing a ``None`` value, vs. *no* value at all
UNSPECIFIED = object()
def load_entry_points(group, ignore_errors=False):
"""
Load a set of ``setuptools``-style entry points.
This is used to locate "plugins" and similar things, e.g. the set
of subcommands which belong to a main command.
:param group: The group (string name) of entry points to be
loaded.
:ignore_errors: If false (the default), any errors will be raised
normally. If true, errors will be logged but not raised.
:returns: A dictionary whose keys are the entry point names, and
values are the loaded entry points.
"""
entry_points = {}
try:
# nb. this package was added in python 3.8
import importlib.metadata
except ImportError:
# older setup, must use pkg_resources
# TODO: remove this section once we require python 3.8
from pkg_resources import iter_entry_points
for entry_point in iter_entry_points(group):
try:
ep = entry_point.load()
except:
if not ignore_errors:
raise
log.warning("failed to load entry point: %s", entry_point,
exc_info=True)
else:
entry_points[entry_point.name] = ep
else:
# newer setup (python >= 3.8); can use importlib
eps = importlib.metadata.entry_points()
for entry_point in eps.select(group=group):
try:
ep = entry_point.load()
except:
if not ignore_errors:
raise
log.warning("failed to load entry point: %s", entry_point,
exc_info=True)
else:
entry_points[entry_point.name] = ep
return entry_points
def load_object(spec):
"""
Load an arbitrary object from a module, according to the spec.
The spec string should contain a dotted path to an importable module,
followed by a colon (``':'``), followed by the name of the object to be
loaded. For example:
.. code-block:: none
wuttjamaican.util:parse_bool
You'll notice from this example that "object" in this context refers to any
valid Python object, i.e. not necessarily a class instance. The name may
refer to a class, function, variable etc. Once the module is imported, the
``getattr()`` function is used to obtain a reference to the named object;
therefore anything supported by that approach should work.
:param spec: Spec string.
:returns: The specified object.
"""
if not spec:
raise ValueError("no object spec provided")
module_path, name = spec.split(':')
module = importlib.import_module(module_path)
return getattr(module, name)
def parse_bool(value):
"""
Derive a boolean from the given string value.
"""
if value is None:
return None
if isinstance(value, bool):
return value
if str(value).lower() in ('true', 'yes', 'y', 'on', '1'):
return True
return False
def parse_list(value):
"""
Parse a configuration value, splitting by whitespace and/or commas
and taking quoting into account etc., yielding a list of strings.
"""
if value is None:
return []
parser = shlex.shlex(value)
parser.whitespace += ','
parser.whitespace_split = True
values = list(parser)
for i, value in enumerate(values):
if value.startswith('"') and value.endswith('"'):
values[i] = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
values[i] = value[1:-1]
return values