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
This commit is contained in:
@@ -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
|
||||
* "<lms.myapp.MyClass object at 0x704599fa2fd0>" # some random class instance
|
||||
* "<_io.TextIOWrapper name='<stderr>' 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 == "<lambda>":
|
||||
# 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)
|
||||
64
openedx/core/djangoapps/util/tests/test_dump_settings.py
Normal file
64
openedx/core/djangoapps/util/tests/test_dump_settings.py
Normal file
@@ -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 "<class 'xmodule.x_module.XModuleMixin'>" 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 "<class 'xmodule.x_module.XModuleMixin'>" 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)
|
||||
Reference in New Issue
Block a user