Skip to content

Commit

Permalink
feat: dump_settings management command
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.

Part of: (TODO ADD LINK)
  • Loading branch information
kdmccormick committed Jan 24, 2025
1 parent 2a07080 commit c1fb2ea
Show file tree
Hide file tree
Showing 2 changed files with 168 additions and 0 deletions.
104 changes: 104 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,104 @@
"""
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, which will be printed as...
* "some.module.some_function" # <-- a string that is a dotted path to said function object
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"
"""

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 == "<lambda>":
# Handle lambdas by printing the source lines
return inspect.getsource(value).strip()
return f"{module}.{qualname}"
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: python references are converted to a dotted path
assert "xmodule.x_module.XModuleMixin" in dump['XBLOCK_MIXINS']

# Check: nested dictionaries come through OK
assert dump['CODE_JAIL']['limit_overrides'] == {}


@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: python references are converted to a dotted path
assert "xmodule.x_module.XModuleMixin" in dump['XBLOCK_MIXINS']

# Check: nested dictionaries come through OK
assert dump['CODE_JAIL']['limit_overrides'] == {}


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 c1fb2ea

Please sign in to comment.