From 65a5767bb2f5b332f62869f214722620b028bbd0 Mon Sep 17 00:00:00 2001 From: "Karl O. Pinc" Date: Sat, 29 Aug 2020 16:54:02 -0500 Subject: [PATCH] Components check their own settings --- README.rst | 65 ++++++++++++++++++++ src/pgwui_server/__init__.py | 26 +++++--- src/pgwui_server/checkset.py | 60 +++++++++++++++++++ tests/test___init__.py | 67 +++++++++++++++++---- tests/test_checkset.py | 112 +++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 17 deletions(-) create mode 100644 src/pgwui_server/checkset.py create mode 100644 tests/test_checkset.py diff --git a/README.rst b/README.rst index 6a95adf..d585991 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/src/pgwui_server/__init__.py b/src/pgwui_server/__init__.py index ae802f3..0c623a6 100644 --- a/src/pgwui_server/__init__.py +++ b/src/pgwui_server/__init__.py @@ -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 index 0000000..97b2e23 --- /dev/null +++ b/src/pgwui_server/checkset.py @@ -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 +# . +# + +# Karl O. Pinc + +'''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 diff --git a/tests/test___init__.py b/tests/test___init__.py index 57535cf..185bf21 100644 --- a/tests/test___init__.py +++ b/tests/test___init__.py @@ -20,9 +20,11 @@ # Karl O. Pinc +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 index 0000000..6bf7aa6 --- /dev/null +++ b/tests/test_checkset.py @@ -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 +# . +# + +# Karl O. Pinc + +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 == [] -- 2.34.1