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..c731e8ae98f1 --- /dev/null +++ b/openedx/core/djangoapps/util/management/commands/dump_settings.py @@ -0,0 +1,105 @@ +""" +Defines the dump_settings management command. +""" +import inspect +import json +import re +import sys +from datetime import timedelta +from importlib.resources import files +from path import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.test import TestCase + + +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 strings and dotted paths to Python objects: + * some.module.some_function # <-- an actual function object + * "some.module.some_function" # <-- a string that is a dotted path to said function object + + disambiguate between lists and tuples: + * (1, 2, 3) # <-- the tuple will b printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between sets and sorted lists: + * {2, 1, 3} # <-- the set will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between internationalized and non-internationalized strings: + * _("hello") + * "hello" + """ + + 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)): + # Print both lists and tuples as JSON arrays + return [_to_json_friendly_repr(element) for element in value] + if isinstance(value, set): + # Print sets by sorting them (so that order doesn't matter) and printing the result as a JSON array + return [sorted(_to_json_friendly_repr(element) for element in value)] + 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 isinstance(value, Path): + # Print path objects as the string `Path('path/to/something')`. + return repr(value) + if isinstance(value, timedelta): + # Print timedelta objects as the string `datetime.timedelta(days=1, ...)` + return repr(value) + if proxy_args := getattr(value, "_proxy____args", None): + # Print gettext_lazy as simply the wrapped string + if len(proxy_args) == 1: + if isinstance(proxy_args[0], str): + return proxy_args[0] + raise ValueError(f"Not sure how to dump value {value!r} with proxy args {proxy_args!r}") + if value is sys.stderr: + # Print the stderr object as simply "sys.stderr" + return "sys.stderr" + try: + # For anything else, assume it's a function or a class, and try to print its dotted path. + module = value.__module__ + qualname = value.__qualname__ + except AttributeError: + # If that doesn't work, then give up--we don't know how to print this value. + raise ValueError(f"Not sure how to dump value {value!r} of type {type(value)}") + if qualname == "": + # Handle lambdas by printing the source lines + return inspect.getsource(value).strip() + return f"{module}.{qualname}" + 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..78f8238f9a90 --- /dev/null +++ b/openedx/core/djangoapps/util/tests/test_dump_settings.py @@ -0,0 +1,14 @@ +""" +Basic test for dump_settings management command +""" +import io +import json + +from django.core.management import CommandError, call_command + + +def test_with_setting_args(): + with io.StringIO() as output: + call_command('dump_settings', stdout=output) + result = json.load(output) + assert "xmodule.x_module.XModuleMixin" in result['XBLOCK_MIXINS']