|
"""Manager to read and modify config data in JSON files.""" |
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
|
import copy |
|
import errno |
|
import glob |
|
import json |
|
import os |
|
import typing as t |
|
|
|
from traitlets.config import LoggingConfigurable |
|
from traitlets.traitlets import Bool, Unicode |
|
|
|
StrDict = t.Dict[str, t.Any] |
|
|
|
|
|
def recursive_update(target: StrDict, new: StrDict) -> None: |
|
"""Recursively update one dictionary using another. |
|
|
|
None values will delete their keys. |
|
""" |
|
for k, v in new.items(): |
|
if isinstance(v, dict): |
|
if k not in target: |
|
target[k] = {} |
|
recursive_update(target[k], v) |
|
if not target[k]: |
|
|
|
del target[k] |
|
|
|
elif v is None: |
|
target.pop(k, None) |
|
|
|
else: |
|
target[k] = v |
|
|
|
|
|
def remove_defaults(data: StrDict, defaults: StrDict) -> None: |
|
"""Recursively remove items from dict that are already in defaults""" |
|
|
|
for key, value in list(data.items()): |
|
if key in defaults: |
|
if isinstance(value, dict): |
|
remove_defaults(data[key], defaults[key]) |
|
if not data[key]: |
|
del data[key] |
|
elif value == defaults[key]: |
|
del data[key] |
|
|
|
|
|
class BaseJSONConfigManager(LoggingConfigurable): |
|
"""General JSON config manager |
|
|
|
Deals with persisting/storing config in a json file with optionally |
|
default values in a {section_name}.d directory. |
|
""" |
|
|
|
config_dir = Unicode(".") |
|
read_directory = Bool(True) |
|
|
|
def ensure_config_dir_exists(self) -> None: |
|
"""Will try to create the config_dir directory.""" |
|
try: |
|
os.makedirs(self.config_dir, 0o755) |
|
except OSError as e: |
|
if e.errno != errno.EEXIST: |
|
raise |
|
|
|
def file_name(self, section_name: str) -> str: |
|
"""Returns the json filename for the section_name: {config_dir}/{section_name}.json""" |
|
return os.path.join(self.config_dir, section_name + ".json") |
|
|
|
def directory(self, section_name: str) -> str: |
|
"""Returns the directory name for the section name: {config_dir}/{section_name}.d""" |
|
return os.path.join(self.config_dir, section_name + ".d") |
|
|
|
def get(self, section_name: str, include_root: bool = True) -> dict[str, t.Any]: |
|
"""Retrieve the config data for the specified section. |
|
|
|
Returns the data as a dictionary, or an empty dictionary if the file |
|
doesn't exist. |
|
|
|
When include_root is False, it will not read the root .json file, |
|
effectively returning the default values. |
|
""" |
|
paths = [self.file_name(section_name)] if include_root else [] |
|
if self.read_directory: |
|
pattern = os.path.join(self.directory(section_name), "*.json") |
|
|
|
|
|
|
|
|
|
|
|
paths = sorted(glob.glob(pattern)) + paths |
|
self.log.debug( |
|
"Paths used for configuration of %s: \n\t%s", |
|
section_name, |
|
"\n\t".join(paths), |
|
) |
|
data: dict[str, t.Any] = {} |
|
for path in paths: |
|
if os.path.isfile(path) and os.path.getsize(path): |
|
with open(path, encoding="utf-8") as f: |
|
try: |
|
recursive_update(data, json.load(f)) |
|
except json.decoder.JSONDecodeError: |
|
self.log.warning("Invalid JSON in %s, skipping", path) |
|
return data |
|
|
|
def set(self, section_name: str, data: t.Any) -> None: |
|
"""Store the given config data.""" |
|
filename = self.file_name(section_name) |
|
self.ensure_config_dir_exists() |
|
|
|
if self.read_directory: |
|
|
|
data = copy.deepcopy(data) |
|
defaults = self.get(section_name, include_root=False) |
|
remove_defaults(data, defaults) |
|
|
|
|
|
|
|
json_content = json.dumps(data, indent=2) |
|
with open(filename, "w", encoding="utf-8") as f: |
|
f.write(json_content) |
|
|
|
def update(self, section_name: str, new_data: t.Any) -> dict[str, t.Any]: |
|
"""Modify the config section by recursively updating it with new_data. |
|
|
|
Returns the modified config data as a dictionary. |
|
""" |
|
data = self.get(section_name) |
|
recursive_update(data, new_data) |
|
self.set(section_name, data) |
|
return data |
|
|