Components check their own settings
authorKarl O. Pinc <kop@karlpinc.com>
Sat, 29 Aug 2020 21:54:02 +0000 (16:54 -0500)
committerKarl O. Pinc <kop@karlpinc.com>
Sat, 29 Aug 2020 21:57:27 +0000 (16:57 -0500)
README.rst
src/pgwui_server/__init__.py
src/pgwui_server/checkset.py [new file with mode: 0644]
tests/test___init__.py
tests/test_checkset.py [new file with mode: 0644]

index 6a95adf5c564a8331deb647c215a151bd5d55622..d585991c5a8a91ae20dad44c74498714eaafcc61 100644 (file)
@@ -333,6 +333,66 @@ setting's value may be turned off.  To do this change the
 ``pgwui.validate_hmac`` setting to ``False``.  Having validation off
 in production is not recommended.
 
+Writing Plugable Components
+---------------------------
+
+Your setup.py must include a ``pgwui.components`` entry point.\ [#f1]_
+The value assigned to the given module must be the name of the PGWUI
+component which the module impliments.  There must also be a
+``pgwui.check_settings`` entrypoint conforming to the following::
+
+   def check_settings(component_config):
+
+       component_config is a dict containing the configuration of the
+       component.
+
+       The components configuation settings should be checked,
+       particularly for required configuration keys and unknown
+       configuration keys.
+
+       Return a list of the errors found. Preferably, an error is a child
+       of UnknownSettingKeyError but it may be anything with a string
+       representation.
+
+In the case of the ``pgwui_upload`` module, both the module name and
+the component name are "pgwui_upload".  The check_settings module name
+is ``check_settings`` and the function which does the check has the
+same name.  So the entry point assignment looks like::
+
+    # Setup an entry point to support PGWUI autoconfigure discovery.
+    entry_points={'pgwui.components': '.pgwui_upload = pgwui_upload',
+                  'pgwui.check_settings':
+                      '.pgwui_upload = check_settings:check_settings'}
+
+
+Your module's ``__init__.py`` must setup the component's default
+configuration::
+
+   '''Provide a way to configure PGWUI.
+   '''
+   PGWUI_COMPONENT = 'pgwui_componentname'
+   DEFAULT_COMPONENTNAME_ROUTE = '/componentname'
+   DEFAULT_COMPONENTNAME_MENU_LABEL = \
+       'componentname --  Example PGWUI Component Label'
+
+
+   def init_menu(config):
+       '''Add default menu information into settings when they are not present
+       '''
+       settings = config.get_settings()
+       settings.setdefault('pgwui', dict())
+       settings['pgwui'].setdefault(PGWUI_COMPONENT, dict())
+       settings['pgwui'][PGWUI_COMPONENT].setdefault(
+           'menu_label', DEFAULT_COMPONENTNAME_MENU_LABEL)
+
+
+   def includeme(config):
+       '''Pyramid configuration for PGWUI_Componentname
+       '''
+       init_menu(config)
+       config.add_route(PGWUI_COMPONENT, DEFAULT_COMPONENTNAME_ROUTE)
+       config.scan()
+
 
 Complete Documentation
 ----------------------
@@ -370,3 +430,8 @@ provided by `The Dian Fossey Gorilla Fund
 .. _Pyramid: https://trypyramid.com/
 .. _WSGI: https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface
 .. _pip: https://pip.pypa.io/en/stable/
+
+
+.. rubric:: Footnotes
+
+.. [#f1] https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins
index ae802f350ad56c5d7b320e31dd34fe297cdfff2b..0c623a6518c1a8f3222ff8c70f3abbcfad19cfde 100644 (file)
@@ -53,13 +53,19 @@ log = logging.getLogger(__name__)
 
 # Functions
 
-def abort_on_bad_setting(errors, key, component_keys):
+def abort_on_bad_setting(
+        errors, component_keys, component_checkers, key, settings):
     '''Abort on a bad pgwui setting
     '''
     if key[:6] == 'pgwui.':
-        if (key[6:] not in SETTINGS
-                and key not in component_keys):
-            errors.append(exceptions.UnknownSettingKeyError(key))
+        if key in component_keys:
+            component = component_keys[key]
+            if component in component_checkers:
+                errors.extend(
+                    component_checkers[component](settings.get(key, {})))
+        else:
+            if key[6:] not in SETTINGS:
+                errors.append(exceptions.UnknownSettingKeyError(key))
 
 
 def require_setting(errors, setting, settings):
@@ -156,14 +162,20 @@ def parse_component_settings(component_keys, key, settings):
         settings[key] = dict(parse_assignments(settings[key]))
 
 
+def map_keys_to_components(components):
+    return dict([(plugin.component_to_key(component), component)
+                 for component in components])
+
+
 def validate_settings(errors, settings, components):
     '''Be sure all settings validate
     '''
-    component_keys = [plugin.component_to_key(component)
-                      for component in components]
+    component_keys = map_keys_to_components(components)
+    component_checkers = plugin.find_pgwui_check_settings()
     for key in settings.keys():
         parse_component_settings(component_keys, key, settings)
-        abort_on_bad_setting(errors, component_keys, key)
+        abort_on_bad_setting(
+            errors, component_keys, component_checkers, key, settings)
     validate_setting_values(errors, settings)
     validate_hmac(errors, settings)
 
diff --git a/src/pgwui_server/checkset.py b/src/pgwui_server/checkset.py
new file mode 100644 (file)
index 0000000..97b2e23
--- /dev/null
@@ -0,0 +1,60 @@
+# Copyright (C) 2020 The Meme Factory, Inc.  http://www.karlpinc.com/
+
+# This file is part of PGWUI_Server.
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program 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
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+#
+
+# Karl O. Pinc <kop@karlpinc.com>
+
+'''Helper routines for checking a PGWUI component's settings
+'''
+
+from ast import literal_eval
+
+from . import exceptions
+
+
+def require_settings(component, required_settings, conf):
+    errors = []
+    for setting in required_settings:
+        if setting not in conf:
+            errors.append(exceptions.MissingSettingError(
+                '{}.{}'.format(component, setting)))
+    return errors
+
+
+def unknown_settings(component, settings, conf):
+    errors = []
+    for setting in conf:
+        if setting not in settings:
+            errors.append(exceptions.UnknownSettingKeyError(
+                '{}.{}'.format(component, setting)))
+    return errors
+
+
+def boolean_settings(component, booleans, conf):
+    errors = []
+    for setting in booleans:
+        if setting in conf:
+            try:
+                val = literal_eval(conf[setting])
+            except ValueError:
+                val = None
+            if (val is not True
+                    and val is not False):
+                errors.append(exceptions.NotBooleanSettingError(
+                    '{}.{}'.format(component, setting), conf[setting]))
+    return errors
index 57535cf81d22004b67682ab178a156b243be22bb..185bf21ab4b2f7ec7321870ea4c527932db99d66 100644 (file)
 
 # Karl O. Pinc <kop@karlpinc.com>
 
+import copy
 import logging
 import pytest
 import sys
+import unittest.mock
 
 import pyramid.testing
 
@@ -66,6 +68,8 @@ mock_add_route = testing.instance_method_mock_fixture('add_route')
 
 mock_find_pgwui_components = testing.make_mock_fixture(
     pgwui_common.plugin, 'find_pgwui_components')
+mock_find_pgwui_check_settings = testing.make_mock_fixture(
+    pgwui_common.plugin, 'find_pgwui_check_settings')
 mock_component_to_key = testing.make_mock_fixture(
     pgwui_common.plugin, 'component_to_key')
 
@@ -77,7 +81,7 @@ mock_component_to_key = testing.make_mock_fixture(
 def test_abort_on_bad_setting_unknown():
     '''Nothing bad happens when there's a non-pgwui setting'''
     errors = []
-    pgwui_server_init.abort_on_bad_setting(errors, 'foo', [])
+    pgwui_server_init.abort_on_bad_setting(errors, {}, {}, 'foo', {})
 
     assert errors == []
 
@@ -85,7 +89,8 @@ def test_abort_on_bad_setting_unknown():
 def test_abort_on_bad_setting_bad():
     '''Delivers an error on a bad pgwui setting'''
     errors = []
-    pgwui_server_init.abort_on_bad_setting(errors, 'pgwui.foo', [])
+    pgwui_server_init.abort_on_bad_setting(
+        errors, {}, {}, 'pgwui.foo', {})
 
     assert errors
     assert isinstance(errors[0], ex.UnknownSettingKeyError)
@@ -94,20 +99,44 @@ def test_abort_on_bad_setting_bad():
 def test_abort_on_bad_setting_good():
     '''Does nothing when a known pgwui setting is supplied'''
     errors = []
-    pgwui_server_init.abort_on_bad_setting(errors, 'pgwui.pg_host', [])
+    pgwui_server_init.abort_on_bad_setting(
+        errors, {}, {}, 'pgwui.pg_host', {})
 
     assert errors == []
 
 
-def test_abort_on_bad_setting_plugin():
-    '''Does nothing when a known plugin has a setting'''
+def test_abort_on_bad_setting_plugin_no_config():
+    '''Does nothing when a known plugin has a setting and there is no
+    config for the plugin'''
     errors = []
     pgwui_server_init.abort_on_bad_setting(
-        errors, 'pgwui.pgwui_upload', ['pgwui.pgwui_upload'])
+        errors, {'pgwui.pgwui_upload': None}, {}, 'pgwui.pgwui_upload', {})
 
     assert errors == []
 
 
+def test_abort_on_bad_setting_plugin_config():
+    '''Calls the component checker with the component's config
+    and appends the result of the call to the errors.
+    '''
+    orig_errors = ['old', 'errors']
+    new_errors = ['some', 'errors']
+    component = 'test component'
+    sample_key = 'pgwui.pgwui_upload'
+    sample_config = {sample_key: 'some sample config'}
+
+    errors = copy.deepcopy(orig_errors)
+    mock_checker = unittest.mock.Mock(side_effect=lambda *args: new_errors)
+
+    pgwui_server_init.abort_on_bad_setting(
+        errors, {sample_key: component}, {component: mock_checker},
+        sample_key, sample_config)
+
+    mock_checker.assert_called_once
+    assert list(mock_checker.call_args[0]) == [sample_config[sample_key]]
+    assert errors == orig_errors + new_errors
+
+
 mock_abort_on_bad_setting = testing.make_mock_fixture(
     pgwui_server_init, 'abort_on_bad_setting')
 
@@ -374,10 +403,28 @@ mock_parse_component_settings = testing.make_mock_fixture(
     pgwui_server_init, 'parse_component_settings')
 
 
+# map_keys_to_components()
+
+def test_map_keys_to_components(mock_component_to_key):
+    '''Returns expected result
+    '''
+    components = ['a', 'b', 'c']
+    mock_component_to_key.side_effect = ['keya', 'keyb', 'keyc']
+
+    result = pgwui_server_init.map_keys_to_components(components)
+
+    assert result == {'keya': 'a', 'keyb': 'b', 'keyc': 'c'}
+
+
+mock_map_keys_to_components = testing.make_mock_fixture(
+    pgwui_server_init, 'map_keys_to_components')
+
+
 # validate_settings()
 
-def test_validate_settings(mock_component_to_key,
+def test_validate_settings(mock_map_keys_to_components,
                            mock_parse_component_settings,
+                           mock_find_pgwui_check_settings,
                            mock_abort_on_bad_setting,
                            mock_validate_setting_values,
                            mock_validate_hmac):
@@ -388,8 +435,9 @@ def test_validate_settings(mock_component_to_key,
     settings = {'key1': 'value1',
                 'key2': 'value2'}
     components = ['pgwui_server']
+    key_map = {'pgwui.server': 'anentrypoint'}
 
-    mock_component_to_key.side_effect = ['pgwui.pgwui_server']
+    mock_map_keys_to_components.side_effect = lambda *args: key_map
 
     errors = []
     pgwui_server_init.validate_settings(errors, settings, components)
@@ -398,8 +446,7 @@ def test_validate_settings(mock_component_to_key,
     assert mock_validate_hmac.called
 
     assert mock_abort_on_bad_setting.call_count == len(settings)
-    assert mock_abort_on_bad_setting.call_args[0][1] == \
-        ['pgwui.{}'.format(components[0])]
+    assert mock_abort_on_bad_setting.call_args[0][1] == key_map
 
 
 mock_validate_settings = testing.make_mock_fixture(
diff --git a/tests/test_checkset.py b/tests/test_checkset.py
new file mode 100644 (file)
index 0000000..6bf7aa6
--- /dev/null
@@ -0,0 +1,112 @@
+# Copyright (C) 2018, 2019, 2020 The Meme Factory, Inc.
+# http://www.karlpinc.com/
+
+# This file is part of PGWUI_Server.
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program 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
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see
+# <http://www.gnu.org/licenses/>.
+#
+
+# Karl O. Pinc <kop@karlpinc.com>
+
+import pgwui_server.exceptions as ex
+import pgwui_server.checkset as checkset
+
+
+# require_settings()
+
+def test_require_settings_good():
+    '''No errors when the required settings are in the config
+    '''
+    required = ['settinga', 'settingb']
+    settings = {'settinga': 'a', 'settingb': 'b'}
+
+    result = checkset.require_settings('testcomp', required, settings)
+
+    assert result == []
+
+
+def test_require_settings_bad():
+    '''Errors when the required settings are not in the config
+    '''
+    required = ['settinga', 'settingb']
+    settings = {}
+
+    result = checkset.require_settings('testcomp', required, settings)
+
+    assert len(result) == len(required)
+    for error in result:
+        assert isinstance(error, ex.MissingSettingError)
+
+
+# unknown_settings()
+
+def test_unknown_settings_good():
+    '''There are no errors when all settings are known
+    '''
+    settings = ['settinga', 'settingb']
+    conf = {'settinga': 'a', 'settingb': 'b'}
+
+    result = checkset.unknown_settings('testcomp', settings, conf)
+
+    assert result == []
+
+
+def test_unknown_settings_bad():
+    '''Errors when settings are not known
+    '''
+    conf = {'settinga': 'a', 'settingb': 'b'}
+
+    result = checkset.unknown_settings('testcomp', [], conf)
+
+    assert len(result) == len(conf)
+    for error in result:
+        assert isinstance(error, ex.UnknownSettingKeyError)
+
+
+# boolean_settings()
+
+def test_boolean_settings_good():
+    '''No errors when boolean settings are boolean
+    '''
+    conf = {'settinga': 'a', 'settingb': 'True', 'settingc': 'False'}
+    bools = ['settingc', 'settingb']
+
+    result = checkset.boolean_settings('testcomp', bools, conf)
+
+    assert result == []
+
+
+def test_boolean_settings_bad():
+    '''Errors when boolean settings are not boolean
+    '''
+    conf = {'settinga': 'a', 'settingb': 'True', 'settingc': 'c'}
+    bools = ['settinga', 'settingb']
+
+    result = checkset.boolean_settings('testcomp', bools, conf)
+
+    assert len(result) == 1
+    for error in result:
+        assert isinstance(error, ex.NotBooleanSettingError)
+
+
+def test_boolean_settings_missing():
+    '''No errors when the boolean setting is missing from the config
+    '''
+    conf = {}
+    bools = ['settinga']
+
+    result = checkset.boolean_settings('testcomp', bools, conf)
+
+    assert result == []