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:
commit
5c3c42d6b3
36 changed files with 3322 additions and 0 deletions
39
src/wuttjamaican/__init__.py
Normal file
39
src/wuttjamaican/__init__.py
Normal 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
|
3
src/wuttjamaican/_version.py
Normal file
3
src/wuttjamaican/_version.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
__version__ = '0.1.0'
|
98
src/wuttjamaican/app.py
Normal file
98
src/wuttjamaican/app.py
Normal 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
649
src/wuttjamaican/conf.py
Normal 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
|
28
src/wuttjamaican/db/__init__.py
Normal file
28
src/wuttjamaican/db/__init__.py
Normal 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
149
src/wuttjamaican/db/conf.py
Normal 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
104
src/wuttjamaican/db/sess.py
Normal 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
37
src/wuttjamaican/exc.py
Normal 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
154
src/wuttjamaican/util.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue