From 32275662b0079f8496622bb57a4f34543cad4d1c Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Tue, 4 Feb 2025 14:57:43 -0500 Subject: [PATCH] feat!: A Better API for Derived Settings (#36192) The Python API for declaring derived settings was confusing to the uninitiated reader, and also prone to spelling mistakes. This replaces the API with one that is more readable and more concise, and updates the implementation of `derive_settings` to properly derive settings declared using the new API. BREAKING CHANGE: The `derived` and `derived_collection_entry` function are replaced with the `Derived` class. We do not expect those functions to have been used outside of edx-platform, but if they are, this commit will cause them to loudly ImportError. Note that there should be NO change in behavior to the `derive_settings` function, which we DO know to be used by some external edx-platform plugins. Part of: https://github.com/openedx/edx-platform/issues/36215 --- cms/envs/common.py | 55 +++------ lms/envs/common.py | 47 +++----- lms/envs/docs/README.rst | 6 +- lms/envs/production.py | 11 -- mypy.ini | 1 + openedx/core/lib/derived.py | 150 ++++++++++++++++--------- openedx/core/lib/tests/test_derived.py | 14 +-- 7 files changed, 135 insertions(+), 149 deletions(-) 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 c7710bbcb2b8..3339b775de1d 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' @@ -4658,13 +4656,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. @@ -4952,14 +4949,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. @@ -5447,11 +5443,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': @@ -5513,33 +5509,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..745d6f1262f8 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 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__)