mycroft-core/mycroft/configuration/__init__.py

347 lines
12 KiB
Python

# Copyright 2017 Mycroft AI Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import json
import inflection
import re
from genericpath import exists, isfile
from os.path import join, dirname, expanduser
from mycroft.util.json_helper import load_commented_json
from mycroft.util.log import LOG
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"]
WEB_CONFIG_CACHE = '/opt/mycroft/web_config_cache.json'
@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)
RemoteConfiguration.__store_cache(setting)
except Exception as e:
LOG.warning("Failed to fetch remote configuration: %s" %
repr(e))
RemoteConfiguration.__load_cache(config)
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):
if key not in config:
config[key] = {}
RemoteConfiguration.__load_list(config[key], v)
else:
config[key] = v
@staticmethod
def __store_cache(setting):
"""
Cache the received settings locally. The cache will be used if
the remote is unreachable to load settings that are as close
to the user's as possible
"""
config = {}
# Remove server specific entries
RemoteConfiguration.__load(config, setting)
with open(RemoteConfiguration.WEB_CONFIG_CACHE, 'w') as f:
json.dump(config, f)
@staticmethod
def __load_cache(config):
"""
Load cache from file
"""
LOG.info("Using cached configuration if available")
ConfigurationLoader.load(config,
[RemoteConfiguration.WEB_CONFIG_CACHE],
False)
@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_internal(config):
LOG.info("Updating config internally")
ConfigurationManager.update(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
try:
LOG.info("Saving config")
loc_config = load_commented_json(location)
with open(location, 'w') as f:
ConfigurationLoader.merge_conf(loc_config, config)
json.dump(loc_config, f)
except Exception as e:
LOG.error(e)
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.
'configuration.update', and updates the cached configuration when this
is encountered.
"""
def __init__(self, ws):
super(_ConfigurationListener, self).__init__()
ws.on("configuration.updated", self.updated)
ws.on("configuration.patch", self.patch)
@staticmethod
def updated(message):
"""
Event handler for configuration updated events. Forces a reload
of all configuration sources.
Args:
message: message bus message structure
"""
ConfigurationManager.load_defaults()
@staticmethod
def patch(message):
"""
Event handler for configuration update events.
Update config with provided data
Args:
message: message bus message structure
"""
config = message.data.get("config", {})
ConfigurationManager.load_internal(config)