# HG changeset patch # User Todd Larsen # Date 1210967176 0 # Node ID 64b3e323210f8fc7974edad67561f88e6d32c280 # Parent 22d872615893e822c209ab9191261646dac817bb 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 diff -r 22d872615893 -r 64b3e323210f scripts/settings.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" ', +] + + +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 diff -r 22d872615893 -r 64b3e323210f scripts/tests/bad_test_settings --- /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 + diff -r 22d872615893 -r 64b3e323210f scripts/tests/good_test_settings --- /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 + diff -r 22d872615893 -r 64b3e323210f scripts/tests/settings_test.py --- /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" ', +] + + +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')