diff --git a/cms/envs/common.py b/cms/envs/common.py index 7a36bfab3d96..a39e06565166 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -144,7 +144,7 @@ get_theme_base_dirs_from_settings ) from openedx.core.lib.license import LicenseMixin -from openedx.core.lib.derived import derived, derived_collection_entry +from openedx.core.lib.derived import Derived from openedx.core.release import doc_version # pylint: enable=useless-suppression @@ -740,7 +740,7 @@ # Don't look for template source files inside installed applications. 'APP_DIRS': False, # Instead, look for template source files in these dirs. - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(_make_mako_template_dirs), # Options specific to this backend. 'OPTIONS': { 'loaders': ( @@ -759,7 +759,7 @@ 'NAME': 'mako', 'BACKEND': 'common.djangoapps.edxmako.backend.Mako', 'APP_DIRS': False, - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(_make_mako_template_dirs), 'OPTIONS': { 'context_processors': CONTEXT_PROCESSORS, 'debug': False, @@ -778,8 +778,6 @@ } }, ] -derived_collection_entry('TEMPLATES', 0, 'DIRS') -derived_collection_entry('TEMPLATES', 1, 'DIRS') DEFAULT_TEMPLATE_ENGINE = TEMPLATES[0] #################################### AWS ####################################### @@ -825,8 +823,7 @@ # Warning: Must have trailing slash to activate correct logout view # (auth_backends, not LMS user_authn) FRONTEND_LOGOUT_URL = '/logout/' -FRONTEND_REGISTER_URL = lambda settings: settings.LMS_ROOT_URL + '/register' -derived('FRONTEND_REGISTER_URL') +FRONTEND_REGISTER_URL = Derived(lambda settings: settings.LMS_ROOT_URL + '/register') LMS_ENROLLMENT_API_PATH = "/api/enrollment/v1/" ENTERPRISE_API_URL = LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' @@ -1316,8 +1313,7 @@ STATICI18N_FILENAME_FUNCTION = 'statici18n.utils.legacy_filename' STATICI18N_ROOT = PROJECT_ROOT / "static" -LOCALE_PATHS = _make_locale_paths -derived('LOCALE_PATHS') +LOCALE_PATHS = Derived(_make_locale_paths) # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -2087,10 +2083,9 @@ # See annotations in lms/envs/common.py for details. RETIRED_EMAIL_DOMAIN = 'retired.invalid' # See annotations in lms/envs/common.py for details. -RETIRED_USERNAME_FMT = lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}' +RETIRED_USERNAME_FMT = Derived(lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}') # See annotations in lms/envs/common.py for details. -RETIRED_EMAIL_FMT = lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN -derived('RETIRED_USERNAME_FMT', 'RETIRED_EMAIL_FMT') +RETIRED_EMAIL_FMT = Derived(lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN) # See annotations in lms/envs/common.py for details. RETIRED_USER_SALTS = ['abc', '123'] # See annotations in lms/envs/common.py for details. @@ -2367,13 +2362,12 @@ ############## Settings for Studio Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "cms" / "envs" / "help_tokens.ini" -HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE -HELP_TOKENS_VERSION = lambda settings: doc_version() +HELP_TOKENS_LANGUAGE_CODE = Derived(lambda settings: settings.LANGUAGE_CODE) +HELP_TOKENS_VERSION = Derived(lambda settings: doc_version()) HELP_TOKENS_BOOKS = { 'learner': 'https://edx.readthedocs.io/projects/open-edx-learner-guide', 'course_author': 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course', } -derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') # Used with Email sending RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5 @@ -2876,15 +2870,15 @@ def _should_send_learning_badge_events(settings): }, 'org.openedx.content_authoring.xblock.published.v1': { 'course-authoring-xblock-lifecycle': - {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + {'event_key_field': 'xblock_info.usage_key', 'enabled': Derived(_should_send_xblock_events)}, }, 'org.openedx.content_authoring.xblock.deleted.v1': { 'course-authoring-xblock-lifecycle': - {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + {'event_key_field': 'xblock_info.usage_key', 'enabled': Derived(_should_send_xblock_events)}, }, 'org.openedx.content_authoring.xblock.duplicated.v1': { 'course-authoring-xblock-lifecycle': - {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + {'event_key_field': 'xblock_info.usage_key', 'enabled': Derived(_should_send_xblock_events)}, }, # LMS events. These have to be copied over here because lms.common adds some derived entries as well, # and the derivation fails if the keys are missing. If we ever remove the import of lms.common, we can remove these. @@ -2899,38 +2893,17 @@ def _should_send_learning_badge_events(settings): "org.openedx.learning.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, "org.openedx.learning.ccx.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.ccx_course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, } - -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.published.v1', - 'course-authoring-xblock-lifecycle', 'enabled') -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.duplicated.v1', - 'course-authoring-xblock-lifecycle', 'enabled') -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.deleted.v1', - 'course-authoring-xblock-lifecycle', 'enabled') - -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.ccx.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) - ################### Authoring API ###################### # This affects the Authoring API swagger docs but not the legacy swagger docs under /api-docs/. diff --git a/lms/envs/common.py b/lms/envs/common.py index d08d0ea0f154..4c65cfabe4e4 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -69,7 +69,7 @@ get_themes_unchecked, get_theme_base_dirs_from_settings ) -from openedx.core.lib.derived import derived, derived_collection_entry +from openedx.core.lib.derived import Derived from openedx.core.release import doc_version from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin @@ -1395,7 +1395,7 @@ def _make_mako_template_dirs(settings): # Don't look for template source files inside installed applications. 'APP_DIRS': False, # Instead, look for template source files in these dirs. - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(_make_mako_template_dirs), # Options specific to this backend. 'OPTIONS': { 'context_processors': CONTEXT_PROCESSORS, @@ -1404,7 +1404,6 @@ def _make_mako_template_dirs(settings): } }, ] -derived_collection_entry('TEMPLATES', 1, 'DIRS') DEFAULT_TEMPLATE_ENGINE = TEMPLATES[0] DEFAULT_TEMPLATE_ENGINE_DIRS = DEFAULT_TEMPLATE_ENGINE['DIRS'][:] @@ -1734,7 +1733,7 @@ def _make_mako_template_dirs(settings): 'DOC_STORE_CONFIG': DOC_STORE_CONFIG, 'OPTIONS': { 'default_class': 'xmodule.hidden_block.HiddenBlock', - 'fs_root': lambda settings: settings.DATA_DIR, + 'fs_root': Derived(lambda settings: settings.DATA_DIR), 'render_template': 'common.djangoapps.edxmako.shortcuts.render_to_string', } }, @@ -1744,7 +1743,7 @@ def _make_mako_template_dirs(settings): 'DOC_STORE_CONFIG': DOC_STORE_CONFIG, 'OPTIONS': { 'default_class': 'xmodule.hidden_block.HiddenBlock', - 'fs_root': lambda settings: settings.DATA_DIR, + 'fs_root': Derived(lambda settings: settings.DATA_DIR), 'render_template': 'common.djangoapps.edxmako.shortcuts.render_to_string', } } @@ -2054,8 +2053,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS: locale_paths += (path(locale_path), ) return locale_paths -LOCALE_PATHS = _make_locale_paths -derived('LOCALE_PATHS') +LOCALE_PATHS = Derived(_make_locale_paths) # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -4661,13 +4659,12 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring ############## Settings for LMS Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "lms" / "envs" / "help_tokens.ini" -HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE -HELP_TOKENS_VERSION = lambda settings: doc_version() +HELP_TOKENS_LANGUAGE_CODE = Derived(lambda settings: settings.LANGUAGE_CODE) +HELP_TOKENS_VERSION = Derived(lambda settings: doc_version()) HELP_TOKENS_BOOKS = { 'learner': 'https://edx.readthedocs.io/projects/open-edx-learner-guide', 'course_author': 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course', } -derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') ############## OPEN EDX ENTERPRISE SERVICE CONFIGURATION ###################### # The Open edX Enterprise service is currently hosted via the LMS container/process. @@ -4955,14 +4952,13 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # .. setting_description: Set the format a retired user username field gets transformed into, where {} # is replaced with the hash of the original username. This is a derived setting that depends on # RETIRED_USERNAME_PREFIX value. -RETIRED_USERNAME_FMT = lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}' +RETIRED_USERNAME_FMT = Derived(lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}') # .. setting_name: RETIRED_EMAIL_FMT # .. setting_default: retired__user_{}@retired.invalid # .. setting_description: Set the format a retired user email field gets transformed into, where {} is # replaced with the hash of the original email. This is a derived setting that depends on # RETIRED_EMAIL_PREFIX and RETIRED_EMAIL_DOMAIN values. -RETIRED_EMAIL_FMT = lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN -derived('RETIRED_USERNAME_FMT', 'RETIRED_EMAIL_FMT') +RETIRED_EMAIL_FMT = Derived(lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN) # .. setting_name: RETIRED_USER_SALTS # .. setting_default: ['abc', '123'] # .. setting_description: Set a list of salts used for hashing usernames and emails on users retirement. @@ -5450,11 +5446,11 @@ def _should_send_learning_badge_events(settings): EVENT_BUS_PRODUCER_CONFIG = { 'org.openedx.learning.certificate.created.v1': { 'learning-certificate-lifecycle': - {'event_key_field': 'certificate.course.course_key', 'enabled': _should_send_certificate_events}, + {'event_key_field': 'certificate.course.course_key', 'enabled': Derived(_should_send_certificate_events)}, }, 'org.openedx.learning.certificate.revoked.v1': { 'learning-certificate-lifecycle': - {'event_key_field': 'certificate.course.course_key', 'enabled': _should_send_certificate_events}, + {'event_key_field': 'certificate.course.course_key', 'enabled': Derived(_should_send_certificate_events)}, }, 'org.openedx.learning.course.unenrollment.completed.v1': { 'course-unenrollment-lifecycle': @@ -5516,33 +5512,16 @@ def _should_send_learning_badge_events(settings): "org.openedx.learning.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, "org.openedx.learning.ccx.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.ccx_course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, } -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.created.v1', - 'learning-certificate-lifecycle', 'enabled') -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.revoked.v1', - 'learning-certificate-lifecycle', 'enabled') - -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.ccx.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) BEAMER_PRODUCT_ID = "" diff --git a/lms/envs/docs/README.rst b/lms/envs/docs/README.rst index 34211a57517d..5a5c78bb734d 100644 --- a/lms/envs/docs/README.rst +++ b/lms/envs/docs/README.rst @@ -56,8 +56,7 @@ For example: for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS: locale_paths += (path(locale_path), ) return locale_paths - LOCALE_PATHS = _make_locale_paths - derived('LOCALE_PATHS') + LOCALE_PATHS = Derived(_make_locale_paths) In this case, ``LOCALE_PATHS`` will be defined correctly at the end of the settings module parsing no matter what ``REPO_ROOT``, @@ -92,7 +91,6 @@ when nested within each other: 'NAME': 'mako', 'BACKEND': 'common.djangoapps.edxmako.backend.Mako', 'APP_DIRS': False, - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(_make_mako_template_dirs), }, ] - derived_collection_entry('TEMPLATES', 1, 'DIRS') diff --git a/lms/envs/production.py b/lms/envs/production.py index ed705ad14bc7..b36245e76540 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -349,17 +349,6 @@ def get_env_setting(setting): # use the one from common.py MODULESTORE = convert_module_store_setting_if_needed(_YAML_TOKENS.get('MODULESTORE', MODULESTORE)) -# After conversion above, the modulestore will have a "stores" list with all defined stores, for all stores, add the -# fs_root entry to derived collection so that if it's a callable it can be resolved. We need to do this because the -# `derived_collection_entry` takes an exact index value but the config file might have overridden the number of stores -# and so we can't be sure that the 2 we define in common.py will be there when we try to derive settings. This could -# lead to exceptions being thrown when the `derive_settings` call later in this file tries to update settings. We call -# the derived_collection_entry function here to ensure that we update the fs_root for any callables that remain after -# we've updated the MODULESTORE setting from our config file. -for idx, store in enumerate(MODULESTORE['default']['OPTIONS']['stores']): - if 'OPTIONS' in store and 'fs_root' in store["OPTIONS"]: - derived_collection_entry('MODULESTORE', 'default', 'OPTIONS', 'stores', idx, 'OPTIONS', 'fs_root') - BROKER_URL = "{}://{}:{}@{}/{}".format(CELERY_BROKER_TRANSPORT, CELERY_BROKER_USER, CELERY_BROKER_PASSWORD, diff --git a/mypy.ini b/mypy.ini index c0d739e8468b..c6cac098c2b8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,6 +12,7 @@ files = openedx/core/djangoapps/content_staging, openedx/core/djangoapps/content_libraries, openedx/core/djangoapps/xblock, + openedx/core/lib/derived.py, openedx/core/types, openedx/core/djangoapps/content_tagging, xmodule/util/keys.py, diff --git a/openedx/core/lib/derived.py b/openedx/core/lib/derived.py index a62731ef5432..b1e805476600 100644 --- a/openedx/core/lib/derived.py +++ b/openedx/core/lib/derived.py @@ -4,71 +4,121 @@ other settings have been set. The derived setting can also be overridden by setting the derived setting to an actual value. """ +from __future__ import annotations +import re import sys +import types +import typing as t -# Global list holding all settings which will be derived. -__DERIVED = [] +Settings: t.TypeAlias = types.ModuleType -def derived(*settings): + +T = t.TypeVar('T') + + +class Derived(t.Generic[T]): """ - Registers settings which are derived from other settings. - Can be called multiple times to add more derived settings. + A temporary Django setting value, defined with a function which generates the setting's eventual value. - Args: - settings (str): Setting names to register. + Said function (`calculate_value`) should accept a Django settings module, and return a calculated value. + + To ensure that application code does not encounter an instance of this class in your settings, be sure to call + `derive_settings` somewhere in your terminal settings file. """ - __DERIVED.extend(settings) + def __init__(self, calculate_value: t.Callable[[Settings], T]): + self.calculate_value = calculate_value -def derived_collection_entry(collection_name, *accessors): +def derive_settings(module_name: str) -> None: """ - Registers a setting which is a dictionary or list and needs a derived value for a particular entry. - Can be called multiple times to add more derived settings. + In the Django settings module at `module_name`, replace `Derived` values with their cacluated values. - Args: - collection_name (str): Name of setting which contains a dictionary or list. - accessors (int|str): Sequence of dictionary keys and list indices in the collection (and - collections within it) leading to the value which will be derived. - For example: 0, 'DIRS'. + The replacement happens recursively for any values or containers defined by a Django setting name (which is: an + uppercase top-level variable name which is not prefixed by an underscore). Within containers, """ - __DERIVED.append((collection_name, accessors)) + module = sys.modules[module_name] + _derive_dict(module, vars(module), key_filter=_key_is_a_setting_name) + + +_SETTING_NAME_REGEX = re.compile(r'^[A-Z][A-Z0-9_]*$') + + +def _key_is_a_setting_name(key: str) -> bool: + return bool(_SETTING_NAME_REGEX.match(key)) -def derive_settings(module_name): +def _match_every_key(_key: str) -> bool: + return True + + +def _derive_recursively(settings: Settings, value: t.Any) -> t.Any: """ - Derives all registered settings and sets them onto a particular module. - Skips deriving settings that are set to a value. + Recursively evaluate `Derived` objects` in `value` and any child containers. Return the evaluated version of `value`. - Args: - module_name (str): Name of module to which the derived settings will be added. + * If `value` is a `Derived` object, then use `settings` to calculate and return its value. + * If `value` is a mutable container, then recursively evaluate it in-place. + * If `value` is an immutable container, then recursively evalute a shallow copy of it. + Keep in mind that immutable containers (particularly: tuples) can contain mutable containers. In such a case, the + original and shallow-copied mutable containers will both reference the same child mutable container object. """ - module = sys.modules[module_name] - for derived in __DERIVED: # lint-amnesty, pylint: disable=redefined-outer-name - if isinstance(derived, str): - setting = getattr(module, derived) - if callable(setting): - setting_val = setting(module) - setattr(module, derived, setting_val) - elif isinstance(derived, tuple): - # If a tuple, two elements are expected - else ignore. - if len(derived) == 2: - # The first element is the name of the attribute which is expected to be a dictionary or list. - # The second element is a list of string keys in that dictionary leading to a derived setting. - collection = getattr(module, derived[0]) - accessors = derived[1] - for accessor in accessors[:-1]: - collection = collection[accessor] - setting = collection[accessors[-1]] - if callable(setting): - setting_val = setting(module) - collection[accessors[-1]] = setting_val - - -def clear_for_tests(): - """ - Clears all settings to be derived. For tests only. - """ - global __DERIVED - __DERIVED = [] + if isinstance(value, Derived): + return value.calculate_value(settings) + elif isinstance(value, dict): + return _derive_dict(settings, value) + elif isinstance(value, list): + return _derive_list(settings, value) + elif isinstance(value, tuple): + return _derive_tuple(settings, value) + elif isinstance(value, frozenset): + return _derive_frozenset(settings, value) + else: + return value + + +def _derive_dict(settings: Settings, the_dict: dict, key_filter: t.Callable[[str], bool] = _match_every_key) -> dict: + """ + Recursively evaluate `Derived` objects in `the_dict` and any child containers. Modifies `the_dict` in place. + + Optionally takes a `key_filter`. Items that do not match the provided `key_filter` will be left alone. + """ + for key, value in the_dict.items(): + if key_filter(key): + the_dict[key] = _derive_recursively(settings, value) + return the_dict + + +def _derive_list(settings: Settings, the_list: list) -> list: + """ + Recursively evaluate `Derived` objects in `the_list` and any child containers. Modifies `the_list` in place. + """ + for ix in range(len(the_list)): + the_list[ix] = _derive_recursively(settings, the_list[ix]) + return the_list + + +def _derive_tuple(settings: Settings, tup: tuple) -> tuple: + """ + Recursively evaluate `Derived` objects in `tup` and any child containers. Returns a shallow copy of `tup`. + """ + return tuple(_derive_recursively(settings, item) for item in tup) + + +def _derive_set(settings: Settings, the_set: set) -> set: + """ + Recursively evaluate `Derived` objects in `the_set` and any child containers. Modifies `the_set` in-place. + """ + for original in the_set: + derived = _derive_recursively(settings, original) + if derived != original: + the_set.remove(original) + the_set.add(derived) + return the_set + + +def _derive_frozenset(settings: Settings, the_set: frozenset) -> frozenset: + """ + Recursively evaluate `Derived` objects in `the_set` and any child containers. Returns a shallow copy of `the_set`. + """ + return frozenset(_derive_recursively(settings, item) for item in the_set) diff --git a/openedx/core/lib/tests/test_derived.py b/openedx/core/lib/tests/test_derived.py index ef3f98042432..7d3f70fa6ab1 100644 --- a/openedx/core/lib/tests/test_derived.py +++ b/openedx/core/lib/tests/test_derived.py @@ -5,7 +5,7 @@ import sys from unittest import TestCase -from openedx.core.lib.derived import derived, derived_collection_entry, derive_settings, clear_for_tests +from openedx.core.lib.derived import Derived, derive_settings class TestDerivedSettings(TestCase): @@ -14,18 +14,14 @@ class TestDerivedSettings(TestCase): """ def setUp(self): super().setUp() - clear_for_tests() self.module = sys.modules[__name__] self.module.SIMPLE_VALUE = 'paneer' - self.module.DERIVED_VALUE = lambda settings: 'mutter ' + settings.SIMPLE_VALUE - self.module.ANOTHER_DERIVED_VALUE = lambda settings: settings.DERIVED_VALUE + ' with naan' + self.module.DERIVED_VALUE = Derived(lambda settings: 'mutter ' + settings.SIMPLE_VALUE) + self.module.ANOTHER_DERIVED_VALUE = Derived(lambda settings: settings.DERIVED_VALUE + ' with naan') self.module.UNREGISTERED_DERIVED_VALUE = lambda settings: settings.SIMPLE_VALUE + ' is cheese' - derived('DERIVED_VALUE', 'ANOTHER_DERIVED_VALUE') self.module.DICT_VALUE = {} - self.module.DICT_VALUE['test_key'] = lambda settings: settings.DERIVED_VALUE * 3 - derived_collection_entry('DICT_VALUE', 'test_key') - self.module.DICT_VALUE['list_key'] = ['not derived', lambda settings: settings.DERIVED_VALUE] - derived_collection_entry('DICT_VALUE', 'list_key', 1) + self.module.DICT_VALUE['test_key'] = Derived(lambda settings: settings.DERIVED_VALUE * 3) + self.module.DICT_VALUE['list_key'] = ['not derived', Derived(lambda settings: settings.DERIVED_VALUE)] def test_derived_settings_are_derived(self): derive_settings(__name__)