Skip to content

Commit

Permalink
feat: dump_settings management command (#36162)
Browse files Browse the repository at this point in the history
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: #36131
  • Loading branch information
kdmccormick authored Jan 27, 2025
1 parent e7771d6 commit dc2a38b
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 0 deletions.
93 changes: 93 additions & 0 deletions openedx/core/djangoapps/util/management/commands/dump_settings.py
Original file line number Diff line number Diff line change
@@ -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 changes: 64 additions & 0 deletions openedx/core/djangoapps/util/tests/test_dump_settings.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit dc2a38b

Please sign in to comment.