#!/usr/bin/env python


'''
ConfReader - configuration file reading class

This class offers a flexible configuration file reading interface. By setting
desired parameters for configuration in one place, it helps reduce confusion
and eases development of end-user applications. It can also be used to parse
existing config files of applications, to build utilities for them.

Initialise with one argument- the name or location of the file. If it is
not absolute, the program's directory will be searched for it. In either
case, IOError will be raised if it cannot be read.

Parameters of config items can be specified by the set() function; the first
argument is the name of the config (as in the file, a string), followed by
four optional arguments:

* default- the value returned if the config is not found in the file.
* post- postprocessing function to use; can be lambda form or any function.
* required- set to 1 if config is required.
* list- set to 1 if config should always be returned as list. Multiple entries
  will be appended to the list if this is set; otherwise, the last entry will
  be used.

Although the name of the config should be specified as in the file, the 
attribute it is made available as will have any non-alphanumeric characters
replaced by an underscore ('_').

When referencing a function for postprocessing, just pass the name, without 
the parenthesis().

Defaults are set before postprocessing occurs; therefore, set defaults as
they would be seen in the file.

Once parameters have been set, call the parse() method, which will apply
them to the configs and make them available as attributes. Users of the 
parse() method should be ready to catch:

* NameError- if a required config is missing
* ValueError- if int() or float() fails
* any other postprocessing exception that may be raised

Remember that unless a default is set, None is passed to the postprocessing
function.

For postprocessing errors, the .config_line_number attribute stores the last
line of the file seen; this makes it possible to see where the error is
in the file.

Config file format:
- blank lines are ignored
- lines that start with a comment character are ignored
- comment character is # by default; set with config.comment attribute
- format of a declaration: name [separator] value
- space around the separator and at the start of the line is ignored
- separator can be changed with the config_separator attribute
- if a requested item is not declared, None will be returned
- this value can be changed with the no_value attribute

yesno() function:
yesno() can be used as a convenient postprocessing function to determine
binary values. See the function for exact behavior. It will raise a
ValueError if it cannot determine the truth of its input.


Example

>>> try:
>>> 	config = ConfReader(filename)
>>> except IOError:							# can't read file
>>> 	print "can't read", filename
>>> config.set('one_conf_item', default='foo')		# default item
>>> config.set('another_item', post=int)			# return an integer
>>> import string
>>> config.set('yet_another', post=string.lower)	# return a lower string
>>> config.set('a_nother', post=string.split)		# return a list of words
>>> config.set('and_another', post=lambda a: a)		# custom postprocessing
>>> config.set('heres_one', list=1)					# return a list
>>> config.set('last_one', required=1)				# retquired config
>>> try:
>>> 	config.parse()								# parse the file
>>> except NameError, missing_one:
>>> 	print filename, "doesn't have", missing_one	# required config missing
>>> except ValueError, convert_problem:
>>> 	print "conversion error:", convert_problem	# conversion problem
>>> print config.one_conf_item						# access a config
'foo'
>>> print config.heres_one
['value', 'value', 'value']



TODO
* Apache-style <Virtual> sections (objects)?
* end-of-line comments
* alternate input methods (any object with a readline(), from array...)
* test() function
* security (take out leading underscores?)

'''


# (c) 1998 Copyright Mark Nottingham
# <mnot@pobox.com>
#
# This software may be freely distributed, modified and used,
# provided that this copyright notice remain intact.
#
# This software is provided 'as is' without warranty of any kind.



__version__ = "0.55"

from types import *
from os import path
import sys, re

illegal_chars = re.compile('\W')	# pattern of illegal characters in
									# attribute names


class ConfReader:
	''' configuration file reading class '''

	def __init__(self, file):
		''' slurp in the conf file '''

		self.config_separator = '='
		self.config_comment = '#'
		self.config_no_value = None
		self.config_line_number = 0

		if not path.isabs(file):
			prog_name = sys.argv[0]
			prog_path = path.split(prog_name)[0]
			file = path.join(prog_path, file)
		conf = open(file, 'r')
		self._confs = conf.readlines()
		conf.close()
		self._defaults = {}
		self._post_proc = {}
		self._required = {}
		self._return_list = {}
		
	
	def __getattr__(self, attr):
		return self.config_no_value


	def set(self, name, default=None, post=None, required=0, list=0):
		''' set parameters for a configuration item '''

		self._defaults[name] = default
		self._post_proc[name] = post
		self._required[name] = required
		self._return_list[name] = list


	def parse(self):
		''' Parse the stored configuration '''

		from string import split, strip
		from types import *
		import re

		tmp = {}							# temporary config dictionary
		comment_pat = re.compile('^\s*%s' % (self.config_comment))	# comment pattern
		blank_pat = re.compile('^\s*$')		# blank lines
		self.config_line_number = 0			# reset line counter

		### process line by line
		for line in self._confs:
			if not line:
				break
			self.config_line_number = self.config_line_number + 1

			### ignore comments, blank lines
			if comment_pat.search(line):
				continue			
			if blank_pat.match(line):
				continue

			### split conf name/value	
			try:
				(conf_name, conf_value) = split(line, self.config_separator, 1)
			except ValueError:
				continue
			conf_name = strip(conf_name)
			conf_value = strip(conf_value)
			
			### add to temp dictionary
			if tmp.has_key(conf_name):
				tmp[conf_name].append(conf_value)
			else:
				tmp[conf_name] = [conf_value]

		### run through required, defaults, postprocess, list
		for name, required in self._required.items():		# required
			if not tmp.has_key(name) and required:
				raise NameError, '%s required - not found' % (name)
		for name, value in self._defaults.items():			# defaults
			if not tmp.has_key(name):
				tmp[name] = [value]
		for name, value in tmp.items():
			tr_func = self._post_proc.get(name, None)		# postprocess
			if tr_func is None: tr_func = lambda a:a
			tmp[name] = map(tr_func, value)
		for name, value in tmp.items():
			if self._return_list.get(name, 0):				# list
				if type(value) is not ListType:
					value = [value]
			else:
				if type(value) is ListType:
					value = value[-1]

			### make names legal
			name = re.sub(illegal_chars, '_', name)

			### populate attributes
			setattr(self, name, value)



def yesno(element):
	''' return 1 for true, 0 for false, or raise error '''

	import string
	e = string.lower(string.strip(element))	
	if e[0] == 't' or e == '1' or e[0] == 'y' or e == 'on':
		return 1
	elif e[0] == 'f' or e == '0' or e[0] == 'n' or e == 'off':
		return 0
	else:
		raise ValueError, "can't determine value of %s" % (element)
		

def test():
	''' haven't gotten around to this yet '''
	pass



if __name__ == '__main__':
	test()
