settings.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 """
16  SkillSettings is a simple extension of a Python dict which enables
17  simplified storage of settings. Values stored into the dict will
18  automatically persist locally. Additionally, it can interact with
19  a backend system to provide a GUI interface for some or all of the
20  settings.
21 
22  The GUI for the setting is described by a file in the skill's root
23  directory called settingsmeta.json (or settingsmeta.yaml, if you
24  prefer working with yaml). The "name" associates the user-interface
25  field with the setting name in the dictionary. For example, you
26  might have a setting['username']. In the settingsmeta you can
27  describe the interface you want to edit that value with:
28  ...
29  "fields": [
30  {
31  "name": "username",
32  "type": "email",
33  "label": "Email address to associate",
34  "placeholder": "example@mail.com",
35  "value": ""
36  }]
37  ...
38  When the user changes the setting via the web UI, it will be sent
39  down to all the devices and automatically placed into the
40  settings['username']. Any local changes made to the value (e.g.
41  via a verbal interaction) will also be synched to the server to show
42  on the web interface.
43 
44  NOTE: As it stands today, this functions seamlessly with a single
45  device. With multiple devices there are a few hitches that are being
46  worked out. The first device where a skill is installed creates the
47  setting and values are sent down to any other devices that install the
48  same skill. However only the original device can make changes locally
49  for synching to the web. This limitation is temporary and will be
50  removed soon.
51 
52 
53  Usage Example:
54  from mycroft.skill.settings import SkillSettings
55 
56  s = SkillSettings('./settings.json', 'ImportantSettings')
57  s['meaning of life'] = 42
58  s['flower pot sayings'] = 'Not again...'
59  s.store() # This happens automagically in a MycroftSkill
60 """
61 
62 import json
63 import hashlib
64 import os
65 import yaml
66 import time
67 import copy
68 import re
69 from threading import Timer
70 from os.path import isfile, join, expanduser
71 from requests.exceptions import RequestException, HTTPError
72 from msm import SkillEntry
73 
74 from mycroft.api import DeviceApi, is_paired
75 from mycroft.util.log import LOG
76 from mycroft.util import camel_case_split
77 from mycroft.configuration import ConfigurationManager
78 
79 from .msm_wrapper import create_msm
80 
81 
82 msm = None
83 msm_creation_time = 0
84 
85 
86 def build_global_id(directory, config):
87  """ Create global id for the skill.
88 
89  TODO: Handle dirty skill
90 
91  Arguments:
92  directory: skill directory
93  config: config for the device to fetch msm setup
94  """
95  # Update the msm object if it's more than an hour old
96  global msm
97  global msm_creation_time
98  if msm is None or time.time() - msm_creation_time > 60 * 60:
99  msm_creation_time = time.time()
100  msm = create_msm(config)
101 
102  s = SkillEntry.from_folder(directory, msm)
103  # If modified prepend the device uuid
104  return s.skill_gid, s.meta_info.get('display_name')
105 
106 
107 def display_name(name):
108  """ Splits camelcase and removes leading/trailing Skill. """
109  name = re.sub(r'(^[Ss]kill|[Ss]kill$)', '', name)
110  return camel_case_split(name)
111 
112 
113 class DelayRequest(Exception):
114  """ Indicate that the next request should be delayed. """
115  pass
116 
117 
118 class SkillSettings(dict):
119  """ Dictionary that can easily be saved to a file, serialized as json. It
120  also syncs to the backend for skill settings
121 
122  Args:
123  directory (str): Path to storage directory
124  name (str): user readable name associated with the settings
125  no_upload (bool): True if the upload to mycroft servers should be
126  disabled.
127  """
128 
129  def __init__(self, directory, name):
130  super(SkillSettings, self).__init__()
131  # when skills try to instantiate settings
132  # in __init__, it can erase the settings saved
133  # on disk (settings.json). So this prevents that
134  # This is set to true in core.py after skill init
135  self.allow_overwrite = False
136 
137  self.api = DeviceApi()
138  self.config = ConfigurationManager.get()
139  self.name = name
140  # set file paths
141  self._settings_path = join(directory, 'settings.json')
142  self._meta_path = _get_meta_path(directory)
143  self._directory = directory
144 
145  self.is_alive = True
146  self.loaded_hash = hash(json.dumps(self, sort_keys=True))
148  self._device_identity = None
149  self._api_path = None
150  self._user_identity = None
151  self.changed_callback = None
152  self._poll_timer = None
153  self._blank_poll_timer = None
154  self._is_alive = True
155  self._meta_upload = True # Flag allowing upload of settings meta
156 
157  # Add Information extracted from the skills-meta.json entry for the
158  # skill.
159  skill_gid, disp_name = build_global_id(self._directory, self.config)
160  self.__skill_gid = skill_gid
161  self.display_name = disp_name
162 
163  # if settingsmeta exist
164  if self._meta_path:
165  self._poll_skill_settings()
166  # if not disallowed by user upload an entry for all skills installed
167  elif self.config['skills']['upload_skill_manifest']:
168  self._blank_poll_timer = Timer(1, self._init_blank_meta)
169  self._blank_poll_timer.daemon = True
170  self._blank_poll_timer.start()
171 
172  @property
173  def skill_gid(self):
174  """ Finalizes the skill gid to include device uuid if needed. """
175  if is_paired():
176  return self.__skill_gid.replace('@|', '@{}|'.format(
177  DeviceApi().identity.uuid))
178  else:
179  return self.__skill_gid
180 
181  def __hash__(self):
182  """ Simple object unique hash. """
183  return hash(str(id(self)) + self.name)
184 
185  def run_poll(self, _=None):
186  """Immediately poll the web for new skill settings"""
187  if self._poll_timer:
188  self._poll_timer.cancel()
189  self._poll_skill_settings()
190 
191  def stop_polling(self):
192  self._is_alive = False
193  if self._poll_timer:
194  self._poll_timer.cancel()
195  if self._blank_poll_timer:
196  self._blank_poll_timer.cancel()
197 
198  def set_changed_callback(self, callback):
199  """ Set callback to perform when server settings have changed.
200 
201  Args:
202  callback: function/method to call when settings have changed
203  """
204  self.changed_callback = callback
205 
206  # TODO: break this up into two classes
208  """ initializes the remote settings to the server """
209  # if the settingsmeta file exists (and is valid)
210  # this block of code is a control flow for
211  # different scenarios that may arises with settingsmeta
212  self.load_skill_settings_from_file() # loads existing settings.json
213  settings_meta = self._load_settings_meta()
214  if not settings_meta:
215  return
216 
217  if not is_paired():
218  return
219 
220  self._device_identity = self.api.identity.uuid
221  self._api_path = "/" + self._device_identity + "/skill"
222  try:
223  self._user_identity = self.api.get()['user']['uuid']
224  except RequestException:
225  return
226 
227  settings = self._request_my_settings(self.skill_gid)
228  if settings:
229  self.save_skill_settings(settings)
230 
231  # TODO if this skill_gid is not a modified version check if a modified
232  # version exists on the server and delete it
233 
234  # Always try to upload settingsmeta on startup
235  self._upload_meta(settings_meta, self.skill_gid)
236 
237  self._complete_intialization = True
238 
239  @property
240  def _is_stored(self):
241  return hash(json.dumps(self, sort_keys=True)) == self.loaded_hash
242 
243  def __getitem__(self, key):
244  """ Get key """
245  return super(SkillSettings, self).__getitem__(key)
246 
247  def __setitem__(self, key, value):
248  """ Add/Update key. """
249  if self.allow_overwrite or key not in self:
250  return super(SkillSettings, self).__setitem__(key, value)
251 
253  """ Load settings metadata from the skill folder.
254 
255  If no settingsmeta exists a basic settingsmeta will be created
256  containing a basic identifier.
257 
258  Returns:
259  (dict) settings meta
260  """
261  if self._meta_path and os.path.isfile(self._meta_path):
262  _, ext = os.path.splitext(self._meta_path)
263  json_file = True if ext.lower() == ".json" else False
264 
265  try:
266  with open(self._meta_path, encoding='utf-8') as f:
267  if json_file:
268  data = json.load(f)
269  else:
270  data = yaml.safe_load(f)
271  except Exception as e:
272  LOG.error("Failed to load setting file: " + self._meta_path)
273  LOG.error(repr(e))
274  data = {}
275  else:
276  data = {}
277 
278  # Insert skill_gid and display_name
279  data['skill_gid'] = self.skill_gid
280  data['display_name'] = (self.display_name or data.get('name') or
281  display_name(self.name))
282 
283  # Backwards compatibility:
284  if 'name' not in data:
285  data['name'] = data['display_name']
286 
287  return data
288 
289  def _send_settings_meta(self, settings_meta):
290  """ Send settingsmeta to the server.
291 
292  Args:
293  settings_meta (dict): dictionary of the current settings meta
294  Returns:
295  dict: uuid, a unique id for the setting meta data
296  """
297  if self._meta_upload:
298  try:
299  uuid = self.api.upload_skill_metadata(
300  self._type_cast(settings_meta, to_platform='web'))
301  return uuid
302  except HTTPError as e:
303  if e.response.status_code in [422, 500, 501]:
304  self._meta_upload = False
305  raise DelayRequest
306  else:
307  LOG.error(e)
308  return None
309 
310  except Exception as e:
311  LOG.error(e)
312  return None
313 
314  def save_skill_settings(self, skill_settings):
315  """ Takes skill object and save onto self
316 
317  Args:
318  skill_settings (dict): skill
319  """
320  if 'skillMetadata' in skill_settings:
321  sections = skill_settings['skillMetadata']['sections']
322  for section in sections:
323  for field in section["fields"]:
324  if "name" in field and "value" in field:
325  # Bypass the change lock to allow server to update
326  # during skill init
327  super(SkillSettings, self).__setitem__(field['name'],
328  field['value'])
329  self.store()
330 
331  def _migrate_settings(self, settings_meta):
332  """ sync settings.json and settingsmeta in memory """
333  meta = settings_meta.copy()
334  if 'skillMetadata' not in meta:
335  return meta
337  sections = meta['skillMetadata']['sections']
338  for i, section in enumerate(sections):
339  for j, field in enumerate(section['fields']):
340  if 'name' in field:
341  if field["name"] in self:
342  sections[i]['fields'][j]['value'] = \
343  str(self.__getitem__(field['name']))
344  meta['skillMetadata']['sections'] = sections
345  return meta
346 
347  def _upload_meta(self, settings_meta, identifier):
348  """ uploads the new meta data to settings with settings migration
349 
350  Args:
351  settings_meta (dict): settingsmeta.json or settingsmeta.yaml
352  identifier (str): identifier for skills meta data
353  """
354  LOG.debug('Uploading settings meta for {}'.format(identifier))
355  meta = self._migrate_settings(settings_meta)
356  meta['identifier'] = identifier
357  response = self._send_settings_meta(meta)
358 
359  def hash(self, string):
360  """ md5 hasher for consistency across cpu architectures """
361  return hashlib.md5(bytes(string, 'utf-8')).hexdigest()
362 
363  def update_remote(self):
364  """ update settings state from server """
365  settings_meta = self._load_settings_meta()
366  if settings_meta is None:
367  return
368  # Get settings
369  skills_settings = self._request_my_settings(self.skill_gid)
370 
371  if skills_settings is not None:
372  self.save_skill_settings(skills_settings)
373  else:
374  LOG.debug("No Settings on server for {}".format(self.skill_gid))
375  # Settings meta doesn't exist on server push them
376  settings_meta = self._load_settings_meta()
377  self._upload_meta(settings_meta, self.skill_gid)
378 
379  def _init_blank_meta(self):
380  """ Send blank settingsmeta to remote. """
381  try:
382  if not is_paired() and self.is_alive:
383  self._blank_poll_timer = Timer(60, self._init_blank_meta)
384  self._blank_poll_timer.daemon = True
385  self._blank_poll_timer.start()
386  else:
388  except DelayRequest:
389  # Delay 5 minutes and retry
390  self._blank_poll_timer = Timer(60 * 5,
391  self._init_blank_meta)
392  self._blank_poll_timer.daemon = True
393  self._blank_poll_timer.start()
394  except Exception as e:
395  LOG.exception('Failed to send blank meta: {}'.format(repr(e)))
396 
398  """ If identifier exists for this skill poll to backend to
399  request settings and store it if it changes
400  TODO: implement as websocket
401  """
402  delay = 1
403  original = hash(str(self))
404  try:
405  if not is_paired():
406  pass
407  elif not self._complete_intialization:
409  else:
410  self.update_remote()
411  except DelayRequest:
412  LOG.info('{}: Delaying next settings fetch'.format(self.name))
413  delay = 5
414  except Exception as e:
415  LOG.exception('Failed to fetch skill settings: {}'.format(repr(e)))
416  finally:
417  # Call callback for updated settings
418  if self._complete_intialization:
419  if self.changed_callback and hash(str(self)) != original:
420  self.changed_callback()
421 
422  if self._poll_timer:
423  self._poll_timer.cancel()
424 
425  if not self._is_alive:
426  return
427 
428  # continues to poll settings every minute
429  self._poll_timer = Timer(delay * 60, self._poll_skill_settings)
430  self._poll_timer.daemon = True
431  self._poll_timer.start()
432 
434  """ If settings.json exist, open and read stored values into self """
435  if isfile(self._settings_path):
436  with open(self._settings_path) as f:
437  try:
438  json_data = json.load(f)
439  for key in json_data:
440  self[key] = json_data[key]
441  except Exception as e:
442  # TODO: Show error on webUI. Dev will have to fix
443  # metadata to be able to edit later.
444  LOG.error(e)
445 
446  def _type_cast(self, settings_meta, to_platform):
447  """ Tranform data type to be compatible with Home and/or Core.
448 
449  e.g.
450  Web to core
451  "true" => True, "1.4" => 1.4
452 
453  core to Web
454  False => "false'
455 
456  Args:
457  settings_meta (dict): skills object
458  to_platform (str): platform to convert
459  compatible data types to
460  Returns:
461  dict: skills object
462  """
463  meta = settings_meta.copy()
464  if 'skillMetadata' not in settings_meta:
465  return meta
466 
467  sections = meta['skillMetadata']['sections']
468 
469  for i, section in enumerate(sections):
470  for j, field in enumerate(section.get('fields', [])):
471  _type = field.get('type')
472  if _type == 'checkbox':
473  value = field.get('value')
474 
475  if to_platform == 'web':
476  if value is True or value == 'True':
477  sections[i]['fields'][j]['value'] = 'true'
478  elif value is False or value == 'False':
479  sections[i]['fields'][j]['value'] = 'false'
480 
481  elif to_platform == 'core':
482  if value == 'true' or value == 'True':
483  sections[i]['fields'][j]['value'] = True
484  elif value == 'false' or value == 'False':
485  sections[i]['fields'][j]['value'] = False
486 
487  elif _type == 'number':
488  value = field.get('value')
489 
490  if to_platform == 'core':
491  if "." in value:
492  sections[i]['fields'][j]['value'] = float(value)
493  else:
494  sections[i]['fields'][j]['value'] = int(value)
495 
496  elif to_platform == 'web':
497  sections[i]['fields'][j]['value'] = str(value)
498 
499  meta['skillMetadata']['sections'] = sections
500  return meta
501 
502  def _request_my_settings(self, identifier):
503  """ Get skill settings for this device associated
504  with the identifier
505  Args:
506  identifier (str): a hashed_meta
507  Returns:
508  skill_settings (dict or None): returns a dict if matches
509  """
510  settings = self._request_settings()
511  if settings:
512  # this loads the settings into memory for use in self.store
513  for skill_settings in settings:
514  if skill_settings['identifier'] == identifier:
515  LOG.debug("Fetched settings for {}".format(identifier))
516  skill_settings = \
517  self._type_cast(skill_settings, to_platform='core')
518  self._remote_settings = skill_settings
519  return skill_settings
520  return None
521 
522  def _request_settings(self):
523  """ Get all skill settings for this device from server.
524 
525  Returns:
526  dict: dictionary with settings collected from the server.
527  """
528  try:
529  settings = self.api.get_skill_settings()
530  except RequestException:
531  return None
532 
533  settings = [skills for skills in settings if skills is not None]
534  return settings
535 
536  @property
538  changed = False
539  if (hasattr(self, '_remote_settings') and
540  'skillMetadata' in self._remote_settings):
541  sections = self._remote_settings['skillMetadata']['sections']
542  for i, section in enumerate(sections):
543  for j, field in enumerate(section['fields']):
544  if 'name' in field:
545  # Ensure that the field exists in settings and that
546  # it has a value to compare
547  if (field["name"] in self and
548  'value' in sections[i]['fields'][j]):
549  remote_val = sections[i]['fields'][j]["value"]
550  self_val = self.get(field['name'])
551  if str(remote_val) != str(self_val):
552  changed = True
553  if self.get('not_owner'):
554  changed = False
555  return changed
556 
557  def store(self, force=False):
558  """ Store dictionary to file if a change has occured.
559 
560  Args:
561  force: Force write despite no change
562  """
563  if force or not self._is_stored:
564  with open(self._settings_path, 'w') as f:
565  json.dump(self, f)
566  self.loaded_hash = hash(json.dumps(self, sort_keys=True))
567 
569  settings_meta = self._load_settings_meta()
570  self._upload_meta(settings_meta, self.skill_gid)
571 
572 
573 def _get_meta_path(base_directory):
574  json_path = join(base_directory, 'settingsmeta.json')
575  yaml_path = join(base_directory, 'settingsmeta.yaml')
576  if isfile(json_path):
577  return json_path
578  if isfile(yaml_path):
579  return yaml_path
580  return None
def _migrate_settings(self, settings_meta)
Definition: settings.py:331
def save_skill_settings(self, skill_settings)
Definition: settings.py:314
def _send_settings_meta(self, settings_meta)
Definition: settings.py:289
def store(self, force=False)
Definition: settings.py:557
def _request_my_settings(self, identifier)
Definition: settings.py:502
def is_paired(ignore_errors=True)
def _get_meta_path(base_directory)
Definition: settings.py:573
def display_name(name)
Definition: settings.py:107
def __setitem__(self, key, value)
Definition: settings.py:247
def build_global_id(directory, config)
Definition: settings.py:86
def set_changed_callback(self, callback)
Definition: settings.py:198
def _upload_meta(self, settings_meta, identifier)
Definition: settings.py:347
def _type_cast(self, settings_meta, to_platform)
Definition: settings.py:446
def __init__(self, directory, name)
Definition: settings.py:129


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