1# Copyright 2011-2019, Damian Johnson and The Tor Project
2# See LICENSE for licensing information
3
4"""
5Handlers for text configuration files. Configurations are simple string to
6string mappings, with the configuration files using the following rules...
7
8* the key/value is separated by a space
9* anything after a '#' is ignored as a comment
10* excess whitespace is trimmed
11* empty lines are ignored
12* multi-line values can be defined by following the key with lines starting
13  with a '|'
14
15For instance...
16
17::
18
19  # This is my sample config
20  user.name Galen
21  user.password yabba1234 # here's an inline comment
22  user.notes takes a fancy to pepperjack cheese
23  blankEntry.example
24
25  msg.greeting
26  |Multi-line message exclaiming of the
27  |wonder and awe that is pepperjack!
28
29... would be loaded as...
30
31::
32
33  config = {
34    'user.name': 'Galen',
35    'user.password': 'yabba1234',
36    'user.notes': 'takes a fancy to pepperjack cheese',
37    'blankEntry.example': '',
38    'msg.greeting': 'Multi-line message exclaiming of the\\nwonder and awe that is pepperjack!',
39  }
40
41Configurations are managed via the :class:`~stem.util.conf.Config` class. The
42:class:`~stem.util.conf.Config` can be be used directly with its
43:func:`~stem.util.conf.Config.get` and :func:`~stem.util.conf.Config.set`
44methods, but usually modules will want a local dictionary with just the
45configurations that it cares about.
46
47To do this use the :func:`~stem.util.conf.config_dict` function. For example...
48
49::
50
51  import getpass
52  from stem.util import conf, connection
53
54  def config_validator(key, value):
55    if key == 'timeout':
56      # require at least a one second timeout
57      return max(1, value)
58    elif key == 'endpoint':
59      if not connection.is_valid_ipv4_address(value):
60        raise ValueError("'%s' isn't a valid IPv4 address" % value)
61    elif key == 'port':
62      if not connection.is_valid_port(value):
63        raise ValueError("'%s' isn't a valid port" % value)
64    elif key == 'retries':
65      # negative retries really don't make sense
66      return max(0, value)
67
68  CONFIG = conf.config_dict('ssh_login', {
69    'username': getpass.getuser(),
70    'password': '',
71    'timeout': 10,
72    'endpoint': '263.12.8.0',
73    'port': 22,
74    'reconnect': False,
75    'retries': 3,
76  }, config_validator)
77
78There's several things going on here so lets take it step by step...
79
80* The :func:`~stem.util.conf.config_dict` provides a dictionary that's bound
81  to a given configuration. If the "ssh_proxy_config" configuration changes
82  then so will the contents of CONFIG.
83
84* The dictionary we're passing to :func:`~stem.util.conf.config_dict` provides
85  two important pieces of information: default values and their types. See the
86  Config's :func:`~stem.util.conf.Config.get` method for how these type
87  inferences work.
88
89* The config_validator is a hook we're adding to make sure CONFIG only gets
90  values we think are valid. In this case it ensures that our timeout value
91  is at least one second, and rejects endpoints or ports that are invalid.
92
93Now lets say our user has the following configuration file...
94
95::
96
97  username waddle_doo
98  password jabberwocky
99  timeout -15
100  port 9000000
101  retries lots
102  reconnect true
103  logging debug
104
105... and we load it as follows...
106
107::
108
109  >>> from stem.util import conf
110  >>> our_config = conf.get_config('ssh_login')
111  >>> our_config.load('/home/atagar/user_config')
112  >>> print CONFIG  # doctest: +SKIP
113  {
114    "username": "waddle_doo",
115    "password": "jabberwocky",
116    "timeout": 1,
117    "endpoint": "263.12.8.0",
118    "port": 22,
119    "reconnect": True,
120    "retries": 3,
121  }
122
123Here's an expanation of what happened...
124
125* the username, password, and reconnect attributes took the values in the
126  configuration file
127
128* the 'config_validator' we added earlier allows for a minimum timeout of one
129  and rejected the invalid port (with a log message)
130
131* we weren't able to convert the retries' "lots" value to an integer so it kept
132  its default value and logged a warning
133
134* the user didn't supply an endpoint so that remained unchanged
135
136* our CONFIG didn't have a 'logging' attribute so it was ignored
137
138**Module Overview:**
139
140::
141
142  config_dict - provides a dictionary that's kept in sync with our config
143  get_config - singleton for getting configurations
144  uses_settings - provides an annotation for functions that use configurations
145  parse_enum_csv - helper funcion for parsing confguration entries for enums
146
147  Config - Custom configuration
148    |- load - reads a configuration file
149    |- save - writes the current configuration to a file
150    |- clear - empties our loaded configuration contents
151    |- add_listener - notifies the given listener when an update occurs
152    |- clear_listeners - removes any attached listeners
153    |- keys - provides keys in the loaded configuration
154    |- set - sets the given key/value pair
155    |- unused_keys - provides keys that have never been requested
156    |- get - provides the value for a given key, with type inference
157    +- get_value - provides the value for a given key as a string
158"""
159
160import inspect
161import os
162import threading
163
164import stem.prereq
165
166from stem.util import log
167
168try:
169  # added in python 2.7
170  from collections import OrderedDict
171except ImportError:
172  from stem.util.ordereddict import OrderedDict
173
174CONFS = {}  # mapping of identifier to singleton instances of configs
175
176
177class _SyncListener(object):
178  def __init__(self, config_dict, interceptor):
179    self.config_dict = config_dict
180    self.interceptor = interceptor
181
182  def update(self, config, key):
183    if key in self.config_dict:
184      new_value = config.get(key, self.config_dict[key])
185
186      if new_value == self.config_dict[key]:
187        return  # no change
188
189      if self.interceptor:
190        interceptor_value = self.interceptor(key, new_value)
191
192        if interceptor_value:
193          new_value = interceptor_value
194
195      self.config_dict[key] = new_value
196
197
198def config_dict(handle, conf_mappings, handler = None):
199  """
200  Makes a dictionary that stays synchronized with a configuration.
201
202  This takes a dictionary of 'config_key => default_value' mappings and
203  changes the values to reflect our current configuration. This will leave
204  the previous values alone if...
205
206  * we don't have a value for that config_key
207  * we can't convert our value to be the same type as the default_value
208
209  If a handler is provided then this is called just prior to assigning new
210  values to the config_dict. The handler function is expected to accept the
211  (key, value) for the new values and return what we should actually insert
212  into the dictionary. If this returns None then the value is updated as
213  normal.
214
215  For more information about how we convert types see our
216  :func:`~stem.util.conf.Config.get` method.
217
218  **The dictionary you get from this is manged by the Config class and should
219  be treated as being read-only.**
220
221  :param str handle: unique identifier for a config instance
222  :param dict conf_mappings: config key/value mappings used as our defaults
223  :param functor handler: function referred to prior to assigning values
224  """
225
226  selected_config = get_config(handle)
227  selected_config.add_listener(_SyncListener(conf_mappings, handler).update)
228  return conf_mappings
229
230
231def get_config(handle):
232  """
233  Singleton constructor for configuration file instances. If a configuration
234  already exists for the handle then it's returned. Otherwise a fresh instance
235  is constructed.
236
237  :param str handle: unique identifier used to access this config instance
238  """
239
240  if handle not in CONFS:
241    CONFS[handle] = Config()
242
243  return CONFS[handle]
244
245
246def uses_settings(handle, path, lazy_load = True):
247  """
248  Provides a function that can be used as a decorator for other functions that
249  require settings to be loaded. Functions with this decorator will be provided
250  with the configuration as its 'config' keyword argument.
251
252  .. versionchanged:: 1.3.0
253     Omits the 'config' argument if the funcion we're decorating doesn't accept
254     it.
255
256  ::
257
258    uses_settings = stem.util.conf.uses_settings('my_app', '/path/to/settings.cfg')
259
260    @uses_settings
261    def my_function(config):
262      print 'hello %s!' % config.get('username', '')
263
264  :param str handle: hande for the configuration
265  :param str path: path where the configuration should be loaded from
266  :param bool lazy_load: loads the configuration file when the decorator is
267    used if true, otherwise it's loaded right away
268
269  :returns: **function** that can be used as a decorator to provide the
270    configuration
271
272  :raises: **IOError** if we fail to read the configuration file, if
273    **lazy_load** is true then this arises when we use the decorator
274  """
275
276  config = get_config(handle)
277
278  if not lazy_load and not config._settings_loaded:
279    config.load(path)
280    config._settings_loaded = True
281
282  def decorator(func):
283    def wrapped(*args, **kwargs):
284      if lazy_load and not config._settings_loaded:
285        config.load(path)
286        config._settings_loaded = True
287
288      if 'config' in inspect.getargspec(func).args:
289        return func(*args, config = config, **kwargs)
290      else:
291        return func(*args, **kwargs)
292
293    return wrapped
294
295  return decorator
296
297
298def parse_enum(key, value, enumeration):
299  """
300  Provides the enumeration value for a given key. This is a case insensitive
301  lookup and raises an exception if the enum key doesn't exist.
302
303  :param str key: configuration key being looked up
304  :param str value: value to be parsed
305  :param stem.util.enum.Enum enumeration: enumeration the values should be in
306
307  :returns: enumeration value
308
309  :raises: **ValueError** if the **value** isn't among the enumeration keys
310  """
311
312  return parse_enum_csv(key, value, enumeration, 1)[0]
313
314
315def parse_enum_csv(key, value, enumeration, count = None):
316  """
317  Parses a given value as being a comma separated listing of enumeration keys,
318  returning the corresponding enumeration values. This is intended to be a
319  helper for config handlers. The checks this does are case insensitive.
320
321  The **count** attribute can be used to make assertions based on the number of
322  values. This can be...
323
324  * None to indicate that there's no restrictions.
325  * An int to indicate that we should have this many values.
326  * An (int, int) tuple to indicate the range that values can be in. This range
327    is inclusive and either can be None to indicate the lack of a lower or
328    upper bound.
329
330  :param str key: configuration key being looked up
331  :param str value: value to be parsed
332  :param stem.util.enum.Enum enumeration: enumeration the values should be in
333  :param int,tuple count: validates that we have this many items
334
335  :returns: list with the enumeration values
336
337  :raises: **ValueError** if the count assertion fails or the **value** entries
338    don't match the enumeration keys
339  """
340
341  values = [val.upper().strip() for val in value.split(',')]
342
343  if values == ['']:
344    return []
345
346  if count is None:
347    pass  # no count validateion checks to do
348  elif isinstance(count, int):
349    if len(values) != count:
350      raise ValueError("Config entry '%s' is expected to be %i comma separated values, got '%s'" % (key, count, value))
351  elif isinstance(count, tuple) and len(count) == 2:
352    minimum, maximum = count
353
354    if minimum is not None and len(values) < minimum:
355      raise ValueError("Config entry '%s' must have at least %i comma separated values, got '%s'" % (key, minimum, value))
356
357    if maximum is not None and len(values) > maximum:
358      raise ValueError("Config entry '%s' can have at most %i comma separated values, got '%s'" % (key, maximum, value))
359  else:
360    raise ValueError("The count must be None, an int, or two value tuple. Got '%s' (%s)'" % (count, type(count)))
361
362  result = []
363  enum_keys = [k.upper() for k in list(enumeration.keys())]
364  enum_values = list(enumeration)
365
366  for val in values:
367    if val in enum_keys:
368      result.append(enum_values[enum_keys.index(val)])
369    else:
370      raise ValueError("The '%s' entry of config entry '%s' wasn't in the enumeration (expected %s)" % (val, key, ', '.join(enum_keys)))
371
372  return result
373
374
375class Config(object):
376  """
377  Handler for easily working with custom configurations, providing persistence
378  to and from files. All operations are thread safe.
379
380  **Example usage:**
381
382  User has a file at '/home/atagar/myConfig' with...
383
384  ::
385
386    destination.ip 1.2.3.4
387    destination.port blarg
388
389    startup.run export PATH=$PATH:~/bin
390    startup.run alias l=ls
391
392  And they have a script with...
393
394  ::
395
396    from stem.util import conf
397
398    # Configuration values we'll use in this file. These are mappings of
399    # configuration keys to the default values we'll use if the user doesn't
400    # have something different in their config file (or it doesn't match this
401    # type).
402
403    ssh_config = conf.config_dict('ssh_login', {
404      'login.user': 'atagar',
405      'login.password': 'pepperjack_is_awesome!',
406      'destination.ip': '127.0.0.1',
407      'destination.port': 22,
408      'startup.run': [],
409    })
410
411    # Makes an empty config instance with the handle of 'ssh_login'. This is
412    # a singleton so other classes can fetch this same configuration from
413    # this handle.
414
415    user_config = conf.get_config('ssh_login')
416
417    # Loads the user's configuration file, warning if this fails.
418
419    try:
420      user_config.load("/home/atagar/myConfig")
421    except IOError as exc:
422      print "Unable to load the user's config: %s" % exc
423
424    # This replace the contents of ssh_config with the values from the user's
425    # config file if...
426    #
427    # * the key is present in the config file
428    # * we're able to convert the configuration file's value to the same type
429    #   as what's in the mapping (see the Config.get() method for how these
430    #   type inferences work)
431    #
432    # For instance in this case...
433    #
434    # * the login values are left alone because they aren't in the user's
435    #   config file
436    #
437    # * the 'destination.port' is also left with the value of 22 because we
438    #   can't turn "blarg" into an integer
439    #
440    # The other values are replaced, so ssh_config now becomes...
441    #
442    # {'login.user': 'atagar',
443    #  'login.password': 'pepperjack_is_awesome!',
444    #  'destination.ip': '1.2.3.4',
445    #  'destination.port': 22,
446    #  'startup.run': ['export PATH=$PATH:~/bin', 'alias l=ls']}
447    #
448    # Information for what values fail to load and why are reported to
449    # 'stem.util.log'.
450
451    .. versionchanged:: 1.7.0
452       Class can now be used as a dictionary.
453  """
454
455  def __init__(self):
456    self._path = None        # location we last loaded from or saved to
457    self._contents = OrderedDict()  # configuration key/value pairs
458    self._listeners = []     # functors to be notified of config changes
459
460    # used for accessing _contents
461    self._contents_lock = threading.RLock()
462
463    # keys that have been requested (used to provide unused config contents)
464    self._requested_keys = set()
465
466    # flag to support lazy loading in uses_settings()
467    self._settings_loaded = False
468
469  def load(self, path = None, commenting = True):
470    """
471    Reads in the contents of the given path, adding its configuration values
472    to our current contents. If the path is a directory then this loads each
473    of the files, recursively.
474
475    .. versionchanged:: 1.3.0
476       Added support for directories.
477
478    .. versionchanged:: 1.3.0
479       Added the **commenting** argument.
480
481    .. versionchanged:: 1.6.0
482       Avoid loading vim swap files.
483
484    :param str path: file or directory path to be loaded, this uses the last
485      loaded path if not provided
486    :param bool commenting: ignore line content after a '#' if **True**, read
487      otherwise
488
489    :raises:
490      * **IOError** if we fail to read the file (it doesn't exist, insufficient
491        permissions, etc)
492      * **ValueError** if no path was provided and we've never been provided one
493    """
494
495    if path:
496      self._path = path
497    elif not self._path:
498      raise ValueError('Unable to load configuration: no path provided')
499
500    if os.path.isdir(self._path):
501      for root, dirnames, filenames in os.walk(self._path):
502        for filename in filenames:
503          if filename.endswith('.swp'):
504            continue  # vim swap file
505
506          self.load(os.path.join(root, filename))
507
508      return
509
510    with open(self._path, 'r') as config_file:
511      read_contents = config_file.readlines()
512
513    with self._contents_lock:
514      while read_contents:
515        line = read_contents.pop(0)
516
517        # strips any commenting or excess whitespace
518        comment_start = line.find('#') if commenting else -1
519
520        if comment_start != -1:
521          line = line[:comment_start]
522
523        line = line.strip()
524
525        # parse the key/value pair
526        if line:
527          if ' ' in line:
528            key, value = line.split(' ', 1)
529            self.set(key, value.strip(), False)
530          else:
531            # this might be a multi-line entry, try processing it as such
532            multiline_buffer = []
533
534            while read_contents and read_contents[0].lstrip().startswith('|'):
535              content = read_contents.pop(0).lstrip()[1:]  # removes '\s+|' prefix
536              content = content.rstrip('\n')  # trailing newline
537              multiline_buffer.append(content)
538
539            if multiline_buffer:
540              self.set(line, '\n'.join(multiline_buffer), False)
541            else:
542              self.set(line, '', False)  # default to a key => '' mapping
543
544  def save(self, path = None):
545    """
546    Saves configuration contents to disk. If a path is provided then it
547    replaces the configuration location that we track.
548
549    :param str path: location to be saved to
550
551    :raises:
552      * **IOError** if we fail to save the file (insufficient permissions, etc)
553      * **ValueError** if no path was provided and we've never been provided one
554    """
555
556    if path:
557      self._path = path
558    elif not self._path:
559      raise ValueError('Unable to save configuration: no path provided')
560
561    with self._contents_lock:
562      if not os.path.exists(os.path.dirname(self._path)):
563        os.makedirs(os.path.dirname(self._path))
564
565      with open(self._path, 'w') as output_file:
566        for entry_key in self.keys():
567          for entry_value in self.get_value(entry_key, multiple = True):
568            # check for multi line entries
569            if '\n' in entry_value:
570              entry_value = '\n|' + entry_value.replace('\n', '\n|')
571
572            output_file.write('%s %s\n' % (entry_key, entry_value))
573
574  def clear(self):
575    """
576    Drops the configuration contents and reverts back to a blank, unloaded
577    state.
578    """
579
580    with self._contents_lock:
581      self._contents.clear()
582      self._requested_keys = set()
583
584  def add_listener(self, listener, backfill = True):
585    """
586    Registers the function to be notified of configuration updates. Listeners
587    are expected to be functors which accept (config, key).
588
589    :param functor listener: function to be notified when our configuration is changed
590    :param bool backfill: calls the function with our current values if **True**
591    """
592
593    with self._contents_lock:
594      self._listeners.append(listener)
595
596      if backfill:
597        for key in self.keys():
598          listener(self, key)
599
600  def clear_listeners(self):
601    """
602    Removes all attached listeners.
603    """
604
605    self._listeners = []
606
607  def keys(self):
608    """
609    Provides all keys in the currently loaded configuration.
610
611    :returns: **list** if strings for the configuration keys we've loaded
612    """
613
614    return list(self._contents.keys())
615
616  def unused_keys(self):
617    """
618    Provides the configuration keys that have never been provided to a caller
619    via :func:`~stem.util.conf.config_dict` or the
620    :func:`~stem.util.conf.Config.get` and
621    :func:`~stem.util.conf.Config.get_value` methods.
622
623    :returns: **set** of configuration keys we've loaded but have never been requested
624    """
625
626    return set(self.keys()).difference(self._requested_keys)
627
628  def set(self, key, value, overwrite = True):
629    """
630    Appends the given key/value configuration mapping, behaving the same as if
631    we'd loaded this from a configuration file.
632
633    .. versionchanged:: 1.5.0
634       Allow removal of values by overwriting with a **None** value.
635
636    :param str key: key for the configuration mapping
637    :param str,list value: value we're setting the mapping to
638    :param bool overwrite: replaces the previous value if **True**, otherwise
639      the values are appended
640    """
641
642    with self._contents_lock:
643      unicode_type = str if stem.prereq.is_python_3() else unicode
644
645      if value is None:
646        if overwrite and key in self._contents:
647          del self._contents[key]
648        else:
649          pass  # no value so this is a no-op
650      elif isinstance(value, (bytes, unicode_type)):
651        if not overwrite and key in self._contents:
652          self._contents[key].append(value)
653        else:
654          self._contents[key] = [value]
655
656        for listener in self._listeners:
657          listener(self, key)
658      elif isinstance(value, (list, tuple)):
659        if not overwrite and key in self._contents:
660          self._contents[key] += value
661        else:
662          self._contents[key] = value
663
664        for listener in self._listeners:
665          listener(self, key)
666      else:
667        raise ValueError("Config.set() only accepts str (bytes or unicode), list, or tuple. Provided value was a '%s'" % type(value))
668
669  def get(self, key, default = None):
670    """
671    Fetches the given configuration, using the key and default value to
672    determine the type it should be. Recognized inferences are:
673
674    * **default is a boolean => boolean**
675
676      * values are case insensitive
677      * provides the default if the value isn't "true" or "false"
678
679    * **default is an integer => int**
680
681      * provides the default if the value can't be converted to an int
682
683    * **default is a float => float**
684
685      * provides the default if the value can't be converted to a float
686
687    * **default is a list => list**
688
689      * string contents for all configuration values with this key
690
691    * **default is a tuple => tuple**
692
693      * string contents for all configuration values with this key
694
695    * **default is a dictionary => dict**
696
697      * values without "=>" in them are ignored
698      * values are split into key/value pairs on "=>" with extra whitespace
699        stripped
700
701    :param str key: config setting to be fetched
702    :param default object: value provided if no such key exists or fails to be converted
703
704    :returns: given configuration value with its type inferred with the above rules
705    """
706
707    is_multivalue = isinstance(default, (list, tuple, dict))
708    val = self.get_value(key, default, is_multivalue)
709
710    if val == default:
711      return val  # don't try to infer undefined values
712
713    if isinstance(default, bool):
714      if val.lower() == 'true':
715        val = True
716      elif val.lower() == 'false':
717        val = False
718      else:
719        log.debug("Config entry '%s' is expected to be a boolean, defaulting to '%s'" % (key, str(default)))
720        val = default
721    elif isinstance(default, int):
722      try:
723        val = int(val)
724      except ValueError:
725        log.debug("Config entry '%s' is expected to be an integer, defaulting to '%i'" % (key, default))
726        val = default
727    elif isinstance(default, float):
728      try:
729        val = float(val)
730      except ValueError:
731        log.debug("Config entry '%s' is expected to be a float, defaulting to '%f'" % (key, default))
732        val = default
733    elif isinstance(default, list):
734      val = list(val)  # make a shallow copy
735    elif isinstance(default, tuple):
736      val = tuple(val)
737    elif isinstance(default, dict):
738      val_map = OrderedDict()
739      for entry in val:
740        if '=>' in entry:
741          entry_key, entry_val = entry.split('=>', 1)
742          val_map[entry_key.strip()] = entry_val.strip()
743        else:
744          log.debug('Ignoring invalid %s config entry (expected a mapping, but "%s" was missing "=>")' % (key, entry))
745      val = val_map
746
747    return val
748
749  def get_value(self, key, default = None, multiple = False):
750    """
751    This provides the current value associated with a given key.
752
753    :param str key: config setting to be fetched
754    :param object default: value provided if no such key exists
755    :param bool multiple: provides back a list of all values if **True**,
756      otherwise this returns the last loaded configuration value
757
758    :returns: **str** or **list** of string configuration values associated
759      with the given key, providing the default if no such key exists
760    """
761
762    with self._contents_lock:
763      if key in self._contents:
764        self._requested_keys.add(key)
765
766        if multiple:
767          return self._contents[key]
768        else:
769          return self._contents[key][-1]
770      else:
771        message_id = 'stem.util.conf.missing_config_key_%s' % key
772        log.log_once(message_id, log.TRACE, "config entry '%s' not found, defaulting to '%s'" % (key, default))
773        return default
774
775  def __getitem__(self, key):
776    with self._contents_lock:
777      return self._contents[key]
778