diff --git a/docs/glossary.rst b/docs/glossary.rst index b74d12e..1b27d73 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -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`. See also diff --git a/docs/narr/index.rst b/docs/narr/index.rst index 74a3de5..47624eb 100644 --- a/docs/narr/index.rst +++ b/docs/narr/index.rst @@ -9,4 +9,5 @@ Documentation config/index cli/index handlers/index + providers/index db/index diff --git a/docs/narr/providers/app.rst b/docs/narr/providers/app.rst new file mode 100644 index 0000000..41df3e6 --- /dev/null +++ b/docs/narr/providers/app.rst @@ -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() diff --git a/docs/narr/providers/arch.rst b/docs/narr/providers/arch.rst new file mode 100644 index 0000000..459c135 --- /dev/null +++ b/docs/narr/providers/arch.rst @@ -0,0 +1,39 @@ + +Architecture +============ + +:term:`Providers` are similar to a "plugin" concept in that +multiple providers may be installed by different +:term:`packages`. 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` 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`. diff --git a/docs/narr/providers/index.rst b/docs/narr/providers/index.rst new file mode 100644 index 0000000..879b006 --- /dev/null +++ b/docs/narr/providers/index.rst @@ -0,0 +1,10 @@ + +Providers +========= + +.. toctree:: + :maxdepth: 2 + + overview + arch + app diff --git a/docs/narr/providers/overview.rst b/docs/narr/providers/overview.rst new file mode 100644 index 0000000..6696fa9 --- /dev/null +++ b/docs/narr/providers/overview.rst @@ -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` 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`. diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index bb18f3a..2a72b26 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -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`; 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`. + + 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() diff --git a/tests/test_app.py b/tests/test_app.py index d35478d..285b5a4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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')