-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: dump_settings management command
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
1 parent
2a07080
commit c1fb2ea
Showing
2 changed files
with
168 additions
and
0 deletions.
There are no files selected for viewing
104 changes: 104 additions & 0 deletions
104
openedx/core/djangoapps/util/management/commands/dump_settings.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |