Hello community,

here is the log from the commit of package python-msm for openSUSE:Factory 
checked in at 2019-09-04 09:10:09
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-msm (Old)
 and      /work/SRC/openSUSE:Factory/.python-msm.new.7948 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-msm"

Wed Sep  4 09:10:09 2019 rev:10 rq:727100 version:0.8.2

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-msm/python-msm.changes    2019-07-24 
20:37:15.490562744 +0200
+++ /work/SRC/openSUSE:Factory/.python-msm.new.7948/python-msm.changes  
2019-09-04 09:10:22.714979546 +0200
@@ -1,0 +2,12 @@
+Tue Aug 27 12:44:58 UTC 2019 - Marketa Calabkova <mcalabk...@suse.com>
+
+- update to version 0.8.2
+  * Add new platforms
+  * Remove temporary copy of skill after action
+  * added a skill_list property to cache the results of the list() method
+  * added tests for more coverage
+  * Fix infinite recursion issue
+  * Make from_folder use msm skill cache if possible
+- reapplied add-local-patch-support.patch
+
+-------------------------------------------------------------------

Old:
----
  v0.7.8.tar.gz

New:
----
  v0.8.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-msm.spec ++++++
--- /var/tmp/diff_new_pack.IifMCX/_old  2019-09-04 09:10:29.786978563 +0200
+++ /var/tmp/diff_new_pack.IifMCX/_new  2019-09-04 09:10:29.790978563 +0200
@@ -19,7 +19,7 @@
 %define skip_python2 1
 %{?!python_module:%define python_module() python-%{**} python3-%{**}}
 Name:           python-msm
-Version:        0.7.8
+Version:        0.8.2
 Release:        0
 Summary:        Mycroft Skills Manager
 License:        Apache-2.0

++++++ add-local-patch-support.patch ++++++
--- /var/tmp/diff_new_pack.IifMCX/_old  2019-09-04 09:10:29.822978558 +0200
+++ /var/tmp/diff_new_pack.IifMCX/_new  2019-09-04 09:10:29.822978558 +0200
@@ -1,17 +1,17 @@
-Index: mycroft-skills-manager-0.7.6/msm/skill_entry.py
+Index: mycroft-skills-manager-0.8.0/msm/skill_entry.py
 ===================================================================
---- mycroft-skills-manager-0.7.6.orig/msm/skill_entry.py
-+++ mycroft-skills-manager-0.7.6/msm/skill_entry.py
+--- mycroft-skills-manager-0.8.0.orig/msm/skill_entry.py
++++ mycroft-skills-manager-0.8.0/msm/skill_entry.py
 @@ -45,6 +45,8 @@ from msm.exceptions import PipRequiremen
      SystemRequirementsException, AlreadyInstalled, SkillModified, \
      AlreadyRemoved, RemoveException, CloneException, NotInstalled, 
GitException
- from msm.util import Git
+ from msm.util import cached_property, Git
 +from msm.local_patches_utils import apply_skill_patch, \
 +    reverse_skill_patch, remove_applied_skill_patch
  
  LOG = logging.getLogger(__name__)
  
-@@ -420,6 +422,7 @@ class SkillEntry(object):
+@@ -430,6 +432,7 @@ class SkillEntry(object):
              raise AlreadyRemoved(self.name)
          try:
              rmtree(self.path)
@@ -19,7 +19,7 @@
              self.is_local = False
          except OSError as e:
              raise RemoveException(str(e))
-@@ -447,6 +450,7 @@ class SkillEntry(object):
+@@ -457,6 +460,7 @@ class SkillEntry(object):
          try:
              move(tmp_location, self.path)
  
@@ -27,7 +27,7 @@
              if self.msm:
                  self.run_skill_requirements()
              self.install_system_deps()
-@@ -484,6 +488,7 @@ class SkillEntry(object):
+@@ -494,6 +498,7 @@ class SkillEntry(object):
          with git_to_msm_exceptions():
              sha_before = git.rev_parse('HEAD')
  
@@ -35,7 +35,7 @@
              modified_files = git.status(porcelain=True, untracked='no')
              if modified_files != '':
                  raise SkillModified('Uncommitted changes:\n' + modified_files)
-@@ -495,6 +500,7 @@ class SkillEntry(object):
+@@ -505,6 +510,7 @@ class SkillEntry(object):
                  git.checkout(self._find_sha_branch())
  
              git.merge(self.sha or 'origin/HEAD', ff_only=True)
@@ -43,10 +43,10 @@
  
          sha_after = git.rev_parse('HEAD')
  
-Index: mycroft-skills-manager-0.7.6/msm/local_patches_utils.py
+Index: mycroft-skills-manager-0.8.0/msm/local_patches_utils.py
 ===================================================================
 --- /dev/null
-+++ mycroft-skills-manager-0.7.6/msm/local_patches_utils.py
++++ mycroft-skills-manager-0.8.0/msm/local_patches_utils.py
 @@ -0,0 +1,99 @@
 +# Copyright (c) 2018 Mycroft AI, Inc.
 +#

++++++ v0.7.8.tar.gz -> v0.8.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/mycroft-skills-manager-0.7.8/msm/mycroft_skills_manager.py 
new/mycroft-skills-manager-0.8.2/msm/mycroft_skills_manager.py
--- old/mycroft-skills-manager-0.7.8/msm/mycroft_skills_manager.py      
2019-07-09 16:28:02.000000000 +0200
+++ new/mycroft-skills-manager-0.8.2/msm/mycroft_skills_manager.py      
2019-08-23 12:31:42.000000000 +0200
@@ -19,31 +19,51 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import time
+"""Install, remove, update and track the skills on a device
 
+MSM can be used on the command line but is also used by Mycroft core daemons.
+"""
+import time
 import logging
 from functools import wraps
 from glob import glob
 from multiprocessing.pool import ThreadPool
-from os.path import expanduser, join, dirname, isdir
+from os import path
 from typing import Dict, List
 
 from msm import GitException
-from msm.exceptions import (MsmException, SkillNotFound, MultipleSkillMatches,
-                            AlreadyInstalled)
+from msm.exceptions import (
+    AlreadyInstalled,
+    AlreadyRemoved,
+    MsmException,
+    MultipleSkillMatches,
+    RemoveException,
+    SkillNotFound
+)
 from msm.skill_entry import SkillEntry
 from msm.skill_repo import SkillRepo
-from msm.skills_data import (build_skill_entry, get_skill_entry,
-                             write_skills_data, load_skills_data,
-                             skills_data_hash)
-from msm.util import MsmProcessLock
+from msm.skill_state import (
+    initialize_skill_state,
+    get_skill_state,
+    write_device_skill_state,
+    load_device_skill_state,
+    device_skill_state_hash
+)
+from msm.util import cached_property, MsmProcessLock
 
 LOG = logging.getLogger(__name__)
 
 CURRENT_SKILLS_DATA_VERSION = 2
+ONE_DAY = 86400
 
 
-def save_skills_data(func):
+def save_device_skill_state(func):
+    """Decorator to overwrite the skills.json file when skill state changes.
+
+    The methods decorated with this function are executed in threads.  So,
+    this contains some funky logic to keep the threads from stepping on one
+    another.
+    """
     @wraps(func)
     def func_wrapper(self, *args, **kwargs):
         will_save = False
@@ -53,7 +73,7 @@
             ret = func(self, *args, **kwargs)
             # Write only if no exception occurs
             if will_save:
-                self.write_skills_data()
+                self.write_device_skill_state()
         finally:
             # Always restore saving_handled flag
             if will_save:
@@ -65,195 +85,352 @@
 
 
 class MycroftSkillsManager(object):
-    SKILL_GROUPS = {'default', 'mycroft_mark_1', 'picroft', 'kde'}
+    SKILL_GROUPS = {'default', 'mycroft_mark_1', 'picroft', 'kde',
+                    'respeaker', 'mycroft_mark_2', 'mycroft_mark_2pi'}
     DEFAULT_SKILLS_DIR = "/opt/mycroft/skills"
 
     def __init__(self, platform='default', skills_dir=None, repo=None,
                  versioned=True):
         self.platform = platform
         self.skills_dir = (
-                expanduser(skills_dir or '') or self.DEFAULT_SKILLS_DIR
+                path.expanduser(skills_dir or '') or self.DEFAULT_SKILLS_DIR
         )
         self.repo = repo or SkillRepo()
         self.versioned = versioned
         self.lock = MsmProcessLock()
 
-        self.skills_data = None
+        # Property placeholders
+        self._all_skills = None
+        self._default_skills = None
+        self._local_skills = None
+        self._device_skill_state = None
+
         self.saving_handled = False
-        self.skills_data_hash = ''
+        self.device_skill_state_hash = ''
         with self.lock:
-            self.sync_skills_data()
+            self._init_skills_data()
+
+    @cached_property(ttl=ONE_DAY)
+    def all_skills(self):
+        """Getting a list of skills can take a while so cache it.
+
+        The list method is called several times in this class and in core.
+        Skill data on a device just doesn't change that frequently so
+        getting a fresh list that many times does not make a lot of sense.
+        The cache will expire every hour to pick up any changes in the
+        mycroft-skills repo.
+
+        Skill installs and updates will invalidate the cache, which will
+        cause this property to refresh next time is is referenced.
+
+        The list method can be called directly if a fresh skill list is needed.
+        """
+        if self._all_skills is None:
+            self._all_skills = self._get_all_skills()
+
+        return self._all_skills
+
+    def _get_all_skills(self):
+        LOG.info('building SkillEntry objects for all skills')
+        self._refresh_skill_repo()
+        remote_skills = self._get_remote_skills()
+        all_skills = self._merge_remote_with_local(remote_skills)
+
+        return all_skills
+
+    def list(self):
+        """Load a list of SkillEntry objects from both local and remote skills
 
-    def __upgrade_skills_data(self, skills_data):
-        local_skills = [s for s in self.list() if s.is_local]
-        if skills_data.get('version', 0) == 0:
-            skills_data = self.__upgrade_to_v1(skills_data, local_skills)
-        if skills_data['version'] == 1:
-            skills_data = self.__upgrade_to_v2(skills_data, local_skills)
-        return skills_data
-
-    def __upgrade_to_v1(self, skills_data, local_skills):
-        new = {
-            'blacklist': [],
-            'version': 1,
-            'skills': []
-        }
-        default_skills = [s.name for s in self.list_defaults()]
-        for skill in local_skills:
-            if 'origin' in skills_data.get(skill.name, {}):
-                origin = skills_data[skill.name]['origin']
-            elif skill.name in default_skills:
-                origin = 'default'
-            elif skill.url:
-                origin = 'cli'
+        It is necessary to load both local and remote skills at
+        the same time to correctly associate local skills with the name
+        in the repo and remote skills with any custom path that they
+        have been downloaded to.
+
+        The return value of this function is cached in the all_skills property.
+        Only call this method if you need a fresh version of the SkillEntry
+        objects.
+        """
+        all_skills = self._get_all_skills()
+        self._invalidate_skills_cache(new_value=all_skills)
+
+        return all_skills
+
+    def _refresh_skill_repo(self):
+        """Get the latest mycroft-skills repo code."""
+        try:
+            self.repo.update()
+        except GitException as e:
+            if not path.isdir(self.repo.path):
+                raise
+            LOG.warning('Failed to update repo: {}'.format(repr(e)))
+
+    def _get_remote_skills(self):
+        """Build a dictionary of skills in mycroft-skills repo keyed by id"""
+        remote_skills = []
+        for name, _, url, sha in self.repo.get_skill_data():
+            skill_dir = SkillEntry.create_path(self.skills_dir, url, name)
+            sha = sha if self.versioned else ''
+            remote_skills.append(
+                SkillEntry(name, skill_dir, url, sha, msm=self)
+            )
+
+        return {skill.id: skill for skill in remote_skills}
+
+    def _merge_remote_with_local(self, remote_skills):
+        """Merge the skills found in the repo with those installed locally."""
+        all_skills = []
+        for skill_file in glob(path.join(self.skills_dir, '*', '__init__.py')):
+            skill = SkillEntry.from_folder(path.dirname(skill_file), msm=self,
+                                           use_cache=False)
+            if skill.id in remote_skills:
+                skill.attach(remote_skills.pop(skill.id))
+            all_skills.append(skill)
+        all_skills.extend(remote_skills.values())
+
+        return all_skills
+
+    @property
+    def local_skills(self):
+        """Property containing a dictionary of local skills keyed by name."""
+        if self._local_skills is None:
+            self._local_skills = {
+                s.name: s for s in self.all_skills if s.is_local
+            }
+
+        return self._local_skills
+
+    @property
+    def default_skills(self):
+        if self._default_skills is None:
+            default_skill_groups = self.list_all_defaults()
+            try:
+                default_skill_group = default_skill_groups[self.platform]
+            except KeyError:
+                LOG.error(
+                    'No default skill list found for platform "{}".  '
+                    'Using base list.'.format(self.platform)
+                )
+                default_skill_group = default_skill_groups.get('default', [])
+            self._default_skills = {s.name: s for s in default_skill_group}
+
+        return self._default_skills
+
+    def list_all_defaults(self):  # type: () -> Dict[str, List[SkillEntry]]
+        """Generate dictionary of default skills in all default skill groups"""
+        all_skills = {skill.name: skill for skill in self.all_skills}
+        default_skills = {group: [] for group in self.SKILL_GROUPS}
+
+        for group_name, skill_names in self.repo.get_default_skill_names():
+            group_skills = []
+            for skill_name in skill_names:
+                try:
+                    group_skills.append(all_skills[skill_name])
+                except KeyError:
+                    LOG.warning('No such default skill: ' + skill_name)
+            default_skills[group_name] = group_skills
+
+        return default_skills
+
+    def _init_skills_data(self):
+        """Initial load of the skill state that occurs upon instantiation.
+
+        If the skills state was upgraded after it was loaded, write the
+        updated skills state to disk.
+        """
+        try:
+            del(self.device_skill_state['upgraded'])
+        except KeyError:
+            self.device_skill_state_hash = device_skill_state_hash(
+                self.device_skill_state
+            )
+        else:
+            self.write_device_skill_state()
+
+    @property
+    def device_skill_state(self):
+        """Dictionary representing the state of skills on a device."""
+        if self._device_skill_state is None:
+            self._device_skill_state = load_device_skill_state()
+            skills_data_version = self._device_skill_state.get('version', 0)
+            if skills_data_version < CURRENT_SKILLS_DATA_VERSION:
+                self._upgrade_skills_data()
             else:
-                origin = 'non-msm'
-            beta = skills_data.get(skill.name, {}).get('beta', False)
-            entry = build_skill_entry(skill.name, origin, beta,
-                                      skill.skill_gid)
-            entry['installed'] = \
-                skills_data.get(skill.name, {}).get('installed') or 0
-            if isinstance(entry['installed'], bool):
-                entry['installed'] = 0
-
-            entry['update'] = \
-                skills_data.get(skill.name, {}).get('updated') or 0
-
-            new['skills'].append(entry)
-        new['upgraded'] = True
-        return new
+                self._sync_device_skill_state()
+
+        return self._device_skill_state
+
+    def _upgrade_skills_data(self):
+        """Upgrade the contents of the device skills state if needed."""
+        if self._device_skill_state.get('version', 0) == 0:
+            self._upgrade_to_v1()
+        if self._device_skill_state['version'] == 1:
+            self._upgrade_to_v2()
+
+    def _upgrade_to_v1(self):
+        """Upgrade the device skills state to version one."""
+        self._device_skill_state.update(blacklist=[], version=1, skills=[])
+        for skill in self.local_skills.values():
+            skill_data = self._device_skill_state.get(skill.name, {})
+            try:
+                origin = skill_data['origin']
+            except KeyError:
+                origin = self._determine_skill_origin(skill)
+            beta = skill_data.get('beta', False)
+            skill_state = initialize_skill_state(
+                skill.name,
+                origin,
+                beta,
+                skill.skill_gid
+            )
+            skill_state['installed'] = skill_data.get('installed', 0)
+            if isinstance(skill_state['installed'], bool):
+                skill_state['installed'] = 0
+            skill_state['updated'] = skill_data.get('updated', 0)
+            self._device_skill_state['skills'].append(skill_state)
+        self._device_skill_state.update(upgraded=True)
 
-    def __upgrade_to_v2(self, skills_data, local_skills):
-        """ Upgrade to v2 of the skills.json format.
+    def _upgrade_to_v2(self):
+        """Upgrade the device skills state to version 2.
 
         This adds the skill_gid field to skill entries.
         """
-        local_skill_dict = {s.name: s for s in local_skills}
+        self._update_skill_gid()
+        self._device_skill_state.update(version=2, upgraded=True)
 
-        for s in skills_data['skills']:
-            if s['name'] in local_skill_dict:
-                skill_info = local_skill_dict[s['name']]
-                s['skill_gid'] = skill_info.skill_gid
+    def _sync_device_skill_state(self):
+        """Sync device's skill state with with actual skills on disk."""
+        self._add_skills_to_state()
+        self._remove_skills_from_state()
+        self._update_skill_gid()
+
+    def _add_skills_to_state(self):
+        """Add local skill to state if it is not already there."""
+        skill_names = [s['name'] for s in self._device_skill_state['skills']]
+        for skill in self.local_skills.values():
+            if skill.name not in skill_names:
+                origin = self._determine_skill_origin(skill)
+                skill_state = initialize_skill_state(
+                    skill.name,
+                    origin,
+                    False,
+                    skill.skill_gid
+                )
+                self._device_skill_state['skills'].append(skill_state)
+
+    def _remove_skills_from_state(self):
+        """Remove skills from state that no longer exist in the filesystem."""
+        skills_to_remove = []
+        for skill in self._device_skill_state['skills']:
+            is_not_local = skill['name'] not in self.local_skills
+            is_installed_state = skill['installation'] == 'installed'
+            if is_not_local and is_installed_state:
+                skills_to_remove.append(skill)
+
+        for skill in skills_to_remove:
+            self._device_skill_state['skills'].remove(skill)
+
+    def _update_skill_gid(self):
+        for skill in self._device_skill_state['skills']:
+            try:
+                local_skill = self.local_skills[skill['name']]
+            except KeyError:
+                skill['skill_gid'] = ''
             else:
-                s['skill_gid'] = ''
-        skills_data['version'] = 2
-        return skills_data
-
-    def curate_skills_data(self, skills_data):
-        """ Sync skills_data with actual skills on disk. """
-        local_skills = [s for s in self.list() if s.is_local]
-        default_skills = [s.name for s in self.list_defaults()]
-        local_skill_names = [s.name for s in local_skills]
-        skills_data_skills = [s['name'] for s in skills_data['skills']]
-
-        # Check for skills that aren't in the list
-        for skill in local_skills:
-            if skill.name not in skills_data_skills:
-                if skill.name in default_skills:
-                    origin = 'default'
-                elif skill.url:
-                    origin = 'cli'
-                else:
-                    origin = 'non-msm'
-                entry = build_skill_entry(skill.name, origin, False,
-                                          skill.skill_gid)
-                skills_data['skills'].append(entry)
-
-        # Check for skills in the list that doesn't exist in the filesystem
-        remove_list = []
-        for s in skills_data.get('skills', []):
-            if (s['name'] not in local_skill_names and
-                    s['installation'] == 'installed'):
-                remove_list.append(s)
-        for skill in remove_list:
-            skills_data['skills'].remove(skill)
-
-        # Update skill gids
-        for s in local_skills:
-            for e in skills_data['skills']:
-                if e['name'] == s.name:
-                    e['skill_gid'] = s.skill_gid
-
-        return skills_data
-
-    def load_skills_data(self) -> dict:
-        skills_data = load_skills_data()
-        if skills_data.get('version', 0) < CURRENT_SKILLS_DATA_VERSION:
-            skills_data = self.__upgrade_skills_data(skills_data)
-        else:
-            skills_data = self.curate_skills_data(skills_data)
-        return skills_data
+                skill['skill_gid'] = local_skill.skill_gid
 
-    def sync_skills_data(self):
-        """ Update internal skill_data_structure from disk. """
-        self.skills_data = self.load_skills_data()
-        if 'upgraded' in self.skills_data:
-            self.skills_data.pop('upgraded')
+    def _determine_skill_origin(self, skill):
+        if skill.name in self.default_skills:
+            origin = 'default'
+        elif skill.url:
+            origin = 'cli'
         else:
-            self.skills_data_hash = skills_data_hash(self.skills_data)
+            origin = 'non-msm'
+
+        return origin
 
-    def write_skills_data(self, data=None):
-        """ Write skills data hash if it has been modified. """
-        data = data or self.skills_data
-        if skills_data_hash(data) != self.skills_data_hash:
-            write_skills_data(data)
-            self.skills_data_hash = skills_data_hash(data)
+    def write_device_skill_state(self, data=None):
+        """Write device's skill state to disk if it has been modified."""
+        data = data or self.device_skill_state
+        if device_skill_state_hash(data) != self.device_skill_state_hash:
+            write_device_skill_state(data)
+            self.device_skill_state_hash = device_skill_state_hash(data)
 
-    @save_skills_data
+    @save_device_skill_state
     def install(self, param, author=None, constraints=None, origin=''):
         """Install by url or name"""
         if isinstance(param, SkillEntry):
             skill = param
         else:
             skill = self.find_skill(param, author)
-        entry = build_skill_entry(skill.name, origin, skill.is_beta,
-                                  skill.skill_gid)
+        skill_state = initialize_skill_state(
+            skill.name,
+            origin,
+            skill.is_beta,
+            skill.skill_gid
+        )
         try:
             skill.install(constraints)
-            entry['installed'] = time.time()
-            entry['installation'] = 'installed'
-            entry['status'] = 'active'
-            entry['beta'] = skill.is_beta
         except AlreadyInstalled:
-            entry = None
+            log_msg = 'Skill {} already installed - ignoring install request'
+            LOG.info(log_msg.format(skill.name))
+            skill_state = None
             raise
         except MsmException as e:
-            entry['installation'] = 'failed'
-            entry['status'] = 'error'
-            entry['failure_message'] = repr(e)
-            raise
+            LOG.exception('Failed to install skill ' + skill.name)
+            skill_state.update(
+                installation='failed',
+                status='error',
+                failure_message=str(e)
+            )
+        else:
+            skill_state.update(
+                installed=time.time(),
+                installation='installed',
+                status='active',
+                beta=skill.is_beta
+            )
         finally:
             # Store the entry in the list
-            if entry:
-                self.skills_data['skills'].append(entry)
+            if skill_state is not None:
+                self.device_skill_state['skills'].append(skill_state)
+                self._invalidate_skills_cache()
 
-    @save_skills_data
+    @save_device_skill_state
     def remove(self, param, author=None):
         """Remove by url or name"""
         if isinstance(param, SkillEntry):
             skill = param
         else:
             skill = self.find_skill(param, author)
-        skill.remove()
-        skills = [s for s in self.skills_data['skills']
-                  if s['name'] != skill.name]
-        self.skills_data['skills'] = skills
-        return
+        try:
+            skill.remove()
+        except AlreadyRemoved:
+            LOG.info('Skill {} has already been removed'.format(skill.name))
+        except RemoveException:
+            LOG.exception('Failed to remove skill ' + skill.name)
+            raise
+        else:
+            remaining_skills = []
+            for skill_state in self.device_skill_state['skills']:
+                if skill_state['name'] != skill.name:
+                    remaining_skills.append(skill_state)
+            self.device_skill_state['skills'] = remaining_skills
+            self._invalidate_skills_cache()
 
     def update_all(self):
-        local_skills = [skill for skill in self.list() if skill.is_local]
-
         def update_skill(skill):
-            entry = get_skill_entry(skill.name, self.skills_data)
+            entry = get_skill_state(skill.name, self.device_skill_state)
             if entry:
                 entry['beta'] = skill.is_beta
             if skill.update():
+                self._invalidate_skills_cache()
+                self._device_skill_state = None
                 if entry:
                     entry['updated'] = time.time()
 
-        return self.apply(update_skill, local_skills)
+        return self.apply(update_skill, self.local_skills.values())
 
-    @save_skills_data
+    @save_device_skill_state
     def update(self, skill=None, author=None):
         """Update all downloaded skills or one specified skill."""
         if skill is None:
@@ -261,15 +438,16 @@
         else:
             if isinstance(skill, str):
                 skill = self.find_skill(skill, author)
-            entry = get_skill_entry(skill.name, self.skills_data)
-            if entry:
-                entry['beta'] = skill.is_beta
+            skill_state = get_skill_state(skill.name, self.device_skill_state)
+            if skill_state:
+                skill_state['beta'] = skill.is_beta
             if skill.update():
                 # On successful update update the update value
-                if entry:
-                    entry['updated'] = time.time()
+                if skill_state:
+                    skill_state['updated'] = time.time()
+                    self._invalidate_skills_cache()
 
-    @save_skills_data
+    @save_device_skill_state
     def apply(self, func, skills, max_threads=20):
         """Run a function on all skills in parallel"""
 
@@ -290,7 +468,7 @@
         with ThreadPool(max_threads) as tp:
             return tp.map(run_item, skills)
 
-    @save_skills_data
+    @save_device_skill_state
     def install_defaults(self):
         """Installs the default skills, updates all others"""
 
@@ -300,83 +478,40 @@
             else:
                 self.install(skill, origin='default')
 
-        return self.apply(install_or_update_skill, self.list_defaults())
-
-    def list_all_defaults(self):  # type: () -> Dict[str, List[SkillEntry]]
-        """Returns {'skill_group': [SkillEntry('name')]}"""
-        skills = self.list()
-        name_to_skill = {skill.name: skill for skill in skills}
-        defaults = {group: [] for group in self.SKILL_GROUPS}
-
-        for section_name, skill_names in self.repo.get_default_skill_names():
-            section_skills = []
-            for skill_name in skill_names:
-                if skill_name in name_to_skill:
-                    section_skills.append(name_to_skill[skill_name])
-                else:
-                    LOG.warning('No such default skill: ' + skill_name)
-                defaults[section_name] = section_skills
-
-        return defaults
-
-    def list_defaults(self):
-        skill_groups = self.list_all_defaults()
-
-        if self.platform not in skill_groups:
-            LOG.error('Unknown platform:' + self.platform)
-        return skill_groups.get(self.platform,
-                                skill_groups.get('default', []))
+        return self.apply(
+            install_or_update_skill,
+            self.default_skills.values()
+        )
 
-    def list(self):
-        """
-        Load a list of SkillEntry objects from both local and
-        remote skills
+    def _invalidate_skills_cache(self, new_value=None):
+        """Reset the cached skill lists in case something changed.
 
-        It is necessary to load both local and remote skills at
-        the same time to correctly associate local skills with the name
-        in the repo and remote skills with any custom path that they
-        have been downloaded to
+        The cached_property decorator builds a _cache instance attribute
+        storing a dictionary of cached values.  Deleting from this attribute
+        invalidates the cache.
         """
-        try:
-            self.repo.update()
-        except GitException as e:
-            if not isdir(self.repo.path):
-                raise
-            LOG.warning('Failed to update repo: {}'.format(repr(e)))
-        remote_skill_list = (
-            SkillEntry(
-                name, SkillEntry.create_path(self.skills_dir, url, name),
-                url, sha if self.versioned else '', msm=self
-            )
-            for name, path, url, sha in self.repo.get_skill_data()
-        )
-        remote_skills = {
-            skill.id: skill for skill in remote_skill_list
-        }
-        all_skills = []
-        for skill_file in glob(join(self.skills_dir, '*', '__init__.py')):
-            skill = SkillEntry.from_folder(dirname(skill_file), msm=self)
-            if skill.id in remote_skills:
-                skill.attach(remote_skills.pop(skill.id))
-            all_skills.append(skill)
-        all_skills += list(remote_skills.values())
-        return all_skills
+        LOG.info('invalidating skills cache')
+        if hasattr(self, '_cache'):
+            del self._cache['all_skills']
+        self._all_skills = None if new_value is None else new_value
+        self._local_skills = None
+        self._default_skills = None
 
     def find_skill(self, param, author=None, skills=None):
         # type: (str, str, List[SkillEntry]) -> SkillEntry
         """Find skill by name or url"""
         if param.startswith('https://') or param.startswith('http://'):
             repo_id = SkillEntry.extract_repo_id(param)
-            for skill in self.list():
+            for skill in self.all_skills:
                 if skill.id == repo_id:
                     return skill
             name = SkillEntry.extract_repo_name(param)
-            path = SkillEntry.create_path(self.skills_dir, param)
-            return SkillEntry(name, path, param, msm=self)
+            skill_directory = SkillEntry.create_path(self.skills_dir, param)
+            return SkillEntry(name, skill_directory, param, msm=self)
         else:
             skill_confs = {
                 skill: skill.match(param, author)
-                for skill in skills or self.list()
+                for skill in skills or self.all_skills
             }
             best_skill, score = max(skill_confs.items(), key=lambda x: x[1])
             LOG.info('Best match ({}): {} by {}'.format(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mycroft-skills-manager-0.7.8/msm/skill_entry.py 
new/mycroft-skills-manager-0.8.2/msm/skill_entry.py
--- old/mycroft-skills-manager-0.7.8/msm/skill_entry.py 2019-07-09 
16:28:02.000000000 +0200
+++ new/mycroft-skills-manager-0.8.2/msm/skill_entry.py 2019-08-23 
12:31:42.000000000 +0200
@@ -44,7 +44,7 @@
 from msm.exceptions import PipRequirementsException, \
     SystemRequirementsException, AlreadyInstalled, SkillModified, \
     AlreadyRemoved, RemoveException, CloneException, NotInstalled, GitException
-from msm.util import Git
+from msm.util import cached_property, Git
 
 LOG = logging.getLogger(__name__)
 
@@ -54,6 +54,7 @@
 
 # default constraints to use if no are given
 DEFAULT_CONSTRAINTS = '/etc/mycroft/constraints.txt'
+FIVE_MINUTES = 300
 
 
 @contextmanager
@@ -92,6 +93,10 @@
                 shutil.copytree(self.old_path, self.path)
             self.is_local = exists(self.path)
             raise
+        finally:
+            # Remove temporary path if needed
+            if self.old_path and exists(self.old_path):
+                rmtree(self.old_path)
 
     return wrapper
 
@@ -137,10 +142,10 @@
 
     @property
     def is_dirty(self):
-        """ True if different from the version in the mycroft-skills repo.
+        """True if different from the version in the mycroft-skills repo.
 
         Considers a skill dirty if
-        - the checkedout sha doesn't match the mycroft-skills repo
+        - the checkout sha doesn't match the mycroft-skills repo
         - the skill doesn't exist in the mycroft-skills repo
         - the skill is not a git repo
         - has local modifications
@@ -151,7 +156,7 @@
             checkout = Git(self.path)
             mod = checkout.status(porcelain=True, untracked_files='no') != ''
             current_sha = checkout.rev_parse('HEAD')
-        except GitCommandError: # Not a git checkout
+        except GitCommandError:  # Not a git checkout
             return True
 
         skill_shas = {d[0]: d[3] for d in self.msm.repo.get_skill_data()}
@@ -159,9 +164,16 @@
                 current_sha != skill_shas[self.name] or
                 mod)
 
-    @property
+    @cached_property(ttl=FIVE_MINUTES)
     def skill_gid(self):
-        """ Format skill gid for the skill. """
+        """Format skill gid for the skill.
+
+        This property does some Git gymnastics to determine its return value.
+        When a device boots, each skill accesses this property several times.
+        To reduce the amount of boot time, cache the value returned by this
+        property.  Cache expires five minutes after it is generated.
+        """
+        LOG.debug('Generating skill_gid for ' + self.name)
         gid = ''
         if self.is_dirty:
             gid += '@|'
@@ -184,7 +196,19 @@
         return self
 
     @classmethod
-    def from_folder(cls, path, msm=None):
+    def from_folder(cls, path, msm=None, use_cache=True):
+        """Find or create skill entry from folder path.
+
+        Arguments:
+            path:       path of skill folder
+            msm:        msm instance to use for caching and extended 
information
+                        retrieval.
+            use_cache:  Enable/Disable cache usage. defaults to True
+        """
+        if msm and use_cache:
+            skills = {skill.path: skill for skill in msm.local_skills.values()}
+            if path in skills:
+                return skills[path]
         return cls(None, path, cls.find_git_url(path), msm=msm)
 
     @classmethod
@@ -508,6 +532,10 @@
     def find_git_url(path):
         """Get the git url from a folder"""
         try:
+            LOG.debug(
+                'Attempting to retrieve the remote origin URL config for '
+                'skill in path ' + path
+            )
             return Git(path).config('remote.origin.url')
         except GitError:
             return ''
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mycroft-skills-manager-0.7.8/msm/skill_state.py 
new/mycroft-skills-manager-0.8.2/msm/skill_state.py
--- old/mycroft-skills-manager-0.7.8/msm/skill_state.py 1970-01-01 
01:00:00.000000000 +0100
+++ new/mycroft-skills-manager-0.8.2/msm/skill_state.py 2019-08-23 
12:31:42.000000000 +0200
@@ -0,0 +1,65 @@
+"""Functions related to manipulating the skills.json file."""
+import json
+from logging import getLogger
+from os.path import expanduser, isfile
+
+LOG = getLogger(__name__)
+SKILL_STATE_PATH = '~/.mycroft/skills.json'
+
+
+def load_device_skill_state() -> dict:
+    """Contains info on how skills should be updated"""
+    skills_data_path = expanduser(SKILL_STATE_PATH)
+    device_skill_state = {}
+    if isfile(skills_data_path):
+        try:
+            with open(skills_data_path) as skill_state_file:
+                device_skill_state = json.load(skill_state_file)
+        except json.JSONDecodeError:
+            LOG.exception('failed to load skills.json')
+
+    return device_skill_state
+
+
+def write_device_skill_state(data: dict):
+    """Write the device skill state to disk."""
+    skill_state_path = expanduser(SKILL_STATE_PATH)
+    with open(skill_state_path, 'w') as skill_state_file:
+        json.dump(data, skill_state_file, indent=4, separators=(',', ':'))
+
+
+def get_skill_state(name, device_skill_state) -> dict:
+    """Find a skill entry in the device skill state and returns it."""
+    skill_state_return = {}
+    for skill_state in device_skill_state.get('skills', []):
+        if skill_state.get('name') == name:
+            skill_state_return = skill_state
+
+    return skill_state_return
+
+
+def initialize_skill_state(name, origin, beta, skill_gid) -> dict:
+    """Create a new skill entry
+    
+    Arguments:
+        name: skill name
+        origin: the source of the installation
+        beta: Boolean indicating wether the skill is in beta
+        skill_gid: skill global id
+    Returns:
+        populated skills entry
+    """
+    return dict(
+        name=name,
+        origin=origin,
+        beta=beta,
+        status='active',
+        installed=0,
+        updated=0,
+        installation='installed',
+        skill_gid=skill_gid
+    )
+
+
+def device_skill_state_hash(data):
+    return hash(json.dumps(data, sort_keys=True))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mycroft-skills-manager-0.7.8/msm/skills_data.py 
new/mycroft-skills-manager-0.8.2/msm/skills_data.py
--- old/mycroft-skills-manager-0.7.8/msm/skills_data.py 2019-07-09 
16:28:02.000000000 +0200
+++ new/mycroft-skills-manager-0.8.2/msm/skills_data.py 1970-01-01 
01:00:00.000000000 +0100
@@ -1,60 +0,0 @@
-"""
-    Functions related to manipulating the skills_data.json
-"""
-
-import json
-from os.path import expanduser, isfile
-
-
-def load_skills_data() -> dict:
-    """Contains info on how skills should be updated"""
-    skills_data_file = expanduser('~/.mycroft/skills.json')
-    if isfile(skills_data_file):
-        try:
-            with open(skills_data_file) as f:
-                return json.load(f)
-        except json.JSONDecodeError:
-            return {}
-    else:
-        return {}
-
-
-def write_skills_data(data: dict):
-    skills_data_file = expanduser('~/.mycroft/skills.json')
-    with open(skills_data_file, 'w') as f:
-        json.dump(data, f, indent=4, separators=(',', ':'))
-
-
-def get_skill_entry(name, skills_data) -> dict:
-    """ Find a skill entry in the skills_data and returns it. """
-    for e in skills_data.get('skills', []):
-        if e.get('name') == name:
-            return e
-    return {}
-
-
-def build_skill_entry(name, origin, beta, skill_gid) -> dict:
-    """ Create a new skill entry
-    
-    Arguments:
-        name: skill name
-        origin: the source of the installation
-        beta: Boolean indicating wether the skill is in beta
-        skill_gid: skill global id
-    Returns:
-        populated skills entry
-    """
-    return {
-        'name': name,
-        'origin': origin,
-        'beta': beta,
-        'status': 'active',
-        'installed': 0,
-        'updated': 0,
-        'installation': 'installed',
-        'skill_gid': skill_gid
-    }
-
-
-def skills_data_hash(data):
-    return hash(json.dumps(data, sort_keys=True))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mycroft-skills-manager-0.7.8/msm/util.py 
new/mycroft-skills-manager-0.8.2/msm/util.py
--- old/mycroft-skills-manager-0.7.8/msm/util.py        2019-07-09 
16:28:02.000000000 +0200
+++ new/mycroft-skills-manager-0.8.2/msm/util.py        2019-08-23 
12:31:42.000000000 +0200
@@ -19,6 +19,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import time
+
 import git
 from os.path import exists
 from os import chmod
@@ -46,3 +48,65 @@
             lock_file.close()
             chmod(lock_path, 0o777)
         super().__init__(lock_path)
+
+# The cached_property class defined below was copied from the
+# PythonDecoratorLibrary at:
+#   https://wiki.python.org/moin/PythonDecoratorLibrary/#Cached_Properties
+#
+# © 2011 Christopher Arndt, MIT License
+#
+class cached_property(object):
+    """Decorator for read-only properties evaluated only once within TTL 
period.
+
+    It can be used to create a cached property like this::
+
+        import random
+
+        # the class containing the property must be a new-style class
+        class MyClass(object):
+            # create property whose value is cached for ten minutes
+            @cached_property(ttl=600)
+            def randint(self):
+                # will only be evaluated every 10 min. at maximum.
+                return random.randint(0, 100)
+
+    The value is cached  in the '_cache' attribute of the object instance that
+    has the property getter method wrapped by this decorator. The '_cache'
+    attribute value is a dictionary which has a key for every property of the
+    object which is wrapped by this decorator. Each entry in the cache is
+    created only when the property is accessed for the first time and is a
+    two-element tuple with the last computed property value and the last time
+    it was updated in seconds since the epoch.
+
+    The default time-to-live (TTL) is 300 seconds (5 minutes). Set the TTL to
+    zero for the cached value to never expire.
+
+    To expire a cached property value manually just do::
+
+        del instance._cache[<property name>]
+
+    """
+    def __init__(self, ttl=300):
+        self.ttl = ttl
+
+    def __call__(self, fget, doc=None):
+        self.fget = fget
+        self.__doc__ = doc or fget.__doc__
+        self.__name__ = fget.__name__
+        self.__module__ = fget.__module__
+        return self
+
+    def __get__(self, inst, owner):
+        now = time.monotonic()
+        try:
+            value, last_update = inst._cache[self.__name__]
+            if self.ttl > 0 and now - last_update > self.ttl:
+                raise AttributeError
+        except (KeyError, AttributeError):
+            value = self.fget(inst)
+            try:
+                cache = inst._cache
+            except AttributeError:
+                cache = inst._cache = {}
+            cache[self.__name__] = (value, now)
+        return value
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mycroft-skills-manager-0.7.8/setup.py 
new/mycroft-skills-manager-0.8.2/setup.py
--- old/mycroft-skills-manager-0.7.8/setup.py   2019-07-09 16:28:02.000000000 
+0200
+++ new/mycroft-skills-manager-0.8.2/setup.py   2019-08-23 12:31:42.000000000 
+0200
@@ -23,7 +23,7 @@
 
 setup(
     name='msm',
-    version='0.7.8',
+    version='0.8.2',
     packages=['msm'],
     install_requires=[
         'GitPython', 'typing', 'fasteners', 'pyyaml', 'pako',
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mycroft-skills-manager-0.7.8/tests/skills_test.json 
new/mycroft-skills-manager-0.8.2/tests/skills_test.json
--- old/mycroft-skills-manager-0.7.8/tests/skills_test.json     1970-01-01 
01:00:00.000000000 +0100
+++ new/mycroft-skills-manager-0.8.2/tests/skills_test.json     2019-08-23 
12:31:42.000000000 +0200
@@ -0,0 +1,26 @@
+{
+    "blacklist":[],
+    "skills":[
+        {
+            "installed":12345,
+            "origin":"default",
+            "status":"active",
+            "name":"skill-foo",
+            "installation":"installed",
+            "skill_gid":"skill-foo|99.99",
+            "beta":false,
+            "updated":0
+        },
+        {
+            "installed":23456,
+            "origin":"default",
+            "status":"active",
+            "name":"skill-bar",
+            "installation":"installed",
+            "skill_gid":"skill-bar|99.99",
+            "beta":false,
+            "updated":0
+        }
+    ],
+    "version":2
+}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/mycroft-skills-manager-0.7.8/tests/test_mycroft_skills_manager.py 
new/mycroft-skills-manager-0.8.2/tests/test_mycroft_skills_manager.py
--- old/mycroft-skills-manager-0.7.8/tests/test_mycroft_skills_manager.py       
2019-07-09 16:28:02.000000000 +0200
+++ new/mycroft-skills-manager-0.8.2/tests/test_mycroft_skills_manager.py       
2019-08-23 12:31:42.000000000 +0200
@@ -19,70 +19,409 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from shutil import rmtree
+import json
+import os
+import tempfile
+from pathlib import Path
+from shutil import copyfile, rmtree
+from unittest import TestCase
+from unittest.mock import call, Mock, patch
 
 import pytest
-from os.path import join, dirname, exists, abspath
 
 from msm import MycroftSkillsManager, AlreadyInstalled, AlreadyRemoved
-from msm.exceptions import SkillNotFound, MultipleSkillMatches
-from msm.skill_repo import SkillRepo
+from msm.exceptions import SkillNotFound, MultipleSkillMatches, MsmException
+from msm.skill_state import device_skill_state_hash
 
 
-class TestMycroftSkillsManager(object):
-    def setup(self):
-        self.root = root = dirname(abspath(__file__))
+class TestMycroftSkillsManager(TestCase):
+    def setUp(self):
+        temp_dir = tempfile.mkdtemp()
+        self.temp_dir = Path(temp_dir)
+        self.skills_json_path = self.temp_dir.joinpath('skills.json')
+        self.skills_dir = self.temp_dir.joinpath('skills')
+        self._build_fake_skills()
+        self._mock_skills_json_path()
+        self._mock_skill_entry()
+        self._mock_skill_repo()
+        copyfile('skills_test.json', str(self.skills_json_path))
         self.msm = MycroftSkillsManager(
-            platform='default', skills_dir=join(root, 'test-skills'),
-            repo=SkillRepo(
-                join(root, 'repo-instance'), branch='test-repo',
-                url='https://github.com/mycroftai/mycroft-skills-manager'
-            ), versioned=True
+            platform='default',
+            skills_dir=str(self.temp_dir.joinpath('skills')),
+            repo=self.skill_repo_mock,
+            versioned=True
         )
 
+    def _build_fake_skills(self):
+        foo_skill_dir = self.skills_dir.joinpath('skill-foo')
+        foo_skill_dir.mkdir(parents=True)
+        foo_skill_dir.joinpath('__init__.py').touch()
+        bar_skill_dir = self.skills_dir.joinpath('skill-bar')
+        bar_skill_dir.mkdir(parents=True)
+        bar_skill_dir.joinpath('__init__.py').touch()
+
+    def _mock_log(self):
+        log_patch = patch('msm.mycroft_skills_manager.LOG')
+        self.addCleanup(log_patch.stop)
+        self.log_mock = log_patch.start()
+
+    def _mock_skills_json_path(self):
+        expanduser_patch = patch('msm.skill_state.expanduser')
+        self.addCleanup(expanduser_patch.stop)
+        self.skills_json_path_mock = expanduser_patch.start()
+        self.skills_json_path_mock.return_value = str(
+            self.temp_dir.joinpath('skills.json')
+        )
+
+    def _mock_skill_entry(self):
+        skill_entry_patch = patch(
+            'msm.mycroft_skills_manager.SkillEntry.install',
+            spec=True
+        )
+        self.addCleanup(skill_entry_patch.stop)
+        self.skill_entry_mock = skill_entry_patch.start()
+
+    def _mock_skill_repo(self):
+        skill_repo_patch = patch(
+            'msm.mycroft_skills_manager.SkillRepo',
+            spec=True
+
+        )
+        self.addCleanup(skill_repo_patch.stop)
+        self.skill_repo_mock = skill_repo_patch.start()
+        self.skill_repo_mock.skills_meta_info = {
+            'https://skill_foo_url': None
+        }
+
     def teardown(self):
-        if exists(self.msm.skills_dir):
-            rmtree(self.msm.skills_dir)
-        if exists(self.msm.repo.path):
-            rmtree(self.msm.repo.path)
+        rmtree(str(self.temp_dir))
+
+    def test_device_skill_state(self):
+        """Contents of skills.json are loaded into memory"""
+        state = self.msm.device_skill_state
+        initial_state = [
+            dict(
+                name='skill-foo',
+                origin='default',
+                beta=False,
+                status='active',
+                installed=12345,
+                updated=0,
+                installation='installed',
+                skill_gid='@|skill-foo'
+            ),
+            dict(
+                name='skill-bar',
+                origin='default',
+                beta=False,
+                status='active',
+                installed=23456,
+                updated=0,
+                installation='installed',
+                skill_gid='@|skill-bar'
+            )
+        ]
+
+        self.assertListEqual(initial_state, state['skills'])
+        self.assertListEqual([], state['blacklist'])
+        self.assertEqual(2, state['version'])
+
+        new_hash = device_skill_state_hash(self.msm.device_skill_state)
+        self.assertEqual(new_hash, self.msm.device_skill_state_hash)
+
+    def test_build_device_skill_state(self):
+        """No skill.json file so build one."""
+        os.remove(str(self.skills_json_path))
+        self.msm._device_skill_state = None
+        self.msm._init_skills_data()
+        state = self.msm.device_skill_state
+
+        initial_state = [
+            dict(
+                name='skill-bar',
+                origin='non-msm',
+                beta=False,
+                status='active',
+                installed=0,
+                updated=0,
+                installation='installed',
+                skill_gid='@|skill-bar'
+            ),
+            dict(
+                name='skill-foo',
+                origin='non-msm',
+                beta=False,
+                status='active',
+                installed=0,
+                updated=0,
+                installation='installed',
+                skill_gid='@|skill-foo'
+            )
+        ]
+
+        self.assertTrue(self.skills_json_path.exists())
+        with open(self.skills_json_path) as skills_json:
+            device_skill_state = json.load(skills_json)
+        self.assertListEqual(initial_state, state['skills'])
+        self.assertListEqual(initial_state, device_skill_state['skills'])
+        self.assertListEqual([], state['blacklist'])
+        self.assertListEqual([], device_skill_state['blacklist'])
+        self.assertEqual(2, state['version'])
+        self.assertEqual(2, device_skill_state['version'])
+        new_hash = device_skill_state_hash(self.msm.device_skill_state)
+        self.assertEqual(new_hash, self.msm.device_skill_state_hash)
+
+    def test_remove_from_device_skill_state(self):
+        """Remove a file no longer installed from the device's skill state.
+
+        Delete skill-bar from the local skills.  This should trigger it being
+        removed from the device skill state.
+        """
+
+        del(self.msm.local_skills['skill-bar'])
+        self.msm._device_skill_state = None
+        state = self.msm.device_skill_state
+
+        initial_state = [
+            dict(
+                name='skill-foo',
+                origin='default',
+                beta=False,
+                status='active',
+                installed=12345,
+                updated=0,
+                installation='installed',
+                skill_gid='@|skill-foo'
+            )
+        ]
+
+        self.assertListEqual(initial_state, state['skills'])
+        self.assertListEqual([], state['blacklist'])
+        self.assertEqual(2, state['version'])
+
+    def test_skill_list(self):
+        """The skill.list() method is called."""
+        all_skills = self.msm.list()
+
+        skill_names = [skill.name for skill in all_skills]
+        self.assertIn('skill-foo', skill_names)
+        self.assertIn('skill-bar', skill_names)
+        self.assertEqual(2, len(all_skills))
+        self.assertIsNone(self.msm._local_skills)
+        self.assertIsNone(self.msm._default_skills)
+        self.assertEqual(all_skills, self.msm._all_skills)
 
     def test_install(self):
-        """Install by url or name"""
-        self.msm.install('skill-a')
-        with pytest.raises(AlreadyInstalled):
-            self.msm.install('skill-a')
-        self.msm.install('skill-b')
+        """Install a skill
+
+        Test that the install method was called on the skill being installed
+        and that the new skill was added to the device's skill state.
+        """
+        skill_to_install = self.skill_entry_mock()
+        skill_to_install.name = 'skill-test'
+        skill_to_install.skill_gid = 'test-skill|99.99'
+        skill_to_install.is_beta = False
+        with patch('msm.mycroft_skills_manager.isinstance') as isinstance_mock:
+            isinstance_mock.return_value = True
+            with patch('msm.mycroft_skills_manager.time') as time_mock:
+                time_mock.time.return_value = 100
+                self.msm.install(skill_to_install, origin='voice')
+
+        with open(self.skills_json_path) as skills_json:
+            device_skill_state = json.load(skills_json)
+
+        skill_test_state = dict(
+            name='skill-test',
+            origin='voice',
+            beta=False,
+            status='active',
+            installed=100,
+            updated=0,
+            installation='installed',
+            skill_gid='test-skill|99.99'
+        )
+        self.assertIn(skill_test_state, device_skill_state['skills'])
+        self.assertListEqual(
+            [call.install(None)],
+            skill_to_install.method_calls
+        )
+
+    def test_already_installed(self):
+        """Attempt install of skill already on the device.
+
+        When this happens, an AlreadyInstalled exception is raised and the
+        device skill state is not modified.
+        """
+        skill_to_install = self.skill_entry_mock()
+        skill_to_install.name = 'skill-foo'
+        skill_to_install.skill_gid = 'skill-foo|99.99'
+        skill_to_install.is_beta = False
+        skill_to_install.install = Mock(side_effect=AlreadyInstalled())
+        pre_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        with patch('msm.mycroft_skills_manager.isinstance') as isinstance_mock:
+            isinstance_mock.return_value = True
+            with self.assertRaises(AlreadyInstalled):
+                self.msm.install(skill_to_install)
+
+        self.assertIsNotNone(self.msm._local_skills)
+        self.assertIn('all_skills', self.msm._cache)
+        post_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        self.assertEqual(pre_install_hash, post_install_hash)
+
+    def test_install_failure(self):
+        """Install attempt fails for whatever reason
+
+        When an install fails, the installation will raise a MsmException.  The
+        skill install will be saved to the device skill state as failed and
+        the error that caused the exception will be included in the state.
+        """
+        skill_to_install = self.skill_entry_mock()
+        skill_to_install.name = 'skill-test'
+        skill_to_install.skill_gid = 'skill-test|99.99'
+        skill_to_install.is_beta = False
+        skill_to_install.install = Mock(side_effect=MsmException('RED ALERT!'))
+        with patch('msm.mycroft_skills_manager.isinstance') as isinstance_mock:
+            isinstance_mock.return_value = True
+            self.msm.install(skill_to_install, origin='cli')
+
+        with open(self.skills_json_path) as skills_json:
+            device_skill_state = json.load(skills_json)
+
+        skill_test_state = dict(
+            name='skill-test',
+            origin='cli',
+            beta=False,
+            status='error',
+            installed=0,
+            updated=0,
+            installation='failed',
+            skill_gid='skill-test|99.99',
+            failure_message='RED ALERT!'
+        )
+        self.assertIn(skill_test_state, self.msm.device_skill_state['skills'])
+        self.assertIn(skill_test_state, device_skill_state['skills'])
+        self.assertListEqual(
+            [call.install(None)],
+            skill_to_install.method_calls
+        )
 
     def test_remove(self):
-        """Remove by url or name"""
-        with pytest.raises(AlreadyRemoved):
-            self.msm.remove('skill-a')
-        self.msm.install('skill-a')
-        self.msm.remove('skill-a')
+        """Remove a skill
+
+        Test that the remove method was called on the skill being installed
+        and that the new skill was removed from the device's skill state.
+        """
+        skill_to_remove = self.skill_entry_mock()
+        skill_to_remove.name = 'skill-foo'
+        pre_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        with patch('msm.mycroft_skills_manager.isinstance') as isinstance_mock:
+            isinstance_mock.return_value = True
+            self.msm.remove(skill_to_remove)
+
+        with open(self.skills_json_path) as skills_json:
+            device_skill_state = json.load(skills_json)
+
+        skill_names = [skill['name'] for skill in device_skill_state['skills']]
+        self.assertNotIn('skill_foo', skill_names)
+        skill_names = [
+            skill['name'] for skill in self.msm.device_skill_state['skills']
+        ]
+        self.assertNotIn('skill_foo', skill_names)
+        self.assertListEqual([call.remove()], skill_to_remove.method_calls)
+        self.assertNotIn('all_skills', self.msm._cache)
+        self.assertIsNone(self.msm._local_skills)
+        post_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        self.assertNotEqual(pre_install_hash, post_install_hash)
+
+    def test_already_removed(self):
+        """Attempt removal of skill already removed from the device.
+
+        When this happens, an AlreadyRemoved exception is raised and the
+        device skill state is not modified.
+        """
+        skill_to_remove = self.skill_entry_mock()
+        skill_to_remove.name = 'skill-foo'
+        skill_to_remove.remove = Mock(side_effect=AlreadyRemoved())
+        pre_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        with patch('msm.mycroft_skills_manager.isinstance') as isinstance_mock:
+            isinstance_mock.return_value = True
+            self.msm.remove(skill_to_remove)
+
+        self.assertListEqual([call.remove()], skill_to_remove.method_calls)
+        self.assertIsNotNone(self.msm._local_skills)
+        self.assertIn('all_skills', self.msm._cache)
+        post_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        self.assertEqual(pre_install_hash, post_install_hash)
+
+    def test_remove_failure(self):
+        """Skill removal attempt fails for whatever reason
+
+        When n removal fails, a MsmException is raised.  The removal will not
+        be saved to the device skill state.
+        """
+        skill_to_remove = self.skill_entry_mock()
+        skill_to_remove.name = 'skill-test'
+        skill_to_remove.remove = Mock(side_effect=MsmException('RED ALERT!'))
+        pre_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        with patch('msm.mycroft_skills_manager.isinstance') as isinstance_mock:
+            isinstance_mock.return_value = True
+            with self.assertRaises(MsmException):
+                self.msm.remove(skill_to_remove)
+
+        self.assertListEqual(
+            [call.remove()],
+            skill_to_remove.method_calls
+        )
+        self.assertIsNotNone(self.msm._local_skills)
+        self.assertIn('all_skills', self.msm._cache)
+        post_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        self.assertEqual(pre_install_hash, post_install_hash)
 
     def test_update(self):
-        """Update all downloaded skills"""
-        self.msm.install('skill-a')
-        self.msm.update()
-
-    def test_install_defaults(self):
-        """Installs the default skills, updates all others"""
-        assert not self.msm.find_skill('skill-a').is_local
-        self.msm.install_defaults()
-        assert self.msm.find_skill('skill-a').is_local
-        assert not self.msm.find_skill('skill-b').is_local
-        self.msm.platform = 'platform-1'
-        self.msm.install_defaults()
-        assert self.msm.find_skill('skill-b').is_local
-
-    def test_list(self):
-        all_skills = {
-            'skill-a', 'skill-b', 'skill-cd', 'skill-ce'
-        }
-        assert {i.name for i in self.msm.list()} == all_skills
+        """Remove a skill
 
-    def test_find_skill(self):
-        with pytest.raises(MultipleSkillMatches):
-            self.msm.find_skill('skill-c')
-        with pytest.raises(SkillNotFound):
-            self.msm.find_skill('jsafpcq')
+        Test that the remove method was called on the skill being installed
+        and that the new skill was removed from the device's skill state.
+        """
+        skill_to_update = self.skill_entry_mock()
+        skill_to_update.name = 'skill-foo'
+        skill_to_update.is_beta = False
+        pre_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        with patch('msm.mycroft_skills_manager.time') as time_mock:
+            time_mock.time.return_value = 100
+            self.msm.update(skill_to_update)
+
+        with open(self.skills_json_path) as skills_json:
+            device_skill_state = json.load(skills_json)
+
+        skill_names = [skill['name'] for skill in device_skill_state['skills']]
+        self.assertIn('skill-foo', skill_names)
+
+        for skill in self.msm.device_skill_state['skills']:
+            if skill['name'] == 'skill-foo':
+                self.assertEqual(100, skill['updated'])
+        self.assertListEqual([call.update()], skill_to_update.method_calls)
+        self.assertNotIn('all_skills', self.msm._cache)
+        self.assertIsNone(self.msm._local_skills)
+        post_install_hash = device_skill_state_hash(
+            self.msm.device_skill_state
+        )
+        self.assertNotEqual(pre_install_hash, post_install_hash)


Reply via email to