2
0
Fork 0

fix: ensure config has no app when constructor finishes

had to move `make_engine_from_config()` out of app handler and define
as a separate function, so that `get_engines()` did not need to
instantiate the app handler.  because if it did, then config
extensions would lose the ability to set a default app handler - er,
they could do it but it would be ignored
This commit is contained in:
Lance Edgar 2024-07-04 06:21:38 -05:00
parent 3ab181b129
commit c3efbfbf7b
6 changed files with 110 additions and 102 deletions

View file

@ -142,63 +142,6 @@ class AppHandler:
if not os.path.exists(path): if not os.path.exists(path):
os.mkdir(path) os.mkdir(path)
def make_engine_from_config(
self,
config_dict,
prefix='sqlalchemy.',
**kwargs):
"""
Construct a new DB engine from configuration dict.
This is a wrapper around upstream
:func:`sqlalchemy:sqlalchemy.engine_from_config()`. For even
broader context of the SQLAlchemy
:class:`~sqlalchemy:sqlalchemy.engine.Engine` and their
configuration, see :doc:`sqlalchemy:core/engines`.
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 dict, 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]
default.url = sqlite:///tmp/default.sqlite
default.poolclass = sqlalchemy.pool:NullPool
default.pool_pre_ping = true
Note that if present, the ``poolclass`` value must be a "spec"
string, as required by
:func:`~wuttjamaican.util.load_object()`.
"""
import sqlalchemy as sa
config_dict = dict(config_dict)
# convert 'poolclass' arg to actual class
key = f'{prefix}poolclass'
if key in config_dict and 'poolclass' not in kwargs:
kwargs['poolclass'] = load_object(config_dict.pop(key))
# convert 'pool_pre_ping' arg to boolean
key = f'{prefix}pool_pre_ping'
if key in config_dict and 'pool_pre_ping' not in kwargs:
kwargs['pool_pre_ping'] = parse_bool(config_dict.pop(key))
engine = sa.engine_from_config(config_dict, prefix, **kwargs)
return engine
def make_session(self, **kwargs): def make_session(self, **kwargs):
""" """
Creates a new SQLAlchemy session for the app DB. By default Creates a new SQLAlchemy session for the app DB. By default

View file

@ -570,12 +570,12 @@ class WuttaConfig:
See also :doc:`/narr/handlers/app`. See also :doc:`/narr/handlers/app`.
""" """
if not hasattr(self, 'app'): if not hasattr(self, '_app'):
spec = self.get(f'{self.appname}.app.handler', usedb=False, spec = self.get(f'{self.appname}.app.handler', usedb=False,
default='wuttjamaican.app:AppHandler') default='wuttjamaican.app:AppHandler')
factory = load_object(spec) factory = load_object(spec)
self.app = factory(self) self._app = factory(self)
return self.app return self._app
class WuttaConfigExtension: class WuttaConfigExtension:

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttJamaican -- Base package for Wutta Framework # WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023 Lance Edgar # Copyright © 2023-2024 Lance Edgar
# #
# This file is part of Wutta Framework. # This file is part of Wutta Framework.
# #
@ -28,7 +28,7 @@ from collections import OrderedDict
import sqlalchemy as sa import sqlalchemy as sa
from wuttjamaican.util import parse_list from wuttjamaican.util import load_object, parse_bool, parse_list
def get_engines(config, prefix): def get_engines(config, prefix):
@ -62,8 +62,6 @@ def get_engines(config, prefix):
:returns: A dictionary of SQLAlchemy engines, with keys matching :returns: A dictionary of SQLAlchemy engines, with keys matching
those found in config. those found in config.
""" """
app = config.get_app()
keys = config.get(f'{prefix}.keys', usedb=False) keys = config.get(f'{prefix}.keys', usedb=False)
if keys: if keys:
keys = parse_list(keys) keys = parse_list(keys)
@ -75,11 +73,11 @@ def get_engines(config, prefix):
for key in keys: for key in keys:
key = key.strip() key = key.strip()
try: try:
engines[key] = app.make_engine_from_config(cfg, prefix=f'{key}.') engines[key] = make_engine_from_config(cfg, prefix=f'{key}.')
except KeyError: except KeyError:
if key == 'default': if key == 'default':
try: try:
engines[key] = app.make_engine_from_config(cfg, prefix='sqlalchemy.') engines[key] = make_engine_from_config(cfg, prefix='sqlalchemy.')
except KeyError: except KeyError:
pass pass
return engines return engines
@ -100,3 +98,56 @@ def get_setting(session, name):
""" """
sql = sa.text("select value from setting where name = :name") sql = sa.text("select value from setting where name = :name")
return session.execute(sql, params={'name': name}).scalar() return session.execute(sql, params={'name': name}).scalar()
def make_engine_from_config(
config_dict,
prefix='sqlalchemy.',
**kwargs):
"""
Construct a new DB engine from configuration dict.
This is a wrapper around upstream
:func:`sqlalchemy:sqlalchemy.engine_from_config()`. For even
broader context of the SQLAlchemy
:class:`~sqlalchemy:sqlalchemy.engine.Engine` and their
configuration, see :doc:`sqlalchemy:core/engines`.
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 dict, 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]
default.url = sqlite:///tmp/default.sqlite
default.poolclass = sqlalchemy.pool:NullPool
default.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(config_dict)
# convert 'poolclass' arg to actual class
key = f'{prefix}poolclass'
if key in config_dict and 'poolclass' not in kwargs:
kwargs['poolclass'] = load_object(config_dict.pop(key))
# convert 'pool_pre_ping' arg to boolean
key = f'{prefix}pool_pre_ping'
if key in config_dict and 'pool_pre_ping' not in kwargs:
kwargs['pool_pre_ping'] = parse_bool(config_dict.pop(key))
engine = sa.engine_from_config(config_dict, prefix, **kwargs)
return engine

View file

@ -7,6 +7,8 @@ from unittest import TestCase
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.engine import Engine
from sqlalchemy.pool import NullPool
from wuttjamaican.db import conf from wuttjamaican.db import conf
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
@ -93,3 +95,38 @@ class TestGetSetting(TestCase):
def test_missing_value(self): def test_missing_value(self):
value = conf.get_setting(self.session, 'foo') value = conf.get_setting(self.session, 'foo')
self.assertIsNone(value) self.assertIsNone(value)
class TestMakeEngineFromConfig(TestCase):
def test_basic(self):
engine = conf.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
})
self.assertIsInstance(engine, Engine)
def test_poolclass(self):
engine = conf.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
})
self.assertNotIsInstance(engine.pool, NullPool)
engine = conf.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
'sqlalchemy.poolclass': 'sqlalchemy.pool:NullPool',
})
self.assertIsInstance(engine.pool, NullPool)
def test_pool_pre_ping(self):
engine = conf.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
})
self.assertFalse(engine.pool._pre_ping)
engine = conf.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
'sqlalchemy.pool_pre_ping': 'true',
})
self.assertTrue(engine.pool._pre_ping)

View file

@ -9,8 +9,6 @@ from unittest.mock import patch, MagicMock
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.engine import Engine
from sqlalchemy.pool import NullPool
from wuttjamaican import app, db from wuttjamaican import app, db
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
@ -47,38 +45,6 @@ class TestAppHandler(TestCase):
self.assertEqual(len(os.listdir(tempdir)), 3) self.assertEqual(len(os.listdir(tempdir)), 3)
shutil.rmtree(tempdir) shutil.rmtree(tempdir)
def test_make_engine_from_config_basic(self):
engine = self.app.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
})
self.assertIsInstance(engine, Engine)
def test_make_engine_from_config_poolclass(self):
engine = self.app.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
})
self.assertNotIsInstance(engine.pool, NullPool)
engine = self.app.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
'sqlalchemy.poolclass': 'sqlalchemy.pool:NullPool',
})
self.assertIsInstance(engine.pool, NullPool)
def test_make_engine_from_config_pool_pre_ping(self):
engine = self.app.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
})
self.assertFalse(engine.pool._pre_ping)
engine = self.app.make_engine_from_config({
'sqlalchemy.url': 'sqlite://',
'sqlalchemy.pool_pre_ping': 'true',
})
self.assertTrue(engine.pool._pre_ping)
def test_make_session(self): def test_make_session(self):
session = self.app.make_session() session = self.app.make_session()
self.assertIsInstance(session, db.Session.class_) self.assertIsInstance(session, db.Session.class_)
@ -118,7 +84,7 @@ class TestAppProvider(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(appname='wuttatest') self.config = WuttaConfig(appname='wuttatest')
self.app = app.AppHandler(self.config) self.app = app.AppHandler(self.config)
self.config.app = self.app self.config._app = self.app
def test_constructor(self): def test_constructor(self):
@ -198,7 +164,7 @@ class TestGenericHandler(TestCase):
def setUp(self): def setUp(self):
self.config = WuttaConfig(appname='wuttatest') self.config = WuttaConfig(appname='wuttatest')
self.app = app.AppHandler(self.config) self.app = app.AppHandler(self.config)
self.config.app = self.app self.config._app = self.app
def test_constructor(self): def test_constructor(self):
handler = app.GenericHandler(self.config) handler = app.GenericHandler(self.config)

View file

@ -228,6 +228,17 @@ configure_logging = true
config = conf.WuttaConfig(files=[myfile]) config = conf.WuttaConfig(files=[myfile])
logging.config.fileConfig.assert_called_once() logging.config.fileConfig.assert_called_once()
def test_config_has_no_app_after_init(self):
# initial config should *not* have an app yet, otherwise
# extensions cannot specify a default app handler
config = conf.WuttaConfig()
self.assertFalse(hasattr(config, '_app'))
# but after that we can get an app okay
app = config.get_app()
self.assertIs(app, config._app)
def test_setdefault(self): def test_setdefault(self):
config = conf.WuttaConfig() config = conf.WuttaConfig()