mycroft-core/mycroft/configuration/__init__.py

291 lines
9.7 KiB
Python

# Copyright 2016 Mycroft AI, Inc.
#
# This file is part of Mycroft Core.
#
# Mycroft Core is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Mycroft Core is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Mycroft Core. If not, see <http://www.gnu.org/licenses/>.
import json
import inflection
import re
from genericpath import exists, isfile
from os.path import join, dirname, expanduser
from mycroft.util.log import getLogger
from mycroft.util.json_helper import load_commented_json
__author__ = 'seanfitz, jdorleans'
LOG = getLogger(__name__)
DEFAULT_CONFIG = join(dirname(__file__), 'mycroft.conf')
SYSTEM_CONFIG = '/etc/mycroft/mycroft.conf'
USER_CONFIG = join(expanduser('~'), '.mycroft/mycroft.conf')
REMOTE_CONFIG = "mycroft.ai"
load_order = [DEFAULT_CONFIG, REMOTE_CONFIG, SYSTEM_CONFIG, USER_CONFIG]
class ConfigurationLoader(object):
"""
A utility for loading Mycroft configuration files.
Mycroft configuration comes from four potential locations:
* Defaults found in 'mycroft.conf' in the code
* Remote settings (coming from home.mycroft.ai)
* System settings (typically found at /etc/mycroft/mycroft.conf
* User settings (typically found at /home/<user>/.mycroft/mycroft.conf
These get loaded in that order on top of each other. So a value specified
in the Default would be overridden by a value with the same name found
in the Remote. And a value in the Remote would be overridden by a value
set in the User settings. Not all values exist at all levels.
See comments in the 'mycroft.conf' for more information about specific
settings and where they reside.
Note:
Values are overridden by name. This includes all data under that name,
so you if a value contains a complex structure, you cannot specify
only a single component of that structure -- you have to override the
entire structure.
"""
@staticmethod
def init_config(config=None):
if not config:
return {}
return config
@staticmethod
def init_locations(locations=None, keep_user_config=True):
if not locations:
locations = [DEFAULT_CONFIG, SYSTEM_CONFIG, USER_CONFIG]
elif keep_user_config:
locations += [USER_CONFIG]
return locations
@staticmethod
def validate(config=None, locations=None):
if not (isinstance(config, dict) and isinstance(locations, list)):
LOG.error("Invalid configuration data type.")
LOG.error("Locations: %s" % locations)
LOG.error("Configuration: %s" % config)
raise TypeError
@staticmethod
def load(config=None, locations=None, keep_user_config=True):
"""
Loads default or specified configuration files
"""
config = ConfigurationLoader.init_config(config)
locations = ConfigurationLoader.init_locations(locations,
keep_user_config)
ConfigurationLoader.validate(config, locations)
for location in locations:
config = ConfigurationLoader.__load(config, location)
return config
@staticmethod
def merge_conf(base, delta):
"""
Recursively merging configuration dictionaries.
Args:
base: Target for merge
delta: Dictionary to merge into base
"""
for k, dv in delta.iteritems():
bv = base.get(k)
if isinstance(dv, dict) and isinstance(bv, dict):
ConfigurationLoader.merge_conf(bv, dv)
else:
base[k] = dv
@staticmethod
def __load(config, location):
if exists(location) and isfile(location):
try:
ConfigurationLoader.merge_conf(
config, load_commented_json(location))
LOG.debug("Configuration '%s' loaded" % location)
except Exception, e:
LOG.error("Error loading configuration '%s'" % location)
LOG.error(repr(e))
else:
LOG.debug("Configuration '%s' not found" % location)
return config
class RemoteConfiguration(object):
"""
map remote configuration properties to
config in the [core] config section
"""
IGNORED_SETTINGS = ["uuid", "@type", "active", "user", "device"]
@staticmethod
def validate(config):
if not (config and isinstance(config, dict)):
LOG.error("Invalid configuration: %s" % config)
raise TypeError
@staticmethod
def load(config=None):
RemoteConfiguration.validate(config)
update = config.get("server", {}).get("update")
if update:
try:
from mycroft.api import DeviceApi
api = DeviceApi()
setting = api.find_setting()
location = api.find_location()
if location:
setting["location"] = location
RemoteConfiguration.__load(config, setting)
except Exception as e:
LOG.warn("Failed to fetch remote configuration: %s" % repr(e))
else:
LOG.debug("Remote configuration not activated.")
return config
@staticmethod
def __load(config, setting):
for k, v in setting.iteritems():
if k not in RemoteConfiguration.IGNORED_SETTINGS:
# Translate the CamelCase values stored remotely into the
# Python-style names used within mycroft-core.
key = inflection.underscore(re.sub(r"Setting(s)?", "", k))
if isinstance(v, dict):
config[key] = config.get(key, {})
RemoteConfiguration.__load(config[key], v)
elif isinstance(v, list):
RemoteConfiguration.__load_list(config[key], v)
else:
config[key] = v
@staticmethod
def __load_list(config, values):
for v in values:
module = v["@type"]
if v.get("active"):
config["module"] = module
config[module] = config.get(module, {})
RemoteConfiguration.__load(config[module], v)
class ConfigurationManager(object):
"""
Static management utility for accessing the cached configuration.
This configuration is periodically updated from the remote server
to keep in sync.
"""
__config = None
__listener = None
@staticmethod
def instance():
"""
The cached configuration.
Returns:
dict: A dictionary representing the Mycroft configuration
"""
return ConfigurationManager.get()
@staticmethod
def init(ws):
# Start listening for configuration update events on the messagebus
ConfigurationManager.__listener = _ConfigurationListener(ws)
@staticmethod
def load_defaults():
for location in load_order:
LOG.info("Loading configuration: " + location)
if location == REMOTE_CONFIG:
RemoteConfiguration.load(ConfigurationManager.__config)
else:
ConfigurationManager.__config = ConfigurationLoader.load(
ConfigurationManager.__config, [location])
return ConfigurationManager.__config
@staticmethod
def load_local(locations=None, keep_user_config=True):
return ConfigurationLoader.load(ConfigurationManager.get(), locations,
keep_user_config)
@staticmethod
def load_remote():
if not ConfigurationManager.__config:
ConfigurationManager.__config = ConfigurationLoader.load()
return RemoteConfiguration.load(ConfigurationManager.__config)
@staticmethod
def get(locations=None):
"""
Get cached configuration.
Returns:
dict: A dictionary representing the Mycroft configuration
"""
if not ConfigurationManager.__config:
ConfigurationManager.load_defaults()
if locations:
ConfigurationManager.load_local(locations)
return ConfigurationManager.__config
@staticmethod
def update(config):
"""
Update cached configuration with the new ``config``.
"""
if not ConfigurationManager.__config:
ConfigurationManager.load_defaults()
if config:
ConfigurationManager.__config.update(config)
@staticmethod
def save(config, is_system=False):
"""
Save configuration ``config``.
"""
ConfigurationManager.update(config)
location = SYSTEM_CONFIG if is_system else USER_CONFIG
loc_config = load_commented_json(location)
with open(location, 'w') as f:
config = loc_config.update(config)
json.dump(config, f)
class _ConfigurationListener(object):
""" Utility to synchronize remote configuration changes locally
This listens to the messagebus for 'configuration.updated', and
refreshes the cached configuration when this is encountered.
"""
def __init__(self, ws):
super(_ConfigurationListener, self).__init__()
ws.on("configuration.updated", self.updated)
@staticmethod
def updated(message):
ConfigurationManager.update(message.data)