Skip to content

Commit 26802a7

Browse files
committed
Merge pull request #162 from stdweird/stop_worrying_and_start_loving_configfiles
Add add_flex action and error logging for possible environment variable typos
2 parents d80b3b6 + e556381 commit 26802a7

File tree

5 files changed

+294
-29
lines changed

5 files changed

+294
-29
lines changed

lib/vsc/utils/generaloption.py

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def set_columns(cols=None):
7474

7575
def what_str_list_tuple(name):
7676
"""Given name, return separator, class and helptext wrt separator.
77-
(Currently supports strlist, strtuple, pathlist, pathtuple)
77+
(Currently supports strlist, strtuple, pathlist, pathtuple)
7878
"""
7979
sep = ','
8080
helpsep = 'comma'
@@ -106,6 +106,21 @@ def check_str_list_tuple(option, opt, value):
106106
return klass(split)
107107

108108

109+
def get_empty_add_flex(allvalues):
110+
"""Return the empty element for add_flex action for allvalues"""
111+
empty = None
112+
113+
if isinstance(allvalues, (list, tuple)):
114+
if isinstance(allvalues[0], basestring):
115+
empty = ''
116+
117+
if empty is None:
118+
msg = "get_empty_add_flex cannot determine empty element for type %s (%s)"
119+
self.log.raiseException(msg % (type(allvalues), allvalues))
120+
121+
return empty
122+
123+
109124
class ExtOption(CompleterOption):
110125
"""Extended options class
111126
- enable/disable support
@@ -119,16 +134,25 @@ class ExtOption(CompleterOption):
119134
- add_first : add default to value (result is value + default)
120135
- extend : alias for add with strlist type
121136
- type must support + (__add__) and one of negate (__neg__) or slicing (__getslice__)
137+
- add_flex : similar to add / add_first, but replaces the first "empty" element with the default
138+
- the empty element is dependent of the type
139+
- for {str,path}{list,tuple} this is the empty string
140+
- types must support the index method to determine the location of the "empty" element
141+
- the replacement uses +
142+
- e.g. a strlist type with value "0,,1"` and default [3,4] and action add_flex will
143+
use the empty string '' as "empty" element, and will result in [0,3,4,1] (not [0,[3,4],1])
144+
(but also a strlist with value "" and default [3,4] will result in [3,4];
145+
so you can't set an empty list with add_flex)
122146
- date : convert into datetime.date
123147
- datetime : convert into datetime.datetime
124148
- regex: compile str in regexp
125149
- store_or_None
126150
- set default to None if no option passed,
127151
- set to default if option without value passed,
128152
- set to value if option with value passed
129-
153+
130154
Types:
131-
- strlist, strtuple : convert comma-separated string in a list resp. tuple of strings
155+
- strlist, strtuple : convert comma-separated string in a list resp. tuple of strings
132156
- pathlist, pathtuple : using os.pathsep, convert pathsep-separated string in a list resp. tuple of strings
133157
- the path separator is OS-dependent
134158
"""
@@ -137,7 +161,7 @@ class ExtOption(CompleterOption):
137161
ENABLE = 'enable' # do nothing
138162
DISABLE = 'disable' # inverse action
139163

140-
EXTOPTION_EXTRA_OPTIONS = ('date', 'datetime', 'regex', 'add', 'add_first',)
164+
EXTOPTION_EXTRA_OPTIONS = ('date', 'datetime', 'regex', 'add', 'add_first', 'add_flex',)
141165
EXTOPTION_STORE_OR = ('store_or_None',) # callback type
142166
EXTOPTION_LOG = ('store_debuglog', 'store_infolog', 'store_warninglog',)
143167
EXTOPTION_HELP = ('shorthelp', 'confighelp',)
@@ -230,19 +254,33 @@ def take_action(self, action, dest, opt, value, values, parser):
230254
Option.take_action(self, action, dest, opt, value, values, parser)
231255

232256
elif action in self.EXTOPTION_EXTRA_OPTIONS:
233-
if action in ("add", "add_first",):
257+
if action in ("add", "add_first", "add_flex",):
234258
# determine type from lvalue
235259
# set default first
236-
values.ensure_value(dest, type(value)())
237-
default = getattr(values, dest)
260+
default = getattr(parser.get_default_values(), dest, None)
261+
if default is None:
262+
default = type(value)()
238263
if not (hasattr(default, '__add__') and
239264
(hasattr(default, '__neg__') or hasattr(default, '__getslice__'))):
240265
msg = "Unsupported type %s for action %s (requires + and one of negate or slice)"
241266
self.log.raiseException(msg % (type(default), action))
242-
if action == 'add':
267+
if action in ('add', 'add_flex'):
243268
lvalue = default + value
244269
elif action == 'add_first':
245270
lvalue = value + default
271+
272+
if action == 'add_flex' and lvalue:
273+
# use lvalue here rather than default to make sure there is 1 element
274+
# to determine the type
275+
if not hasattr(lvalue, 'index'):
276+
msg = "Unsupported type %s for action %s (requires index method)"
277+
self.log.raiseException(msg % (type(lvalue), action))
278+
empty = get_empty_add_flex(lvalue)
279+
if empty in value:
280+
ind = value.index(empty)
281+
lvalue = value[:ind] + default + value[ind+1:]
282+
else:
283+
lvalue = value
246284
elif action == "date":
247285
lvalue = date_parser(value)
248286
elif action == "datetime":
@@ -350,7 +388,7 @@ def add_option(self, *args, **kwargs):
350388

351389
class ExtOptionParser(OptionParser):
352390
"""
353-
Make an option parser that limits the C{-h} / C{--shorthelp} to short opts only,
391+
Make an option parser that limits the C{-h} / C{--shorthelp} to short opts only,
354392
C{-H} / C{--help} for all options.
355393
356394
Pass options through environment. Like:
@@ -369,11 +407,34 @@ class ExtOptionParser(OptionParser):
369407
DESCRIPTION_DOCSTRING = False
370408

371409
def __init__(self, *args, **kwargs):
410+
"""
411+
Following named arguments are specific to ExtOptionParser
412+
(the remaining ones are passed to the parent OptionParser class)
413+
414+
:param help_to_string: boolean, if True, the help is written
415+
to a newly created StingIO instance
416+
:param help_to_file: filehandle, help is written to this filehandle
417+
:param envvar_prefix: string, specify the environment variable prefix
418+
to use (if you don't want the default one)
419+
:param process_env_options: boolean, if False, don't check the
420+
environment for options (default: True)
421+
:param error_env_options: boolean, if True, use error_env_options_method
422+
if an environment variable with correct envvar_prefix
423+
exists but does not correspond to an existing option
424+
(default: False)
425+
:param error_env_options_method: callable; method to use to report error
426+
in used environment variables (see error_env_options);
427+
accepts string value + additional
428+
string arguments for formatting the message
429+
(default: own log.error method)
430+
"""
372431
self.log = getLogger(self.__class__.__name__)
373432
self.help_to_string = kwargs.pop('help_to_string', None)
374433
self.help_to_file = kwargs.pop('help_to_file', None)
375434
self.envvar_prefix = kwargs.pop('envvar_prefix', None)
376435
self.process_env_options = kwargs.pop('process_env_options', True)
436+
self.error_env_options = kwargs.pop('error_env_options', False)
437+
self.error_env_option_method = kwargs.pop('error_env_option_method', self.log.error)
377438

378439
# py2.4 epilog compatibilty with py2.7 / optparse 1.5.3
379440
self.epilog = kwargs.pop('epilog', None)
@@ -453,7 +514,7 @@ def set_usage(self, usage):
453514
def get_default_values(self):
454515
"""Introduce the ExtValues class with class constant
455516
- make it dynamic, otherwise the class constant is shared between multiple instances
456-
- class constant is used to avoid _action_taken as option in the __dict__
517+
- class constant is used to avoid _action_taken as option in the __dict__
457518
- only works by using reference to object
458519
- same for _logaction_taken
459520
"""
@@ -607,14 +668,16 @@ def get_env_options(self):
607668
epilogprefixtxt += "eg. --some-opt is same as setting %(prefix)s_SOME_OPT in the environment."
608669
self.epilog.append(epilogprefixtxt % {'prefix': self.envvar_prefix})
609670

671+
candidates = dict([(k, v) for k, v in os.environ.items() if k.startswith("%s_" % self.envvar_prefix)])
672+
610673
for opt in self._get_all_options():
611674
if opt._long_opts is None:
612675
continue
613676
for lo in opt._long_opts:
614677
if len(lo) == 0:
615678
continue
616679
env_opt_name = "%s_%s" % (self.envvar_prefix, lo.lstrip('-').replace('-', '_').upper())
617-
val = os.environ.get(env_opt_name, None)
680+
val = candidates.pop(env_opt_name, None)
618681
if not val is None:
619682
if opt.action in opt.TYPED_ACTIONS: # not all typed actions are mandatory, but let's assume so
620683
self.environment_arguments.append("%s=%s" % (lo, val))
@@ -625,6 +688,14 @@ def get_env_options(self):
625688
else:
626689
self.log.debug("Environment variable %s is not set" % env_opt_name)
627690

691+
if candidates:
692+
msg = "Found %s environment variable(s) that are prefixed with %s but do not match valid option(s): %s"
693+
if self.error_env_options:
694+
logmethod = self.error_env_option_method
695+
else:
696+
logmethod = self.log.debug
697+
logmethod(msg, len(candidates), self.envvar_prefix, ','.join(candidates))
698+
628699
self.log.debug("Environment variable options with prefix %s: %s" % (self.envvar_prefix, self.environment_arguments))
629700
return self.environment_arguments
630701

@@ -654,7 +725,7 @@ class GeneralOption(object):
654725
if True, an option --configfiles will be added
655726
- go_configfiles : list of configfiles to parse. Uses ConfigParser.read; last file wins
656727
- go_configfiles_initenv : section dict of key/value dict; inserted before configfileparsing
657-
As a special case, using all uppercase key in DEFAULT section with a case-sensitive
728+
As a special case, using all uppercase key in DEFAULT section with a case-sensitive
658729
configparser can be used to set "constants" for easy interpolation in all sections.
659730
- go_loggername : name of logger, default classname
660731
- go_mainbeforedefault : set the main options before the default ones
@@ -1046,11 +1117,11 @@ def parseoptions(self, options_list=None):
10461117

10471118
def configfile_parser_init(self, initenv=None):
10481119
"""
1049-
Initialise the confgiparser to use.
1050-
1051-
@params initenv: insert initial environment into the configparser.
1052-
It is a dict of dicts; the first level key is the section name;
1053-
the 2nd level key,value is the key=value.
1120+
Initialise the configparser to use.
1121+
1122+
@params initenv: insert initial environment into the configparser.
1123+
It is a dict of dicts; the first level key is the section name;
1124+
the 2nd level key,value is the key=value.
10541125
All section names, keys and values are converted to strings.
10551126
"""
10561127
self.configfile_parser = self.CONFIGFILE_PARSER()
@@ -1419,9 +1490,17 @@ def generate_cmd_line(self, ignore=None, add_default=None):
14191490
(opt_name, action, default))
14201491
else:
14211492
args.append("--%s" % opt_name)
1422-
elif action in ("add", "add_first"):
1493+
elif action in ("add", "add_first", "add_flex"):
14231494
if default is not None:
1424-
if hasattr(opt_value, '__neg__'):
1495+
if action == 'add_flex' and default:
1496+
for ind, elem in enumerate(opt_value):
1497+
if elem == default[0] and opt_value[ind:ind+len(default)] == default:
1498+
empty = get_empty_add_flex(opt_value)
1499+
# TODO: this will only work for tuples and lists
1500+
opt_value = opt_value[:ind] + type(opt_value)([empty]) + opt_value[ind+len(default):]
1501+
# only the first occurence
1502+
break
1503+
elif hasattr(opt_value, '__neg__'):
14251504
if action == 'add_first':
14261505
opt_value = opt_value + -default
14271506
else:

lib/vsc/utils/testing.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@
3636
from cStringIO import StringIO
3737
from unittest import TestCase
3838

39-
4039
class EnhancedTestCase(TestCase):
4140
"""Enhanced test case, provides extra functionality (e.g. an assertErrorRegex method)."""
4241

42+
LOGCACHE = {}
43+
4344
def setUp(self):
4445
"""Prepare test case."""
4546
super(EnhancedTestCase, self).setUp()
@@ -107,8 +108,45 @@ def get_stderr(self):
107108
"""Return output captured from stderr until now."""
108109
return sys.stderr.getvalue()
109110

111+
def mock_logmethod(self, logmethod_func):
112+
"""
113+
Intercept the logger logmethod. Use as
114+
mylogger = logging.getLogger
115+
mylogger.error = self.mock_logmethod(mylogger.error)
116+
"""
117+
def logmethod(*args, **kwargs):
118+
if hasattr(logmethod_func, 'func_name'):
119+
funcname=logmethod_func.func_name
120+
elif hasattr(logmethod_func, 'im_func'):
121+
funcname = logmethod_func.im_func.__name__
122+
else:
123+
raise Exception("Unknown logmethod %s" % (dir(logmethod_func)))
124+
logcache = self.LOGCACHE.setdefault(funcname, [])
125+
logcache.append({'args': args, 'kwargs': kwargs})
126+
logmethod_func(*args, **kwargs)
127+
128+
return logmethod
129+
130+
def reset_logcache(self, funcname=None):
131+
"""
132+
Reset the LOGCACHE
133+
@param: funcname: if set, only reset the cache for this log function
134+
(default is to reset the whole chache)
135+
"""
136+
if funcname:
137+
self.LOGCACHE[funcname] = []
138+
else:
139+
self.LOGCACHE = {}
140+
141+
def count_logcache(self, funcname):
142+
"""
143+
Return the number of log messages for funcname in the logcache
144+
"""
145+
return len(self.LOGCACHE.get(funcname, []))
146+
110147
def tearDown(self):
111148
"""Cleanup after running a test."""
112149
self.mock_stdout(False)
113150
self.mock_stderr(False)
151+
self.reset_logcache()
114152
super(EnhancedTestCase, self).tearDown()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def remove_bdist_rpm_source_file():
5252

5353
PACKAGE = {
5454
'name': 'vsc-base',
55-
'version': '2.1.3',
55+
'version': '2.2.0',
5656
'author': [sdw, jt, ag, kh],
5757
'maintainer': [sdw, jt, ag, kh],
5858
'packages': ['vsc', 'vsc.utils', 'vsc.install'],

0 commit comments

Comments
 (0)