2
0
Fork 0

Add app providers, tests, docs

This commit is contained in:
Lance Edgar 2023-11-24 22:24:20 -06:00
parent 3cafa28ab9
commit 3a8bd1fce9
8 changed files with 288 additions and 0 deletions

View file

@ -42,6 +42,10 @@ Glossary
See also the human-friendly :term:`app title`.
app provider
A :term:`provider` which pertains to the :term:`app handler`.
See :doc:`narr/providers/app`.
app title
Human-friendly name for the :term:`app` (e.g. "Wutta Poser").
@ -104,6 +108,11 @@ Glossary
modules etc. which is installed via ``pip``. See also
:doc:`narr/install/pkg`.
provider
Python object which "provides" extra functionality to some
portion of the :term:`app`. Similar to a "plugin" concept; see
:doc:`narr/providers/index`.
settings table
Table in the :term:`app database` which is used to store
:term:`config settings<config setting>`. See also

View file

@ -9,4 +9,5 @@ Documentation
config/index
cli/index
handlers/index
providers/index
db/index

View file

@ -0,0 +1,57 @@
App Providers
=============
An :term:`app provider` is a :term:`provider` which can "extend" the
main :term:`app handler`.
The provider generally does this by adding extra methods to the app
handler. Note that it does this regardless of which app handler is
configured to be used.
:class:`~wuttjamaican.app.AppProvider` is the base class.
Adding a new Provider
---------------------
First define your provider class. Note that the method names should
include a "prefix" unique to your project (``poser_`` in this case).
This is to avoid naming collisions with the app handler itself, as
well as other app providers. So e.g. in ``poser/app.py``::
from wuttjamaican.app import AppProvider
class PoserAppProvider(AppProvider):
"""
App provider for Poser system
"""
# nb. method name uses 'poser_' prefix
def poser_do_something(self, **kwargs):
"""
Do something for Poser
"""
print("did something")
Register the :term:`entry point` in your ``setup.cfg``:
.. code-block:: ini
[options.entry_points]
wutta.providers =
poser = poser.app:PoserAppProvider
Assuming you have not customized the app handler proper, then you will
be using the *default* app handler yet it will behave as though it has
the "provided" methods::
from wuttjamaican.conf import make_config
# make normal app
config = make_config()
app = config.get_app()
# whatever this does..
app.poser_do_something()

View file

@ -0,0 +1,39 @@
Architecture
============
:term:`Providers<provider>` are similar to a "plugin" concept in that
multiple providers may be installed by different
:term:`packages<package>`. But whereas plugins are typically limited
to a particular interface (method list/signatures etc.) a provider can
also "bolt on" entirely new methods which may be used elsewhere in the
:term:`app`.
In that sense providers can perhaps be more accurately thought of as
"extensions" rather than plugins.
Providers may be related to :term:`handlers<handler>` in some cases,
but not all. But whereas there is only *one handler* configured for a
given portion of the app, multiple providers of the same type would
*all contribute* to the overall app. In other words they are always
enabled if installed. (Some may require a :term:`config setting` to
be "active" - but that is up to each provider.)
There can be many "types" of providers; each pertains to a certain
aspect of the overall app. A given type of provider will apply to a
certain "parent" class.
What a Provider Does
--------------------
Each type of provider pertains to a certain parent class. The app
itself will define the need for a provider type.
For instance there might be a "dashboard" class which can show various
blocks of info (charts etc.). Providers might be used to supplement the
parent dashboard class, by adding extra blocks to the display.
But in that example, providers look an awful lot like plugins.
For a better (and real) example see :doc:`app`.

View file

@ -0,0 +1,10 @@
Providers
=========
.. toctree::
:maxdepth: 2
overview
arch
app

View file

@ -0,0 +1,13 @@
Overview
========
The :term:`provider` concept is a way to "supplement" the main app
logic. It is different from a :term:`handler` though:
Providers are *more* analagous to "plugins" than are handlers. For
instance multiple :term:`app providers<app provider>` may be installed
by various packages and *each of these* will supplement the (one and
only) :term:`app handler`. See also :doc:`arch`.
So far there is only one provider type defined; see :doc:`app`.

View file

@ -25,6 +25,7 @@ WuttJamaican - app handler
"""
import os
import warnings
from wuttjamaican.util import load_entry_points, load_object, parse_bool
@ -46,6 +47,11 @@ class AppHandler:
:param config: Config object for the app. This should be an
instance of :class:`~wuttjamaican.conf.WuttaConfig`.
.. attribute:: providers
Dictionary of :class:`AppProvider` instances, as returned by
:meth:`get_all_providers()`.
"""
def __init__(self, config):
@ -66,6 +72,47 @@ class AppHandler:
"""
return self.config.appname
def __getattr__(self, name):
"""
Custom attribute getter, called when the app handler does not
already have an attribute named with ``name``.
This will delegate to the set of :term:`app providers<app
provider>`; the first provider with an appropriately-named
attribute wins, and that value is returned.
:returns: The first value found among the set of app
providers.
"""
if name == 'providers':
self.providers = self.get_all_providers()
return self.providers
# if 'providers' not in self.__dict__:
# self.__dict__['providers'] = self.get_all_providers()
for provider in self.providers.values():
if hasattr(provider, name):
return getattr(provider, name)
return super().__getattr__(name)
def get_all_providers(self):
"""
Load and return all registered providers.
Note that you do not need to call this directly; instead just
use :attr:`providers`.
:returns: Dictionary keyed by entry point name; values are
:class:`AppProvider` *instances*.
"""
providers = load_entry_points(f'{self.appname}.providers')
for key in list(providers):
providers[key] = providers[key](self.config)
return providers
def make_appdir(self, path, subfolders=None, **kwargs):
"""
Establish an :term:`app dir` at the given path.
@ -201,3 +248,36 @@ class AppHandler:
from .db import get_setting
return get_setting(session, name)
class AppProvider:
"""
Base class for :term:`app providers<app provider>`.
These can add arbitrary extra functionality to the main :term:`app
handler`. See also :doc:`/narr/providers/app`.
:param config: Config object for the app. This should be an
instance of :class:`~wuttjamaican.conf.WuttaConfig`.
Instances have the following attributes:
.. attribute:: config
Reference to the config object.
.. attribute:: app
Reference to the parent app handler.
"""
def __init__(self, config):
if isinstance(config, AppHandler):
warnings.warn("passing app handler to app provider is deprecated; "
"must pass config object instead",
DeprecationWarning, stacklevel=2)
config = config.config
self.config = config
self.app = config.get_app()

View file

@ -20,6 +20,7 @@ class TestAppHandler(TestCase):
def setUp(self):
self.config = WuttaConfig(appname='wuttatest')
self.app = app.AppHandler(self.config)
self.config.app = self.app
def test_init(self):
self.assertIs(self.app.config, self.config)
@ -109,3 +110,81 @@ class TestAppHandler(TestCase):
session.execute(sa.text("insert into setting values ('foo', 'bar');"))
value = self.app.get_setting(session, 'foo')
self.assertEqual(value, 'bar')
class TestAppProvider(TestCase):
def setUp(self):
self.config = WuttaConfig(appname='wuttatest')
self.app = app.AppHandler(self.config)
self.config.app = self.app
def test_constructor(self):
# config object is expected
provider = app.AppProvider(self.config)
self.assertIs(provider.config, self.config)
self.assertIs(provider.app, self.app)
# but can pass app handler instead
provider = app.AppProvider(self.app)
self.assertIs(provider.config, self.config)
self.assertIs(provider.app, self.app)
def test_get_all_providers(self):
class FakeProvider(app.AppProvider):
pass
# nb. we specify *classes* here
fake_providers = {'fake': FakeProvider}
with patch('wuttjamaican.app.load_entry_points') as load_entry_points:
load_entry_points.return_value = fake_providers
# sanity check, we get *instances* back from this
providers = self.app.get_all_providers()
load_entry_points.assert_called_once_with('wuttatest.providers')
self.assertEqual(len(providers), 1)
self.assertIn('fake', providers)
self.assertIsInstance(providers['fake'], FakeProvider)
def test_hasattr(self):
class FakeProvider(app.AppProvider):
def fake_foo(self):
pass
self.app.providers = {'fake': FakeProvider(self.config)}
self.assertTrue(hasattr(self.app, 'fake_foo'))
self.assertFalse(hasattr(self.app, 'fake_method_does_not_exist'))
def test_getattr(self):
class FakeProvider(app.AppProvider):
def fake_foo(self):
return 42
# nb. using instances here
fake_providers = {'fake': FakeProvider(self.config)}
with patch.object(self.app, 'get_all_providers') as get_all_providers:
get_all_providers.return_value = fake_providers
self.assertNotIn('providers', self.app.__dict__)
self.assertIs(self.app.providers, fake_providers)
get_all_providers.assert_called_once_with()
def test_getattr_providers(self):
# collection of providers is loaded on demand
self.assertNotIn('providers', self.app.__dict__)
self.assertIsNotNone(self.app.providers)
# custom attr does not exist yet
self.assertRaises(AttributeError, getattr, self.app, 'foo_value')
# but provider can supply the attr
self.app.providers['mytest'] = MagicMock(foo_value='bar')
self.assertEqual(self.app.foo_value, 'bar')