settings.py module (and associated tests) for reading a settings file and
authorTodd Larsen <tlarsen@google.com>
Fri, 16 May 2008 19:46:16 +0000
changeset 29 64b3e323210f
parent 28 22d872615893
child 30 636baa95715c
settings.py module (and associated tests) for reading a settings file and combining it with command-line options, for SoC utility and tool scripts. Patch by: Todd Larsen Review by: Sverre Rabbelier Review issue: 145 Review URL: http://codereviews.googleopensourceprograms.com/145
scripts/settings.py
scripts/tests/bad_test_settings
scripts/tests/good_test_settings
scripts/tests/settings_test.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/settings.py	Fri May 16 19:46:16 2008 +0000
@@ -0,0 +1,164 @@
+#!/usr/bin/python2.5
+#
+# Copyright 2008 the Melange authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Custom optparse OptionParser and functions for reading Python settings files.
+
+  Option:  class derived from optparse.Option that adds a 'required' parameter
+  OptionParser:  class derived from optparse.OptionParser for use with Option
+
+  readPythonSettings():  interprets a valid Python file as a settings file
+"""
+
+__authors__ = [
+  # alphabetical order by last name, please
+  '"Todd Larsen" <tlarsen@google.com>',
+]
+
+
+import os
+import optparse
+import sys
+
+
+DEF_SETTINGS_FILE_DIR = "~"
+DEF_SETTINGS_FILE_NAME = '.soc_scripts_settings'
+
+
+class Error(Exception):
+  """Base exception class for all exceptions in the settings module."""
+  pass
+
+
+class Option(optparse.Option):
+  """Class derived from optparse.Option that adds a 'required' parameter."""
+
+  ATTRS = optparse.Option.ATTRS + ['required']
+
+  def _check_required(self):
+    """Insures that 'required' option can accept a value."""
+    if self.required and (not self.takes_value()):
+      raise optparse.OptionError(
+          "'required' parameter set for option that does not take a value",
+          self)
+
+  # Make sure _check_required() is called from the constructor!
+  CHECK_METHODS = optparse.Option.CHECK_METHODS + [_check_required]
+
+  def process(self, opt, value, values, parser):
+    optparse.Option.process(self, opt, value, values, parser)
+    parser.option_seen[self] = 1
+
+
+class OptionParser(optparse.OptionParser):
+  """Class derived from optparse.OptionParser for use with Option."""
+
+  def _init_parsing_state(self):
+    """Sets up dict to track options seen so far."""
+    optparse.OptionParser._init_parsing_state(self)
+    self.option_seen = {}
+
+  def error(self, *args):
+    """Convert errors reported by optparse.OptionParser to Error exceptions.
+
+    Args:
+      *args:  passed through to the Error exception __init__() constructor,
+        usually a list of strings
+
+    Raises:
+      Error with the supplied *args
+    """
+    raise Error(*args)
+
+  def check_values(self, values, args):
+    """Checks to make sure all required=True options were supplied.
+
+    Args:
+      values, args:  passed through unchanged (see Returns:)
+
+    Returns:
+      (values, args) unchanged.
+
+    Raises:
+      Error if an option was not supplied that had required=True;  exception
+      positional arguments are the error message strings.
+    """
+    errors = []
+
+    for option in self.option_list:
+      if (isinstance(option, Option)
+          and option.required
+          and (not self.option_seen.has_key(option))):
+        errors.append(
+            'required %s option not supplied' % option)
+
+    if errors:
+      self.error(*errors)
+
+    return values, args
+
+
+def readPythonSettings(defaults={},  # {} OK since defaults is always copied
+                       settings_dir=DEF_SETTINGS_FILE_DIR,
+                       settings_file=DEF_SETTINGS_FILE_NAME):
+  """Executes a Python-syntax settings file and returns the local variables.
+
+  Args:
+    defaults:  dict of default values to use when settings are not present
+      in the settings file (or if no settings file is present at all);  this
+      dict is *copied* and is not altered at all
+    settings_dir:  optional directory containing settings_file
+    settings_file:  optional settings file name found in settings_dir
+
+  Returns:
+    dict of setting name/value pairs (possibly including some values from the
+    defaults parameter).  Since the settings file is full-fledged Python
+    source, the values could be any valid Python object.
+
+  Raises:
+    Error if some error occurred parsing the present settings file;  exception
+    positional arguments are the error message strings.
+  """
+  # do not let the original defaults be altered
+  defaults = defaults.copy()
+
+  # form absolute path to the settings file, expanding any environment
+  # variables and "~", then removing excess . and .. path elements
+  path = os.path.abspath(
+      os.path.normpath(
+          os.path.expanduser(
+              os.path.expandvars(
+                  os.path.join(settings_dir, settings_file)))))
+
+  # empty dict to capture the local variables in the settings file
+  settings_locals = {}
+
+  try:
+    # execute the Python source file and recover the local variables as settings
+    execfile(path, {}, settings_locals)
+  except IOError:
+    # If the settings file is not present, there are no defaults.
+    pass
+  except Exception, error:
+    # Other exceptions usually mean a faulty settings file.
+    raise Error(
+        'faulty settings file:',
+        ('  %s: %s' % (error.__class__.__name__, str(error))),
+        ('  %s' % path))
+
+  # overwrite defaults copy with values from the (possibly empty) settings file
+  defaults.update(settings_locals)
+
+  return defaults
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/tests/bad_test_settings	Fri May 16 19:46:16 2008 +0000
@@ -0,0 +1,4 @@
+# test settings file with syntax error
+
+undefined_symbol
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/tests/good_test_settings	Fri May 16 19:46:16 2008 +0000
@@ -0,0 +1,5 @@
+# test settings file with no errors
+
+foo = 3
+bar = foo
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/scripts/tests/settings_test.py	Fri May 16 19:46:16 2008 +0000
@@ -0,0 +1,127 @@
+#!/usr/bin/python2.5
+#
+# Copyright 2008 the Melange authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for the scripts.settings module.
+
+These tests can be run from the root of the SoC svn working copy with:
+
+  nosetests trunk/scripts/tests
+
+To see specifically which tests are being run, add the -v (--verbosity) option.
+
+This test module is explicitly *not* an executable script so that it can use
+explicit relative references to "reach back" to the module to be tested from
+the tests/ sub-directory (which do not work if __name__ == '__main__').
+"""
+
+__authors__ = [
+  # alphabetical order by last name, please
+  '"Todd Larsen" <tlarsen@google.com>',
+]
+
+
+import optparse
+import os
+import sys
+import unittest
+
+from .. import settings
+
+
+class SettingsTests(unittest.TestCase):
+  """Python-format settings file tests for the settings.py module.
+  """
+
+  def setUp(self):
+    self.test_srcdir = os.path.dirname(__file__)
+    self.settings_defaults = {'foo': 1, 'bif': 4}
+
+  def testMissingPythonSettings(self):
+    """Test that non-existent files work properly without errors.
+    """
+    # non-existent settings file with no defaults produces empty dict
+    self.assertEqual(
+        {},
+        settings.readPythonSettings(settings_dir=self.test_srcdir,
+                                    settings_file='nonexistent_file'))
+
+    # non-existent settings file should just pass through the defaults
+    self.assertEqual(
+        self.settings_defaults,
+        settings.readPythonSettings(defaults=self.settings_defaults,
+                                    settings_dir=self.test_srcdir,
+                                    settings_file='nonexistent_file'))
+
+  def testGoodPythonSettings(self):
+    """Test that settings file that is present overwrites defaults.
+    """
+    # foo and bar are overwritten, but not bif (not in the settings file)
+    self.assertEqual(
+        {'foo': 3, 'bar': 3, 'bif': 4},
+        settings.readPythonSettings(defaults=self.settings_defaults,
+                                    settings_dir=self.test_srcdir,
+                                    settings_file='good_test_settings'))
+
+    # but the original defaults will be untouched
+    self.assertEqual({'foo': 1, 'bif': 4}, self.settings_defaults)
+
+  def testBadPythonSettings(self):
+    """Test that exception is raised when format of settings file is bad.
+    """
+    self.assertRaises(settings.Error, settings.readPythonSettings,
+                      settings_dir=self.test_srcdir,
+                      settings_file='bad_test_settings')
+
+
+class OptionParserTests(unittest.TestCase):
+  """Tests of custom optparse OptionParser with 'required' parameter support.
+  """
+
+  def testRequiredPresent(self):
+    """Test required=True raises nothing when value option is present.
+    """
+    parser = settings.OptionParser(
+      option_list=[
+        settings.Option(
+            '-t', '--test', action='store', dest='test', required=True,
+            help='(REQUIRED) test option'),
+        ],
+      )
+
+    options, args = parser.parse_args([sys.argv[0], '--test', '3'])
+
+  def testRequiredMissing(self):
+    """Test that Error exception is raised if required option not present.
+    """
+    parser = settings.OptionParser(
+      option_list=[
+        settings.Option(
+            '-t', '--test', action='store', dest='test', required=True,
+            help='(REQUIRED) test option'),
+        ],
+      )
+
+    self.assertRaises(settings.Error, parser.parse_args, [])
+
+  def testBadRequiredAction(self):
+    """Test that OptionError is raised if action does not support required=True.
+    """
+
+    # store_true is not in Options.TYPED_VALUES, which means option cannot
+    # take a value, so required=True is not permitted.
+    self.assertRaises(optparse.OptionError, settings.Option,
+        '-t', '--test', action='store_true', dest='test', required=True,
+        help='(REQUIRED) test option')