From dc2a38b1f4f4edae08e7a7f2bebfbe6a06f06c8d Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 27 Jan 2025 15:29:29 -0500 Subject: [PATCH] feat: dump_settings management command (#36162) This command dumps the current Django settings to JSON for debugging/diagnostics. The output of this command is for *humans*... it is NOT suitable for consumption by production systems. In particular, we are introducing this command as part of a series of refactorings to the Django settings files lms/envs/* and cms/envs/*. We want to ensure that these refactorings do not introduce any unexpected breaking changes, so the dump_settings command will both help us manually verify our refactorings and help operators verify that our refactorings behave expectedly when using their custom python/yaml settings files. Related to: https://github.com/openedx/edx-platform/pull/36131 --- .../util/management/commands/dump_settings.py | 93 +++++++++++++++++++ .../util/tests/test_dump_settings.py | 64 +++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 openedx/core/djangoapps/util/management/commands/dump_settings.py create mode 100644 openedx/core/djangoapps/util/tests/test_dump_settings.py diff --git a/openedx/core/djangoapps/util/management/commands/dump_settings.py b/openedx/core/djangoapps/util/management/commands/dump_settings.py new file mode 100644 index 000000000000..1f9949ffbdaf --- /dev/null +++ b/openedx/core/djangoapps/util/management/commands/dump_settings.py @@ -0,0 +1,93 @@ +""" +Defines the dump_settings management command. +""" +import inspect +import json +import re + +from django.conf import settings +from django.core.management.base import BaseCommand + + +SETTING_NAME_REGEX = re.compile(r'^[A-Z][A-Z0-9_]*$') + + +class Command(BaseCommand): + """ + Dump current Django settings to JSON for debugging/diagnostics. + + BEWARE: OUTPUT IS NOT SUITABLE FOR CONSUMPTION BY PRODUCTION SYSTEMS. + The purpose of this output is to be *helpful* for a *human* operator to understand how their settings are being + rendered and how they differ between different settings files. The serialization format is NOT perfect: there are + certain situations where two different settings will output identical JSON. For example, this command does NOT: + + disambiguate between lists and tuples: + * (1, 2, 3) # <-- this tuple will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between sets and sorted lists: + * {2, 1, 3} # <-- this set will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between internationalized and non-internationalized strings: + * _("hello") # <-- this will become just "hello" + * "hello" + + Furthermore, objects which are not easily JSON-ifiable will stringified using their `repr(...)`, e.g.: + * "Path('my/path')" # a Path object + * "" # some random class instance + * "<_io.TextIOWrapper name='' mode='w' encoding='utf-8'>" # sys.stderr + + and lambdas are printed by *roughly* printing out their source lines (it's impossible in Python to get the *exact* + source code, as it's been compiled into bytecode). + """ + + def handle(self, *args, **kwargs): + """ + Handle the command. + """ + settings_json = { + name: _to_json_friendly_repr(getattr(settings, name)) + for name in dir(settings) + if SETTING_NAME_REGEX.match(name) + } + print(json.dumps(settings_json, indent=4)) + + +def _to_json_friendly_repr(value: object) -> object: + """ + Turn the value into something that we can print to a JSON file (that is: str, bool, None, int, float, list, dict). + + See the docstring of `Command` for warnings about this function's behavior. + """ + if isinstance(value, (type(None), bool, int, float, str)): + # All these types can be printed directly + return value + if isinstance(value, (list, tuple, set)): + if isinstance(value, set): + # Print sets by sorting them (so that order doesn't matter) into a JSON array. + elements = sorted(value) + else: + # Print both lists and tuples as JSON arrays. + elements = value + return [_to_json_friendly_repr(element) for ix, element in enumerate(elements)] + if isinstance(value, dict): + # Print dicts as JSON objects + for subkey in value.keys(): + if not isinstance(subkey, (str, int)): + raise ValueError(f"Unexpected dict key {subkey} of type {type(subkey)}") + return {subkey: _to_json_friendly_repr(subval) for subkey, subval in value.items()} + if proxy_args := getattr(value, "_proxy____args", None): + if len(proxy_args) == 1 and isinstance(proxy_args[0], str): + # Print gettext_lazy as simply the wrapped string + return proxy_args[0] + try: + qualname = value.__qualname__ + except AttributeError: + pass + else: + if qualname == "": + # Handle lambdas by printing the source lines + return "lambda defined with line(s): " + inspect.getsource(value).strip() + # For all other objects, print the repr + return repr(value) diff --git a/openedx/core/djangoapps/util/tests/test_dump_settings.py b/openedx/core/djangoapps/util/tests/test_dump_settings.py new file mode 100644 index 000000000000..b8712e9aed1c --- /dev/null +++ b/openedx/core/djangoapps/util/tests/test_dump_settings.py @@ -0,0 +1,64 @@ +""" +Basic tests for dump_settings management command. + +These are moreso testing that dump_settings works, less-so testing anything about the Django +settings files themselves. Remember that tests only run with (lms,cms)/envs/test.py, +which are based on (lms,cms)/envs/common.py, so these tests will not execute any of the +YAML-loading or post-processing defined in (lms,cms)/envs/production.py. +""" +import json + +from django.core.management import call_command + +from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms + + +@skip_unless_lms +def test_for_lms_settings(capsys): + """ + Ensure LMS's test settings can be dumped, and sanity-check them for certain values. + """ + dump = _get_settings_dump(capsys) + + # Check: something LMS-specific + assert dump['MODULESTORE_BRANCH'] == "published-only" + + # Check: tuples are converted to lists + assert isinstance(dump['XBLOCK_MIXINS'], list) + + # Check: objects (like classes) are repr'd + assert "" in dump['XBLOCK_MIXINS'] + + # Check: nested dictionaries come through OK, and int'l strings are just strings + assert dump['COURSE_ENROLLMENT_MODES']['audit']['display_name'] == "Audit" + + +@skip_unless_cms +def test_for_cms_settings(capsys): + """ + Ensure CMS's test settings can be dumped, and sanity-check them for certain values. + """ + dump = _get_settings_dump(capsys) + + # Check: something CMS-specific + assert dump['MODULESTORE_BRANCH'] == "draft-preferred" + + # Check: tuples are converted to lists + assert isinstance(dump['XBLOCK_MIXINS'], list) + + # Check: objects (like classes) are repr'd + assert "" in dump['XBLOCK_MIXINS'] + + # Check: nested dictionaries come through OK, and int'l strings are just strings + assert dump['COURSE_ENROLLMENT_MODES']['audit']['display_name'] == "Audit" + + +def _get_settings_dump(captured_sys): + """ + Call dump_settings, ensure no error output, and return parsed JSON. + """ + call_command('dump_settings') + out, err = captured_sys.readouterr() + assert out + assert not err + return json.loads(out)