Skip to content
This repository has been archived by the owner on Sep 8, 2024. It is now read-only.

Prioritise remote user config over device #2861

Closed
wants to merge 9 commits into from
60 changes: 53 additions & 7 deletions mycroft/configuration/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,19 @@
from os.path import exists, isfile
from requests import RequestException

from mycroft.util.json_helper import load_commented_json, merge_dict
from mycroft.util.json_helper import (
delete_key_from_dict,
load_commented_json,
merge_dict
)
from mycroft.util.log import LOG

from .locations import (DEFAULT_CONFIG, SYSTEM_CONFIG, USER_CONFIG,
WEB_CONFIG_CACHE)
from .locations import (
DEFAULT_CONFIG,
SYSTEM_CONFIG,
USER_CONFIG,
WEB_CONFIG_CACHE
)


def is_remote_list(values):
Expand All @@ -38,6 +46,20 @@ def is_remote_list(values):
return True


def prune_config(config, prune_list):
"""Delete list of nested keys from the provided config.

Arguments:
config (dict): config to prune
prune_list (list(str)): list of keys to delete. Each item may be a
period separated list of nested keys eg
["nested.dict.key", "listener.sample_rate"]
"""
for key in prune_list:
config = delete_key_from_dict(key, config)
return config


def translate_remote(config, setting):
"""Translate config names from server to equivalents for mycroft-core.

Expand Down Expand Up @@ -83,6 +105,7 @@ def translate_list(config, values):

class LocalConf(dict):
"""Config dictionary from file."""

def __init__(self, path):
super(LocalConf, self).__init__()
if path:
Expand Down Expand Up @@ -124,6 +147,7 @@ def merge(self, conf):

class RemoteConf(LocalConf):
"""Config dictionary fetched from mycroft.ai."""

def __init__(self, cache=None):
super(RemoteConf, self).__init__(None)

Expand Down Expand Up @@ -203,20 +227,42 @@ def load_config_stack(configs=None, cache=False):
Returns:
(dict) merged dict of all configuration files
"""
# system administrators can define different constraints in how
# configurations are loaded
if not configs:
configs = [LocalConf(DEFAULT_CONFIG), RemoteConf(),
LocalConf(SYSTEM_CONFIG), LocalConf(USER_CONFIG),
configs = [LocalConf(DEFAULT_CONFIG), LocalConf(SYSTEM_CONFIG),
RemoteConf(), LocalConf(USER_CONFIG),
Configuration.__patch]
else:
# Handle strings in stack
for index, item in enumerate(configs):
if isinstance(item, str):
configs[index] = LocalConf(item)

# Build maintainers and system administrators may prevent users from
# modifying specific keys within the configuration.
system_config = LocalConf(SYSTEM_CONFIG) or {}
remote_config_disabled = system_config.get(
'disable_remote_config', False)
user_config_disabled = system_config.get(
'disable_user_config', False)
protected_keys = system_config.get('protected_keys', [])

# Merge all configs into one
base = {}
for c in configs:
merge_dict(base, c)
for config in configs:

# handle system constraints
if isinstance(config, RemoteConf):
if remote_config_disabled:
continue
prune_config(config, protected_keys)
elif isinstance(config, LocalConf) and config.path == USER_CONFIG:
if user_config_disabled:
JarbasAl marked this conversation as resolved.
Show resolved Hide resolved
continue
prune_config(config, protected_keys)

merge_dict(base, config)

# copy into cache
if cache:
Expand Down
23 changes: 17 additions & 6 deletions mycroft/configuration/mycroft.conf
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@
// Definition and documentation of all variables used by mycroft-core.
//
// Settings seen here are considered DEFAULT. Settings can also be
// overridden at the REMOTE level (set by the user via
// https://home.mycroft.ai), at the SYSTEM level (typically in the file
// '/etc/mycroft/mycroft.conf'), or at the USER level (typically in the
// overridden at the SYSTEM level (typically in the file
// '/etc/mycroft/mycroft.conf'), at the REMOTE level (set by the user via
// https://home.mycroft.ai), or at the USER level (typically in the
// file '~/.mycroft/mycroft.conf').
//
// The load order of settings is:
// DEFAULT
// REMOTE
// SYSTEM
// REMOTE
// USER
//
// The Override: comments below indicates where these settings are generally
// set outside of this file. The load order is always followed, so an
// individual systems can still apply changes at the SYSTEM or USER levels.
// set outside of this file. The load order is always followed, so a
// specific system can set relevant attributes at the SYSTEM level, and users
// can further customize their experience at the REMOTE or USER levels.

// REMOTE or USER configurations can be disabled thereby requiring root
// access to the system to change any config parameters.
"disable_remote_config": false,
"disable_user_config": false,

// Alternatively a set of protected keys can be defined. This prevents
// either REMOTE or USER from modifying these settings. Nested keys should
// be provided as period separated strings eg ["listener.sample_rate"]
"protected_keys": [],

// Language used for speech-to-text and text-to-speech.
// Code is a BCP-47 identifier (https://tools.ietf.org/html/bcp47), lowercased
Expand Down
23 changes: 23 additions & 0 deletions mycroft/util/json_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.
#
import json
from copy import copy


def merge_dict(base, delta):
Expand All @@ -32,6 +33,28 @@ def merge_dict(base, delta):
base[k] = dv


def delete_key_from_dict(key, dictionary):
"""Recursivily find nested key in a dict and delete it.

Arguments:
key (str): a period separated list of nested keys to remove
eg "nested.dict.keys"
dictionary (dict): the dictionary to remove keys from
Returns:
Dict: original dictionary with specified keys deleted.
"""
modified_dict = copy(dictionary)
key_list = key.split('.')
Copy link
Contributor

@JarbasAl JarbasAl Apr 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the separator be made into an optional argument ?

it's possible dict keys will contain the default one, so this should be configurable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true, but then we need a way to configure that delimiter so we'd be adding another mycroft.conf attribute.

Should we just use the right data structure to begin with and make it a list of lists of strings...

[['nested', 'key', 'example'], ['second', 'key']]

This seems really painful to write out if you want to actually use it but who knows how often it will actually get used and only has to be done once

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if len(key_list) == 1 and modified_dict.get(key) is not None:
del modified_dict[key]
elif len(key_list) > 1:
remaining_keys = '.'.join(key_list[1:])
if modified_dict.get(key_list[0]) is not None:
modified_dict[key_list[0]] = delete_key_from_dict(
remaining_keys, modified_dict[key_list[0]])
return modified_dict


def load_commented_json(filename):
""" Loads an JSON file, ignoring comments

Expand Down
16 changes: 13 additions & 3 deletions test/unittests/configuration/test_configuration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from unittest.mock import MagicMock, patch
from unittest import TestCase
import mycroft.configuration
from mycroft.configuration.locations import SYSTEM_CONFIG, USER_CONFIG
from mycroft.util.json_helper import merge_dict


class TestConfiguration(TestCase):
Expand Down Expand Up @@ -64,10 +66,18 @@ def test_local(self, mock_json_loader, mock_isfile, mock_exists,
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, {})

@patch('mycroft.configuration.config.RemoteConf')
@patch('mycroft.configuration.config.LocalConf')
def test_prune_config(self):
prune_config = mycroft.configuration.config.prune_config
config = {'a': 1, 'b': {'c': 1, 'd': 2}, 'e': 3}
self.assertEqual(prune_config(config, ['b']), {'a': 1, 'e': 3})
self.assertEqual(prune_config(config, ['b.c']), {
'a': 1, 'b': {'d': 2}, 'e': 3})
self.assertEqual(prune_config(config, ['b', 'e']), {'a': 1})
self.assertEqual(prune_config(config, ['b', 'b.c']), {'a': 1, 'e': 3})

@patch('mycroft.configuration.config.RemoteConf.__new__')
@patch('mycroft.configuration.config.LocalConf.__new__')
def test_update(self, mock_remote, mock_local):
mock_remote.return_value = {}
mock_local.return_value = {'a': 1}
c = mycroft.configuration.Configuration.get()
self.assertEqual(c, {'a': 1})
Expand Down
13 changes: 12 additions & 1 deletion test/unittests/util/test_json_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@

from os.path import dirname, join

from mycroft.util.json_helper import load_commented_json
from mycroft.util.json_helper import delete_key_from_dict, load_commented_json


class TestDictUtils(unittest.TestCase):

def test_delete_key_from_dict(self):
dct = {'a': 1, 'b': {'c': 1, 'd': 2}}
self.assertEqual(delete_key_from_dict("b", dct), {'a': 1})
self.assertEqual(delete_key_from_dict(
"b.c", dct), {'a': 1, 'b': {'d': 2}})
self.assertEqual(delete_key_from_dict(
"b.c", dct), {'a': 1, 'b': {'d': 2}})


class TestFileLoad(unittest.TestCase):
Expand Down