skill_manager.py
Go to the documentation of this file.
1 # Copyright 2017 Mycroft AI Inc.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14 #
15 import gc
16 import json
17 import sys
18 import time
19 from glob import glob
20 from itertools import chain
21 
22 import os
23 from os.path import exists, join, basename, dirname, expanduser, isfile
24 from threading import Thread, Event, Lock
25 
26 from msm import MycroftSkillsManager, SkillRepo, MsmException
27 from mycroft import dialog
28 from mycroft.enclosure.api import EnclosureAPI
29 from mycroft.configuration import Configuration
30 from mycroft.messagebus.message import Message
31 from mycroft.util import connected
32 from mycroft.util.log import LOG
33 from mycroft.api import DeviceApi, is_paired
34 
35 from .core import load_skill, create_skill_descriptor, MainModule
36 from .msm_wrapper import create_msm as msm_creator
37 
38 DEBUG = Configuration.get().get("debug", False)
39 skills_config = Configuration.get().get("skills")
40 BLACKLISTED_SKILLS = skills_config.get("blacklisted_skills", [])
41 PRIORITY_SKILLS = skills_config.get("priority_skills", [])
42 
43 installer_config = Configuration.get().get("SkillInstallerSkill")
44 
45 MINUTES = 60 # number of seconds in a minute (syntatic sugar)
46 
47 
48 def ignored_file(f):
49  """ Checks if the file is valid file to require a reload. """
50  return (f.endswith('.pyc') or
51  f == 'settings.json' or
52  f.startswith('.') or
53  f.endswith('.qmlc'))
54 
55 
57  """
58  Get last modified date excluding compiled python files, hidden
59  directories and the settings.json file.
60 
61  Args:
62  path: skill directory to check
63 
64  Returns:
65  int: time of last change
66  """
67  all_files = []
68  for root_dir, dirs, files in os.walk(path):
69  dirs[:] = [d for d in dirs if not d.startswith('.')]
70  for f in files:
71  if not ignored_file(f):
72  all_files.append(join(root_dir, f))
73  # check files of interest in the skill root directory
74  return max(os.path.getmtime(f) for f in all_files)
75 
76 
77 MSM_LOCK = None
78 
79 
80 class SkillManager(Thread):
81  """ Load, update and manage instances of Skill on this system.
82 
83  Arguments:
84  bus (eventemitter): Mycroft messagebus connection
85  """
86 
87  def __init__(self, bus):
88  super(SkillManager, self).__init__()
89  self._stop_event = Event()
90  self._connected_event = Event()
91 
92  self.loaded_skills = {}
93  self.bus = bus
94  self.enclosure = EnclosureAPI(bus)
95 
96  # Schedule install/update of default skill
97  self.msm = self.create_msm()
98  self.thread_lock = self.get_lock()
100 
101  self.update_interval = Configuration.get()['skills']['update_interval']
102  self.update_interval = int(self.update_interval * 60 * MINUTES)
103  self.dot_msm = join(self.msm.skills_dir, '.msm')
104  # Update immediately if the .msm or installed skills file is missing
105  # otherwise according to timestamp on .msm
106  if exists(self.dot_msm) and exists(self.installed_skills_file):
107  self.next_download = os.path.getmtime(self.dot_msm) + \
108  self.update_interval
109  else:
110  self.next_download = time.time() - 1
111 
112  # Conversation management
113  bus.on('skill.converse.request', self.handle_converse_request)
114 
115  # Update on initial connection
116  bus.on('mycroft.internet.connected',
117  lambda x: self._connected_event.set())
118 
119  # Update upon request
120  bus.on('skillmanager.update', self.schedule_now)
121  bus.on('skillmanager.list', self.send_skill_list)
122  bus.on('skillmanager.deactivate', self.deactivate_skill)
123  bus.on('skillmanager.keep', self.deactivate_except)
124  bus.on('skillmanager.activate', self.activate_skill)
125 
126  @staticmethod
127  def get_lock():
128  global MSM_LOCK
129  if MSM_LOCK is None:
130  MSM_LOCK = Lock()
131  return MSM_LOCK
132 
133  @staticmethod
134  def create_msm():
135  return msm_creator(Configuration.get())
136 
137  def schedule_now(self, message=None):
138  self.next_download = time.time() - 1
139 
140  @staticmethod
141  @property
143  return Configuration.get()['skills'].get('upload_skill_manifest')
144 
145  @property
147  venv = dirname(dirname(sys.executable))
148  if os.access(venv, os.W_OK | os.R_OK | os.X_OK):
149  return join(venv, '.mycroft-skills')
150  return expanduser('~/.mycroft/.mycroft-skills')
151 
152  def load_installed_skills(self) -> set:
153  skills_file = self.installed_skills_file
154  if not isfile(skills_file):
155  return set()
156  with open(skills_file) as f:
157  return {
158  i.strip() for i in f.read().split('\n') if i.strip()
159  }
160 
161  def save_installed_skills(self, skill_names):
162  with open(self.installed_skills_file, 'w') as f:
163  f.write('\n'.join(skill_names))
164 
165  def download_skills(self, speak=False):
166  """ Invoke MSM to install default skills and/or update installed skills
167 
168  Args:
169  speak (bool, optional): Speak the result? Defaults to False
170  """
171  if not connected():
172  LOG.error('msm failed, network connection not available')
173  if speak:
174  self.bus.emit(Message("speak", {
175  'utterance': dialog.get(
176  "not connected to the internet")}))
177  self.next_download = time.time() + 5 * MINUTES
178  return False
179 
180  installed_skills = self.load_installed_skills()
181  msm = SkillManager.create_msm()
182  with msm.lock, self.thread_lock:
183  default_groups = dict(msm.repo.get_default_skill_names())
184  if msm.platform in default_groups:
185  platform_groups = default_groups[msm.platform]
186  else:
187  LOG.info('Platform defaults not found, using DEFAULT '
188  'skills only')
189  platform_groups = []
190  default_names = set(chain(default_groups['default'],
191  platform_groups))
192  default_skill_errored = False
193 
194  def get_skill_data(skill_name):
195  """ Get skill data structure from name. """
196  for e in msm.skills_data.get('skills', []):
197  if e.get('name') == skill_name:
198  return e
199  # if skill isn't in the list return empty structure
200  return {}
201 
202  def install_or_update(skill):
203  """Install missing defaults and update existing skills"""
204  if get_skill_data(skill.name).get('beta'):
205  skill.sha = None # Will update to latest head
206  if skill.is_local:
207  skill.update()
208  if skill.name not in installed_skills:
209  skill.update_deps()
210  elif skill.name in default_names:
211  try:
212  msm.install(skill, origin='default')
213  except Exception:
214  if skill.name in default_names:
215  LOG.warning('Failed to install default skill: ' +
216  skill.name)
217  nonlocal default_skill_errored
218  default_skill_errored = True
219  raise
220  installed_skills.add(skill.name)
221  try:
222  msm.apply(install_or_update, msm.list())
223  if SkillManager.manifest_upload_allowed and is_paired():
224  try:
225  DeviceApi().upload_skills_data(msm.skills_data)
226  except Exception:
227  LOG.exception('Could not upload skill manifest')
228 
229  except MsmException as e:
230  LOG.error('Failed to update skills: {}'.format(repr(e)))
231 
232  self.save_installed_skills(installed_skills)
233 
234  if speak:
235  data = {'utterance': dialog.get("skills updated")}
236  self.bus.emit(Message("speak", data))
237 
238  # Schedule retry in 5 minutes on failure, after 10 shorter periods
239  # Go back to 60 minutes wait
240  if default_skill_errored and self.num_install_retries < 10:
241  self.num_install_retries += 1
242  self.next_download = time.time() + 5 * MINUTES
243  return False
244  self.num_install_retries = 0
245 
246  # Update timestamp on .msm file to be used when system is restarted
247  with open(self.dot_msm, 'a'):
248  os.utime(self.dot_msm, None)
249  self.next_download = time.time() + self.update_interval
250 
251  return True
252 
253  def _unload_removed(self, paths):
254  """ Shutdown removed skills.
255 
256  Arguments:
257  paths: list of current directories in the skills folder
258  """
259  paths = [p.rstrip('/') for p in paths]
260  skills = self.loaded_skills
261  # Find loaded skills that doesn't exist on disk
262  removed_skills = [str(s) for s in skills.keys() if str(s) not in paths]
263  for s in removed_skills:
264  # HACK: Ensure MycroftSkill's created from ROS nodes aren't removed
265  if skills[s].get("is_ros_node", False):
266  return
267  LOG.info('removing {}'.format(s))
268  try:
269  LOG.debug('Removing: {}'.format(skills[s]))
270  skills[s]['instance'].default_shutdown()
271  except Exception as e:
272  LOG.exception(e)
273  self.loaded_skills.pop(s)
274 
275  def _load_or_reload_skill(self, skill_path):
276  """
277  Check if unloaded skill or changed skill needs reloading
278  and perform loading if necessary.
279 
280  Returns True if the skill was loaded/reloaded
281  """
282  skill_path = skill_path.rstrip('/')
283  skill = self.loaded_skills.setdefault(skill_path, {})
284  skill.update({"id": basename(skill_path), "path": skill_path})
285 
286  # check if folder is a skill (must have __init__.py)
287  if not MainModule + ".py" in os.listdir(skill_path):
288  return False
289 
290  # getting the newest modified date of skill
291  modified = _get_last_modified_date(skill_path)
292  last_mod = skill.get("last_modified", 0)
293 
294  # checking if skill is loaded and hasn't been modified on disk
295  if skill.get("loaded") and modified <= last_mod:
296  return False # Nothing to do!
297 
298  # check if skill was modified
299  elif skill.get("instance") and modified > last_mod:
300  # check if skill has been blocked from reloading
301  if (not skill["instance"].reload_skill or
302  not skill.get('active', True)):
303  return False
304 
305  LOG.debug("Reloading Skill: " + basename(skill_path))
306  # removing listeners and stopping threads
307  try:
308  skill["instance"].default_shutdown()
309  except Exception:
310  LOG.exception("An error occured while shutting down {}"
311  .format(skill["instance"].name))
312 
313  if DEBUG:
314  gc.collect() # Collect garbage to remove false references
315  # Remove two local references that are known
316  refs = sys.getrefcount(skill["instance"]) - 2
317  if refs > 0:
318  msg = ("After shutdown of {} there are still "
319  "{} references remaining. The skill "
320  "won't be cleaned from memory.")
321  LOG.warning(msg.format(skill['instance'].name, refs))
322  del skill["instance"]
323  self.bus.emit(Message("mycroft.skills.shutdown",
324  {"path": skill_path,
325  "id": skill["id"]}))
326 
327  skill["loaded"] = True
328  desc = create_skill_descriptor(skill_path)
329  skill["instance"] = load_skill(desc,
330  self.bus, skill["id"],
331  BLACKLISTED_SKILLS)
332 
333  skill["last_modified"] = modified
334  if skill['instance'] is not None:
335  self.bus.emit(Message('mycroft.skills.loaded',
336  {'path': skill_path,
337  'id': skill['id'],
338  'name': skill['instance'].name,
339  'modified': modified}))
340  return True
341  else:
342  self.bus.emit(Message('mycroft.skills.loading_failure',
343  {'path': skill_path,
344  'id': skill['id']}))
345  return False
346 
347  def load_priority(self):
348  skills = {skill.name: skill for skill in self.msm.list()}
349  for skill_name in PRIORITY_SKILLS:
350  skill = skills.get(skill_name)
351  if skill:
352  if not skill.is_local:
353  try:
354  skill.install()
355  except Exception:
356  LOG.exception('Downloading priority skill: '
357  '{} failed'.format(skill.name))
358  if not skill.is_local:
359  continue
360  self._load_or_reload_skill(skill.path)
361  else:
362  LOG.error('Priority skill {} can\'t be found')
363 
364  def remove_git_locks(self):
365  """If git gets killed from an abrupt shutdown it leaves lock files"""
366  for i in glob(join(self.msm.skills_dir, '*/.git/index.lock')):
367  LOG.warning('Found and removed git lock file: ' + i)
368  os.remove(i)
369 
370  def run(self):
371  """ Load skills and update periodically from disk and internet """
372 
373  self.remove_git_locks()
374  self._connected_event.wait()
375  has_loaded = False
376 
377  # check if skill updates are enabled
378  update = Configuration.get()["skills"]["auto_update"]
379 
380  # Scan the file folder that contains Skills. If a Skill is updated,
381  # unload the existing version from memory and reload from the disk.
382  while not self._stop_event.is_set():
383  # Update skills once an hour if update is enabled
384  if time.time() >= self.next_download and update:
385  self.download_skills()
386 
387  # Look for recently changed skill(s) needing a reload
388  # checking skills dir and getting all skills there
389  skill_paths = glob(join(self.msm.skills_dir, '*/'))
390  still_loading = False
391  for skill_path in skill_paths:
392  try:
393  still_loading = (
394  self._load_or_reload_skill(skill_path) or
395  still_loading
396  )
397  except Exception as e:
398  LOG.error('(Re)loading of {} failed ({})'.format(
399  skill_path, repr(e)))
400  if not has_loaded and not still_loading and len(skill_paths) > 0:
401  has_loaded = True
402  LOG.info("Skills all loaded!")
403  self.bus.emit(Message('mycroft.skills.initialized'))
404 
405  self._unload_removed(skill_paths)
406  # Pause briefly before beginning next scan
407  time.sleep(2)
408 
409  def send_skill_list(self, message=None):
410  """
411  Send list of loaded skills.
412  """
413  try:
414  info = {}
415  for s in self.loaded_skills:
416  is_active = (self.loaded_skills[s].get('active', True) and
417  self.loaded_skills[s].get('instance') is not None)
418  info[basename(s)] = {
419  'active': is_active,
420  'id': self.loaded_skills[s]['id']
421  }
422  self.bus.emit(Message('mycroft.skills.list', data=info))
423  except Exception as e:
424  LOG.exception(e)
425 
426  def __deactivate_skill(self, skill):
427  """ Deactivate a skill. """
428  for s in self.loaded_skills:
429  if skill in s:
430  skill = s
431  break
432  try:
433  self.loaded_skills[skill]['active'] = False
434  self.loaded_skills[skill]['instance'].default_shutdown()
435  except Exception as e:
436  LOG.error('Couldn\'t deactivate skill, {}'.format(repr(e)))
437 
438  def deactivate_skill(self, message):
439  """ Deactivate a skill. """
440  try:
441  skill = message.data['skill']
442  if skill in [basename(s) for s in self.loaded_skills]:
443  self.__deactivate_skill(skill)
444  except Exception as e:
445  LOG.error('Couldn\'t deactivate skill, {}'.format(repr(e)))
446 
447  def deactivate_except(self, message):
448  """ Deactivate all skills except the provided. """
449  try:
450  skill_to_keep = message.data['skill']
451  LOG.info('DEACTIVATING ALL SKILLS EXCEPT {}'.format(skill_to_keep))
452  if skill_to_keep in [basename(i) for i in self.loaded_skills]:
453  for skill in self.loaded_skills:
454  if basename(skill) != skill_to_keep:
455  self.__deactivate_skill(skill)
456  else:
457  LOG.info('Couldn\'t find skill')
458  except Exception as e:
459  LOG.error('Error during skill removal, {}'.format(repr(e)))
460 
461  def __activate_skill(self, skill):
462  if not self.loaded_skills[skill].get('active', True):
463  self.loaded_skills[skill]['loaded'] = False
464  self.loaded_skills[skill]['active'] = True
465 
466  def activate_skill(self, message):
467  """ Activate a deactivated skill. """
468  try:
469  skill = message.data['skill']
470  if skill == 'all':
471  for s in self.loaded_skills:
472  self.__activate_skill(s)
473  else:
474  for s in self.loaded_skills:
475  if skill in s:
476  skill = s
477  break
478  self.__activate_skill(skill)
479  except Exception as e:
480  LOG.error('Couldn\'t activate skill, {}'.format(repr(e)))
481 
482  def stop(self):
483  """ Tell the manager to shutdown """
484  self._stop_event.set()
485 
486  # Do a clean shutdown of all skills
487  for name, skill_info in self.loaded_skills.items():
488  instance = skill_info.get('instance')
489  if instance:
490  try:
491  instance.default_shutdown()
492  except Exception:
493  LOG.exception('Shutting down skill: ' + name)
494 
495  def handle_converse_request(self, message):
496  """ Check if the targeted skill id can handle conversation
497 
498  If supported, the conversation is invoked.
499  """
500 
501  skill_id = message.data["skill_id"]
502  utterances = message.data["utterances"]
503  lang = message.data["lang"]
504 
505  # loop trough skills list and call converse for skill with skill_id
506  for skill in self.loaded_skills:
507  if self.loaded_skills[skill]["id"] == skill_id:
508  instance = self.loaded_skills[skill].get("instance")
509  if instance is None:
510  self.bus.emit(message.reply("skill.converse.error",
511  {"skill_id": skill_id,
512  "error": "converse requested"
513  " but skill not "
514  "loaded"}))
515  return
516  try:
517  result = instance.converse(utterances, lang)
518  self.bus.emit(message.reply("skill.converse.response", {
519  "skill_id": skill_id, "result": result}))
520  return
521  except BaseException:
522  self.bus.emit(message.reply("skill.converse.error",
523  {"skill_id": skill_id,
524  "error": "exception in "
525  "converse method"}))
526  return
527 
528  self.bus.emit(message.reply("skill.converse.error",
529  {"skill_id": skill_id,
530  "error": "skill id does not exist"}))
def is_paired(ignore_errors=True)
def load_skill(skill_descriptor, bus, skill_id, BLACKLISTED_SKILLS=None)
Definition: core.py:106
def create_skill_descriptor(skill_path)
Definition: core.py:173
def get(phrase, lang=None, context=None)
def save_installed_skills(self, skill_names)


mycroft_ros
Author(s):
autogenerated on Mon Apr 26 2021 02:35:40