Merge pull request #2104 from MycroftAI/feature/skill-gid

Feature/skill gid
pull/2130/head
Åke 2019-05-22 06:19:37 +02:00 committed by GitHub
commit 1300773f3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 269 additions and 233 deletions

View File

@ -6,6 +6,7 @@ before_install:
- sudo apt-get install -qq mpg123 portaudio19-dev libglib2.0-dev swig bison libtool autoconf libglib2.0-dev libicu-dev libfann-dev realpath
- sudo apt-get install -y gcc-4.8 g++-4.8
- export CC="gcc-4.8"
- export TMPDIR="/tmp/${TRAVIS_PYTHON_VERSION}"
python:
- "3.4"
- "3.5"
@ -15,6 +16,9 @@ python:
cache: pocketsphinx-python
# command to install dependencies
install:
- rm -rf ${TMPDIR}
- mkdir ${TMPDIR}
- echo ${TMPDIR}
- VIRTUALENV_ROOT=${VIRTUAL_ENV} ./dev_setup.sh
- pip install -r requirements.txt
- pip install -r test-requirements.txt

View File

@ -371,6 +371,11 @@ class DeviceApi(Api):
skills = {s['name']: s for s in data['skills']}
to_send['skills'] = [skills[key] for key in skills]
# Finalize skill_gid with uuid if needed
for s in to_send['skills']:
s['skill_gid'] = s.get('skill_gid', '').replace(
'@|', '@{}|'.format(self.identity.uuid))
self.request({
"method": "PUT",
"path": "/" + self.identity.uuid + "/skillJson",

View File

@ -0,0 +1,43 @@
# Copyright 2019 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 os
from os.path import join, expanduser, exists
from msm import MycroftSkillsManager, SkillRepo
from mycroft.util.combo_lock import ComboLock
mycroft_msm_lock = ComboLock('/tmp/mycroft-msm.lck')
def create_msm(config):
""" Create msm object from config. """
msm_config = config['skills']['msm']
repo_config = msm_config['repo']
data_dir = expanduser(config['data_dir'])
skills_dir = join(data_dir, msm_config['directory'])
repo_cache = join(data_dir, repo_config['cache'])
platform = config['enclosure'].get('platform', 'default')
with mycroft_msm_lock:
# Try to create the skills directory if it doesn't exist
if not exists(skills_dir):
os.makedirs(skills_dir)
return MycroftSkillsManager(
platform=platform, skills_dir=skills_dir,
repo=SkillRepo(repo_cache, repo_config['url'],
repo_config['branch']),
versioned=msm_config['versioned'])

View File

@ -63,14 +63,52 @@ import json
import hashlib
import os
import yaml
import time
import copy
import re
from threading import Timer
from os.path import isfile, join, expanduser
from requests.exceptions import RequestException, HTTPError
from msm import SkillEntry
from mycroft.api import DeviceApi, is_paired
from mycroft.util.log import LOG
from mycroft.util import camel_case_split
from mycroft.configuration import ConfigurationManager
from .msm_wrapper import create_msm
msm = None
msm_creation_time = 0
def build_global_id(directory, config):
""" Create global id for the skill.
TODO: Handle dirty skill
Arguments:
directory: skill directory
config: config for the device to fetch msm setup
"""
# Update the msm object if it's more than an hour old
global msm
global msm_creation_time
if msm is None or time.time() - msm_creation_time > 60 * 60:
msm_creation_time = time.time()
msm = create_msm(config)
s = SkillEntry.from_folder(directory, msm)
# If modified prepend the device uuid
return s.skill_gid, s.meta_info.get('display_name')
def display_name(name):
""" Splits camelcase and removes leading/trailing Skill. """
name = re.sub(r'(^[Ss]kill|[Ss]kill$)', '', name)
return camel_case_split(name)
class DelayRequest(Exception):
""" Indicate that the next request should be delayed. """
@ -82,8 +120,10 @@ class SkillSettings(dict):
also syncs to the backend for skill settings
Args:
directory (str): Path to storage directory
name (str): user readable name associated with the settings
directory (str): Path to storage directory
name (str): user readable name associated with the settings
no_upload (bool): True if the upload to mycroft servers should be
disabled.
"""
def __init__(self, directory, name):
@ -100,6 +140,8 @@ class SkillSettings(dict):
# set file paths
self._settings_path = join(directory, 'settings.json')
self._meta_path = _get_meta_path(directory)
self._directory = directory
self.is_alive = True
self.loaded_hash = hash(json.dumps(self, sort_keys=True))
self._complete_intialization = False
@ -108,11 +150,33 @@ class SkillSettings(dict):
self._user_identity = None
self.changed_callback = None
self._poll_timer = None
self._blank_poll_timer = None
self._is_alive = True
self._meta_upload = True # Flag allowing upload of settings meta
# Add Information extracted from the skills-meta.json entry for the
# skill.
skill_gid, disp_name = build_global_id(self._directory, self.config)
self.__skill_gid = skill_gid
self.display_name = disp_name
# if settingsmeta exist
if self._meta_path:
self._poll_skill_settings()
# if not disallowed by user upload an entry for all skills installed
elif self.config['skills']['upload_skill_manifest']:
self._blank_poll_timer = Timer(1, self._init_blank_meta)
self._blank_poll_timer.daemon = True
self._blank_poll_timer.start()
@property
def skill_gid(self):
""" Finalizes the skill gid to include device uuid if needed. """
if is_paired():
return self.__skill_gid.replace('@|', '@{}|'.format(
DeviceApi().identity.uuid))
else:
return self.__skill_gid
def __hash__(self):
""" Simple object unique hash. """
@ -128,6 +192,8 @@ class SkillSettings(dict):
self._is_alive = False
if self._poll_timer:
self._poll_timer.cancel()
if self._blank_poll_timer:
self._blank_poll_timer.cancel()
def set_changed_callback(self, callback):
""" Set callback to perform when server settings have changed.
@ -158,34 +224,17 @@ class SkillSettings(dict):
except RequestException:
return
hashed_meta = self._get_meta_hash(settings_meta)
skill_settings = self._request_other_settings(hashed_meta)
# if hash is new then there is a diff version of settingsmeta
if self._is_new_hash(hashed_meta):
# first look at all other devices on user account to see
# if the settings exist. if it does then sync with device
if skill_settings:
# not_owner flags that this settings is loaded from
# another device. If a skill settings doesn't have
# not_owner, then the skill is created from that device
self['not_owner'] = True
self.save_skill_settings(skill_settings)
else: # upload skill settings if
uuid = self._load_uuid()
if uuid is not None:
self._delete_metadata(uuid)
self._upload_meta(settings_meta, hashed_meta)
else: # hash is not new
if skill_settings is not None:
self['not_owner'] = True
self.save_skill_settings(skill_settings)
else:
settings = self._request_my_settings(hashed_meta)
if settings is None:
# metadata got deleted from Home, send up
self._upload_meta(settings_meta, hashed_meta)
else:
self.save_skill_settings(settings)
settings = (self._request_my_settings(self.skill_gid) or
self._request_other_settings(self.skill_gid))
if settings:
self.save_skill_settings(settings)
# TODO if this skill_gid is not a modified version check if a modified
# version exists on the server and delete it
# Always try to upload settingsmeta on startup
self._upload_meta(settings_meta, self.skill_gid)
self._complete_intialization = True
@property
@ -202,24 +251,41 @@ class SkillSettings(dict):
return super(SkillSettings, self).__setitem__(key, value)
def _load_settings_meta(self):
""" Loads settings metadata from skills path. """
if not self._meta_path:
return None
""" Load settings metadata from the skill folder.
_, ext = os.path.splitext(self._meta_path)
json_file = True if ext.lower() == ".json" else False
If no settingsmeta exists a basic settingsmeta will be created
containing a basic identifier.
try:
with open(self._meta_path, encoding='utf-8') as f:
if json_file:
data = json.load(f)
else:
data = yaml.load(f)
return data
except Exception as e:
LOG.error("Failed to load setting file: " + self._meta_path)
LOG.error(repr(e))
return None
Returns:
(dict) settings meta
"""
if self._meta_path and os.path.isfile(self._meta_path):
_, ext = os.path.splitext(self._meta_path)
json_file = True if ext.lower() == ".json" else False
try:
with open(self._meta_path, encoding='utf-8') as f:
if json_file:
data = json.load(f)
else:
data = yaml.load(f)
except Exception as e:
LOG.error("Failed to load setting file: " + self._meta_path)
LOG.error(repr(e))
data = copy.copy(BLANK_META)
else:
data = {}
# Insert skill_gid and display_name
data['skill_gid'] = self.skill_gid
data['display_name'] = (self.display_name or data.get('name') or
display_name(self.name))
# Backwards compatibility:
if 'name' not in data:
data['name'] = data['display_name']
return data
def _send_settings_meta(self, settings_meta):
""" Send settingsmeta to the server.
@ -229,18 +295,21 @@ class SkillSettings(dict):
Returns:
dict: uuid, a unique id for the setting meta data
"""
try:
uuid = self._put_metadata(settings_meta)
return uuid
except HTTPError as e:
if e.response.status_code in [422, 500, 501]:
raise DelayRequest
else:
if self._meta_upload:
try:
uuid = self._put_metadata(settings_meta)
return uuid
except HTTPError as e:
if e.response.status_code in [422, 500, 501]:
self._meta_upload = False
raise DelayRequest
else:
LOG.error(e)
return None
except Exception as e:
LOG.error(e)
return None
except Exception as e:
LOG.error(e)
return None
def save_skill_settings(self, skill_settings):
""" Takes skill object and save onto self
@ -248,61 +317,22 @@ class SkillSettings(dict):
Args:
skill_settings (dict): skill
"""
if self._is_new_hash(skill_settings['identifier']):
self._save_uuid(skill_settings['uuid'])
self._save_hash(skill_settings['identifier'])
sections = skill_settings['skillMetadata']['sections']
for section in sections:
for field in section["fields"]:
if "name" in field and "value" in field:
self[field['name']] = field['value']
self.store()
def _load_uuid(self):
""" Loads uuid
Returns:
str: uuid of the previous settingsmeta
"""
directory = self.config.get("skills")["directory"]
directory = join(directory, self.name)
directory = expanduser(directory)
uuid_file = join(directory, 'uuid')
uuid = None
if isfile(uuid_file):
with open(uuid_file, 'r') as f:
uuid = f.read()
return uuid
def _save_uuid(self, uuid):
""" Saves uuid.
Args:
uuid (str): uuid, unique id of new settingsmeta
"""
directory = self.config.get("skills")["directory"]
directory = join(directory, self.name)
directory = expanduser(directory)
uuid_file = join(directory, 'uuid')
os.makedirs(directory, exist_ok=True)
with open(uuid_file, 'w') as f:
f.write(str(uuid))
def _uuid_exist(self):
""" Checks if there is an uuid file.
Returns:
bool: True if uuid file exist False otherwise
"""
directory = self.config.get("skills")["directory"]
directory = join(directory, self.name)
directory = expanduser(directory)
uuid_file = join(directory, 'uuid')
return isfile(uuid_file)
if 'skillMetadata' in skill_settings:
sections = skill_settings['skillMetadata']['sections']
for section in sections:
for field in section["fields"]:
if "name" in field and "value" in field:
# Bypass the change lock to allow server to update
# during skill init
super(SkillSettings, self).__setitem__(field['name'],
field['value'])
self.store()
def _migrate_settings(self, settings_meta):
""" sync settings.json and settingsmeta in memory """
meta = settings_meta.copy()
if 'skillMetadata' not in meta:
return meta
self.load_skill_settings_from_file()
sections = meta['skillMetadata']['sections']
for i, section in enumerate(sections):
@ -314,90 +344,50 @@ class SkillSettings(dict):
meta['skillMetadata']['sections'] = sections
return meta
def _upload_meta(self, settings_meta, hashed_meta):
def _upload_meta(self, settings_meta, identifier):
""" uploads the new meta data to settings with settings migration
Args:
settings_meta (dict): from settingsmeta.json or settingsmeta.yaml
hashed_meta (str): {skill-folder}-settinsmeta.json
settings_meta (dict): settingsmeta.json or settingsmeta.yaml
identifier (str): identifier for skills meta data
"""
LOG.debug('Uploading settings meta for {}'.format(identifier))
meta = self._migrate_settings(settings_meta)
meta['identifier'] = str(hashed_meta)
meta['identifier'] = identifier
response = self._send_settings_meta(meta)
if response and 'uuid' in response:
self._save_uuid(response['uuid'])
if 'not_owner' in self:
del self['not_owner']
self._save_hash(hashed_meta)
def hash(self, string):
""" md5 hasher for consistency across cpu architectures """
return hashlib.md5(bytes(string, 'utf-8')).hexdigest()
def _get_meta_hash(self, settings_meta):
""" Gets the hash of skill
Args:
settings_meta (dict): settingsmeta object
Returns:
_hash (str): hashed to identify skills
"""
_hash = self.hash(json.dumps(settings_meta, sort_keys=True) +
self._user_identity)
return "{}--{}".format(self.name, _hash)
def _save_hash(self, hashed_meta):
""" Saves hashed_meta to settings directory.
Args:
hashed_meta (str): hash of new settingsmeta
"""
directory = self.config.get("skills")["directory"]
directory = join(directory, self.name)
directory = expanduser(directory)
hash_file = join(directory, 'hash')
os.makedirs(directory, exist_ok=True)
with open(hash_file, 'w') as f:
f.write(hashed_meta)
def _is_new_hash(self, hashed_meta):
""" Check if stored hash is the same as current.
If the hashed file does not exist, usually in the
case of first load, then the create it and return True
Args:
hashed_meta (str): hash of metadata and uuid of device
Returns:
bool: True if hash is new, otherwise False
"""
directory = self.config.get("skills")["directory"]
directory = join(directory, self.name)
directory = expanduser(directory)
hash_file = join(directory, 'hash')
if isfile(hash_file):
with open(hash_file, 'r') as f:
current_hash = f.read()
return False if current_hash == str(hashed_meta) else True
return True
def update_remote(self):
""" update settings state from server """
skills_settings = None
settings_meta = self._load_settings_meta()
if settings_meta is None:
return
hashed_meta = self._get_meta_hash(settings_meta)
if self.get('not_owner'):
skills_settings = self._request_other_settings(hashed_meta)
if not skills_settings:
skills_settings = self._request_my_settings(hashed_meta)
# Get settings
skills_settings = (self._request_my_settings(self.skill_gid) or
self._request_other_settings(self.skill_gid))
if skills_settings is not None:
self.save_skill_settings(skills_settings)
self.store()
else:
LOG.debug("No Settings on server for {}".format(self.skill_gid))
# Settings meta doesn't exist on server push them
settings_meta = self._load_settings_meta()
self._upload_meta(settings_meta, hashed_meta)
self._upload_meta(settings_meta, self.skill_gid)
def _init_blank_meta(self):
""" Send blank settingsmeta to remote. """
try:
if not is_paired() and self.is_alive:
self._blank_poll_timer = Timer(60, self._init_blank_meta)
self._blank_poll_timer.daemon = True
self._blank_poll_timer.start()
else:
self.initialize_remote_settings()
except Exception as e:
LOG.exception('Failed to send blank meta: {}'.format(repr(e)))
def _poll_skill_settings(self):
""" If identifier exists for this skill poll to backend to
@ -466,6 +456,9 @@ class SkillSettings(dict):
dict: skills object
"""
meta = settings_meta.copy()
if 'skillMetadata' not in settings_meta:
return meta
sections = meta['skillMetadata']['sections']
for i, section in enumerate(sections):
@ -504,7 +497,6 @@ class SkillSettings(dict):
def _request_my_settings(self, identifier):
""" Get skill settings for this device associated
with the identifier
Args:
identifier (str): a hashed_meta
Returns:
@ -515,12 +507,33 @@ class SkillSettings(dict):
# this loads the settings into memory for use in self.store
for skill_settings in settings:
if skill_settings['identifier'] == identifier:
LOG.debug("Fetched settings for {}".format(identifier))
skill_settings = \
self._type_cast(skill_settings, to_platform='core')
self._remote_settings = skill_settings
return skill_settings
return None
def _request_other_settings(self, identifier):
""" Retrieve skill settings from other devices by identifier
Args:
identifier (str): identifier for this skill
Returns:
settings (dict or None): the retrieved settings or None
"""
path = \
"/" + self._device_identity + "/userSkill?identifier=" + identifier
try:
user_skill = self.api.request({"method": "GET", "path": path})
except RequestException:
# Some kind of Timeout, connection HTTPError, etc.
user_skill = None
if not user_skill or not user_skill[0]:
return None
else:
settings = self._type_cast(user_skill[0], to_platform='core')
return settings
def _request_settings(self):
""" Get all skill settings for this device from server.
@ -538,27 +551,6 @@ class SkillSettings(dict):
settings = [skills for skills in settings if skills is not None]
return settings
def _request_other_settings(self, identifier):
""" Retrieve skill settings from other devices by identifier
Args:
identifier (str): identifier for this skill
Returns:
settings (dict or None): the retrieved settings or None
"""
path = \
"/" + self._device_identity + "/userSkill?identifier=" + identifier
try:
user_skill = self.api.request({"method": "GET", "path": path})
except RequestException:
# Some kind of Timeout, connection HTTPError, etc.
user_skill = None
if not user_skill:
return None
else:
settings = self._type_cast(user_skill[0], to_platform='core')
return settings
def _put_metadata(self, settings_meta):
""" PUT settingsmeta to backend to be configured in server.
used in place of POST and PATCH.
@ -576,6 +568,7 @@ class SkillSettings(dict):
def _delete_metadata(self, uuid):
""" Delete the current skill metadata
TODO: UPDATE FOR NEW BACKEND
Args:
uuid (str): unique id of the skill
"""
@ -594,7 +587,8 @@ class SkillSettings(dict):
@property
def _should_upload_from_change(self):
changed = False
if hasattr(self, '_remote_settings'):
if (hasattr(self, '_remote_settings') and
'skillMetadata' in self._remote_settings):
sections = self._remote_settings['skillMetadata']['sections']
for i, section in enumerate(sections):
for j, field in enumerate(section['fields']):
@ -624,11 +618,7 @@ class SkillSettings(dict):
if self._should_upload_from_change:
settings_meta = self._load_settings_meta()
hashed_meta = self._get_meta_hash(settings_meta)
uuid = self._load_uuid()
if uuid is not None:
self._delete_metadata(uuid)
self._upload_meta(settings_meta, hashed_meta)
self._upload_meta(settings_meta, self.skill_gid)
def _get_meta_path(base_directory):

View File

@ -33,7 +33,7 @@ from mycroft.util.log import LOG
from mycroft.api import DeviceApi, is_paired
from .core import load_skill, create_skill_descriptor, MainModule
from .msm_wrapper import create_msm as msm_creator
DEBUG = Configuration.get().get("debug", False)
skills_config = Configuration.get().get("skills")
@ -132,23 +132,7 @@ class SkillManager(Thread):
@staticmethod
def create_msm():
config = Configuration.get()
msm_config = config['skills']['msm']
repo_config = msm_config['repo']
data_dir = expanduser(config['data_dir'])
skills_dir = join(data_dir, msm_config['directory'])
# Try to create the skills directory if it doesn't exist
if not exists(skills_dir):
os.makedirs(skills_dir)
repo_cache = join(data_dir, repo_config['cache'])
platform = config['enclosure'].get('platform', 'default')
return MycroftSkillsManager(
platform=platform, skills_dir=skills_dir,
repo=SkillRepo(
repo_cache, repo_config['url'], repo_config['branch']
), versioned=msm_config['versioned']
)
return msm_creator(Configuration.get())
def schedule_now(self, message=None):
self.next_download = time.time() - 1

View File

@ -23,7 +23,7 @@ google-api-python-client==1.6.4
fasteners==0.14.1
PyYAML==3.13
msm==0.7.3
msm==0.7.5
msk==0.3.12
adapt-parser==0.3.2
padatious==0.4.6

View File

@ -14,6 +14,7 @@
#
import json
import unittest
from mock import MagicMock
from os import remove
from os.path import join, dirname
@ -21,6 +22,10 @@ from os.path import join, dirname
from mycroft.skills.settings import SkillSettings
SkillSettings._poll_skill_settings = MagicMock()
SkillSettings._init_blank_meta = MagicMock()
class SkillSettingsTest(unittest.TestCase):
def setUp(self):
try:

View File

@ -1,5 +1,6 @@
import unittest
import mock
import copy
import tempfile
from os.path import exists, join
from shutil import rmtree
@ -9,7 +10,6 @@ from mycroft.configuration import Configuration
from mycroft.skills.skill_manager import SkillManager
BASE_CONF = base_config()
BASE_CONF['data_dir'] = tempfile.mkdtemp()
BASE_CONF['skills'] = {
'msm': {
'directory': 'skills',
@ -17,7 +17,7 @@ BASE_CONF['skills'] = {
'repo': {
'cache': '.skills-repo',
'url': 'https://github.com/MycroftAI/mycroft-skills',
'branch': '18.08'
'branch': '19.02'
}
},
'update_interval': 3600,
@ -56,14 +56,16 @@ class MycroftSkillTest(unittest.TestCase):
self.emitter.reset()
self.temp_dir = tempfile.mkdtemp()
@mock.patch.dict(Configuration._Configuration__config, BASE_CONF)
def test_create_manager(self):
""" Verify that the skill manager and msm loads as expected and
that the skills dir is created as needed.
"""
SkillManager(self.emitter)
self.assertTrue(exists(join(BASE_CONF['data_dir'], 'skills')))
conf = copy.deepcopy(BASE_CONF)
conf['data_dir'] = self.temp_dir
with mock.patch.dict(Configuration._Configuration__config,
BASE_CONF):
SkillManager(self.emitter)
self.assertTrue(exists(join(BASE_CONF['data_dir'], 'skills')))
@classmethod
def tearDownClass(cls):
rmtree(BASE_CONF['data_dir'])
def tearDown(self):
rmtree(self.temp_dir)

View File

@ -15,23 +15,25 @@
import unittest
from shutil import rmtree
from os.path import exists, isfile
from os.path import exists, isfile, join
from tempfile import gettempdir
from mycroft.util import create_signal, check_for_signal
class TestSignals(unittest.TestCase):
def setUp(self):
if exists('/tmp/mycroft'):
rmtree('/tmp/mycroft')
if exists(join(gettempdir(), 'mycroft')):
rmtree(join(gettempdir(), 'mycroft'))
def test_create_signal(self):
create_signal('test_signal')
self.assertTrue(isfile('/tmp/mycroft/ipc/signal/test_signal'))
self.assertTrue(isfile(join(gettempdir(),
'mycroft/ipc/signal/test_signal')))
def test_check_signal(self):
if exists('/tmp/mycroft'):
rmtree('/tmp/mycroft')
if exists(join(gettempdir(), 'mycroft')):
rmtree(join(gettempdir(), 'mycroft'))
# check that signal is not found if file does not exist
self.assertFalse(check_for_signal('test_signal'))
@ -39,7 +41,8 @@ class TestSignals(unittest.TestCase):
create_signal('test_signal')
self.assertTrue(check_for_signal('test_signal'))
# Check that the signal is removed after use
self.assertFalse(isfile('/tmp/mycroft/ipc/signal/test_signal'))
self.assertFalse(isfile(join(gettempdir(),
'mycroft/ipc/signal/test_signal')))
if __name__ == "__main__":