Source code for gavo.utils.fancyconfig

# -*- coding: utf-8 -*-
r"""
A wrapper around configparser that defines syntax and types within
the configuration options.

This tries to do for configuration processing what optparse did for
command line option processing: A declarative way of handling the main
chores.

The idea is that, in a client program, you say something like::

	from pftf.fancyconfig import (Configuration, Section, ConfigError,
		...(items you want)...)
	
	_config = Config(
		Section(...
			XYConfigItem(...)
		),
		Section(...
		...
		)
	)
			
	get = _config.get
	set = _config.set
			
	if __name__=="__main__":
		print fancyconfig.makeTxtDocs(_config)
	else:
		try:
			fancyconfig.readConfiguration(_config, None,
				os.path.join(dataDir, "config"))
		except ConfigError, msg:
			import sys
			sys.stderr.write("%s: %s\n"%(
				sys.argv[0], unicode(msg)))
			sys.exit(0)

and be done with most of it.

For examples of how this is used, see pftf (http://www.tfiu.de/pftf)
or DaCHS (http://soft.g-vo.org/dachs)
"""

#c Copyright 2008-2023, the GAVO project <gavo@ari.uni-heidelberg.de>
#c
#c This program is free software, covered by the GNU GPL.  See the
#c COPYING file in the source distribution.


import codecs
import configparser
import re
import os
import tempfile
import warnings
import weakref

defaultSection = "general"  # must be all lowercase

# Set to true if avoid making a bad config item bomb out
BAD_CONFIG_ITEM_JUST_WARNS = True


[docs]class ConfigError(Exception): """is the base class of the user visible exceptions from this module. """ fileName = "<internal>"
[docs]class ParseError(ConfigError): """is raised by ConfigItem's parse methods if there is a problem with the input. These should only escape to users of this module unless they call ConfigItem.set themselves (which they shouldn't). """
[docs]class NoConfigItem(ConfigError): """is raised by Configuration if a non-existing configuration item is set or requested. """
[docs]class BadConfigValue(ConfigError): """is raised by getConfiguration when there is a syntax error or the like in a value. The error message gives a hint at the reason of the error and is intended for human consumption. """
[docs]class SyntaxError(ConfigError): """is raised when the input file syntax is bad (i.e., on configparser.ParsingErrors) """
[docs]class ConfigItem(object): """A description of a configuration item including methods to parse and unparse them. This class is an abstract base class for options with real syntax (_parse and _unparse methods). ConfigItems have a section and a name (as in configparser), a value (that defaults to default), an origin (which is "default", if the value has not been changed and otherwise can be freely used by clients), and a description. The origin is important for distinguishing what to save. You need to define the _parse and _unparse methods when deriving subclasses. The _parse methods must take a string and return anything or raise ParseErrors (with a sensible description of the problem) if there is a problem with the input; they must not raise other exceptions when passed a string (but may do anything when passed something else). _unparse methods must not raise exceptions, take a value as returned by parse (nothing else must be passed in) and return a string that _parse would parse into this value. Thus, the set method *only* takes strings as values. To set parsed values, assign to the value attribute directly. However, _unparse methods are not required to cope with any crazy stuff you enter in this way, and thus you suddenly may receive all kinds of funny exceptions when serializing a Configuration. Inheriting classes need to specify a class attribute default that kicks in when no default has been specified during construction. These must be strings parseable by _parse. Finally, you should provide a typedesc class attribute, a description of the type intended for human consumption. See the documentation functions below to get an idea how the would be shown. """ typedesc = "unspecified value" def __init__(self, name, default=None, description="Undocumented"): self.name = name if default is None: default = self.default self.default = default self.set(default, "default") self.description = description self.parent = None # will be set on adoption by a Configuration
[docs] def set(self, value, origin="user"): self.value, self.origin = self._parse(value), origin
[docs] def getAsString(self): return self._unparse(self.value)
def _parse(self, value): raise ParseError("Internal error: Base config item used.") def _unparse(self, value): return value
[docs]class StringConfigItem(ConfigItem): """A config item containing unicode strings. The serialization of the config file is supposed to be utf-8. The special value None is used as a Null value literal. Tests are below. """ typedesc = "string" default = "" def _parse(self, value): if value=="None": return None return value def _unparse(self, value): if value is None: return "None" return value
[docs]class BytestringConfigItem(ConfigItem): """A config item containing byte strings. No characters outside of ASCII are allowed. """ typedesc = "ASCII string" default = "" def _parse(self, value): if value=="None": return None return str(value) def _unparse(self, value): return str(value)
[docs]class IntConfigItem(ConfigItem): """A config item containing an integer. It supports a Null value through the special None literal. >>> ci = IntConfigItem("foo"); print(ci.value) None >>> ci = IntConfigItem("foo", default="23"); ci.value 23 >>> ci.set("42"); ci.value 42 >>> ci.getAsString() '42' """ typedesc = "integer" default = "None" def _parse(self, value): if value=="None": return None try: return int(value) except ValueError: raise ParseError(f"{value} is not an integer literal" ) def _unparse(self, value): return str(value)
[docs]class FloatConfigItem(ConfigItem): """A config item containing a float. It supports a Null value through the special None literal. >>> ci = FloatConfigItem("foo"); print(ci.value) None >>> ci = FloatConfigItem("foo", default="23"); ci.value 23.0 >>> ci.set("42.25"); ci.value 42.25 >>> ci.getAsString() '42.25' """ typedesc = "floating point value" default = "None" def _parse(self, value): if value=="None": return None try: return float(value) except ValueError: raise ParseError("%s is not a floating point literal"%value ) def _unparse(self, value): return repr(value)
[docs]class ListConfigItem(StringConfigItem): r"""A ConfigItem containing a list of strings, comma separated. The values are space-normalized. Trailing whitespace-only items are discarded, so "" is an empty list, "," is a list containing one empty string. There is currently no way to embed commas in the values. If that should become necessary, I'd probably go for backslash escaping. >>> ci = ListConfigItem("foo"); ci.value, ci.getAsString() ([], '') >>> ci.set(ci.getAsString());ci.value [] >>> ci.set("3, 2, 1, Zündung"); ci.value, ci.getAsString() (['3', '2', '1', 'Zündung'], '3, 2, 1, Zündung, ') >>> ci.set(",");ci.value [''] >>> ci.set(ci.getAsString());ci.value [''] """ typedesc = "list of strings" default = "" def _parse(self, value): res = [s.strip() for s in StringConfigItem._parse(self, value).split(",")] if not res[-1]: del res[-1] return res def _unparse(self, value): return StringConfigItem._unparse(self, ", ".join(value+[""]))
[docs]class SetConfigItem(ListConfigItem): """A set-valued ListConfigItem for quick existence lookups. """ typedesc = "set of strings" def _parse(self, value): return set(ListConfigItem._parse(self, value)) def _unparse(self, value): return StringConfigItem._unparse(self, ", ".join(value))
[docs]class IntListConfigItem(ListConfigItem): """A ConfigItem containing a comma separated list of ints. Literal handling is analoguos to ListConfigItem. >>> ci = IntListConfigItem("foo"); ci.value, ci.getAsString() ([], '') >>> ci.set("3,2, 1"); ci.value, ci.getAsString() ([3, 2, 1], '3, 2, 1, ') >>> ci.set(ci.getAsString()); ci.value [3, 2, 1] >>> ci.set("1, 2, 3, rubbish") Traceback (most recent call last): ... fancyconfig.ParseError: Non-integer in integer list """ typedesc = "list of integers" default = "" def _parse(self, value): try: return [int(s) for s in ListConfigItem._parse(self, value)] except ValueError: raise ParseError("Non-integer in integer list") from None def _unparse(self, value): return ListConfigItem._unparse(self, [str(n) for n in value])
[docs]class IntSetConfigItem(IntListConfigItem): """A set-valued IntListConfigItem for fast existence lookups. """ typedesc = "set of integers" def _parse(self, value): return set(IntListConfigItem._parse(self, value))
[docs]class DictConfigItem(ListConfigItem): r"""A config item that contains a concise representation of a string-string mapping. The literal format is {<key>:<value>,}, where whitespace is ignored between tokens and the last comma may be omitted. No commas and colons are allowed within keys and values. To lift this, I'd probably go for backslash escaping. >>> ci = DictConfigItem("foo"); ci.value {} >>> ci.set("ab:cd, foo:Fuß"); ci.value {'ab': 'cd', 'foo': 'Fuß'} >>> ci.getAsString();ci.set(ci.getAsString()); ci.value 'ab:cd, foo:Fuß, ' {'ab': 'cd', 'foo': 'Fuß'} >>> ci.set("ab:cd, rubbish") Traceback (most recent call last): ... fancyconfig.ParseError: 'rubbish' is not a valid mapping literal element """ typedesc = "mapping" default = "" def _parse(self, value): res = {} for item in ListConfigItem._parse(self, value): try: k, v = item.split(":") res[k.strip()] = v.strip() except ValueError: raise ParseError(f"'{item}' is not a valid mapping literal element" ) from None return res def _unparse(self, value): return ListConfigItem._unparse(self, ["%s:%s"%(k, v) for k, v in value.items()])
[docs]class BooleanConfigItem(ConfigItem): """A config item that contains a boolean and can be parsed from many fancy representations. """ typedesc = "boolean" default = "False" trueLiterals = set(["true", "yes", "t", "on", "enabled", "1"]) falseLiterals = set(["false", "no", "f", "off", "disabled", "0"]) def _parse(self, value): value = value.lower() if value in self.trueLiterals: return True elif value in self.falseLiterals: return False else: raise ParseError( f"'{value}' is no recognized boolean literal.") def _unparse(self, value): return {True: "True", False: "False"}[value]
[docs]class EnumeratedConfigItem(StringConfigItem): """A ConfigItem taking string values out of a set of possible strings. Use the keyword argument options to pass in the possible strings. The first item becomes the default unless you give a default. You must give a non-empty list of strings as options. """ typedesc = "value from a defined set" def __init__(self, name, default=None, description="Undocumented", options=[]): if default is None: default = options[0] self.options = set(options) self.typedesc = "value from the list %s"%(", ".join( sorted(self.options))) StringConfigItem.__init__(self, name, default, description) def _parse(self, value): encVal = StringConfigItem._parse(self, value) if encVal not in self.options: raise ParseError("%s is not an allowed value. Choose one of" " %s"%(value, ", ".join([o for o in self.options]))) return encVal
[docs]class PathConfigItem(StringConfigItem): """A ConfigItem for a unix shell-type path. The individual items are separated by colons, ~ is replaced by the current value of $HOME (or "/", if unset), and $<key> substitutions are supported, with key having to point to a key in the defaultSection. To embed a real $ sign, double it. This is parented ConfigItem, i.e., it needs a Configuration parent before its value can be accessed. """ typedesc = "shell-type path" def _parse(self, value): self._unparsed = StringConfigItem._parse(self, value) if self._unparsed is None: return [] else: return [s.strip() for s in self._unparsed.split(":")] def _unparse(self, value): return StringConfigItem._unparse(self, self._unparsed) def _getValue(self): def resolveReference(mat): if mat.group(1)=='$': return '$' else: return self.parent.get(mat.group(1)) res = [] for p in self._value: if p.startswith("~"): p = os.environ.get("HOME", "")+p[1:] if '$' in p: p = re.sub(r"\$(\w+)", resolveReference, p) res.append(p) return res def _setValue(self, val): self._value = val value = property(_getValue, _setValue)
[docs]class PathRelativeConfigItem(StringConfigItem): """A configuration item interpreted relative to a path given in the general section. Basically, this is a replacement for configparser's %(x)s interpolation. In addition, we expand ~ in front of a value to the current value of $HOME. To enable general-type interpolation, override the baseKey class Attribute. """ baseKey = None _value = "" def _getValue(self): if self._value is None: return None if self._value.startswith("~"): return os.environ.get("HOME", "/no_home")+self._value[1:] if self.baseKey: return os.path.join(self.parent.get(self.baseKey), self._value) return self._value def _setValue(self, val): self._value = val value = property(_getValue, _setValue)
[docs]class ExpandedPathConfigItem(StringConfigItem): """A configuration item in that returns its value expandusered. """ def _parse(self, value): val = StringConfigItem._parse(self, value) if val is not None: val = os.path.expanduser(val) return val
class _Undefined(object): """A sentinel for section.get. """
[docs]class Section(object): """A section within the configuration. It is constructed with a name, a documentation, and the configuration items. They double as proxies between the configuration and their items via the setParent method. """ def __init__(self, name, documentation, *items): self.name, self.documentation = name, documentation self.items = {} for item in items: self.items[item.name.lower()] = item def __iter__(self): for name in sorted(self.items): yield self.items[name]
[docs] def getitem(self, name): if name.lower() in self.items: return self.items[name.lower()] else: raise NoConfigItem("No such configuration item: [%s] %s"%( self.name, name))
[docs] def get(self, name): """returns the value of the configuration item name. If it does not exist, a NoConfigItem exception will be raised. """ return self.getitem(name).value
[docs] def set(self, name, value, origin="user"): """set the value of the configuration item name. value must always be a string, regardless of the item's actual type. """ try: self.getitem(name).set(value, origin) except NoConfigItem: if BAD_CONFIG_ITEM_JUST_WARNS: warnings.warn("Unknown configuration item [%s] %s ignored."%( self.name, name)) else: raise
[docs] def setParent(self, parent): for item in list(self.items.values()): item.parent = parent
[docs]class DefaultSection(Section): """is the default section, named by defaultSection above. The only difference to Section is that you leave out the name. """ def __init__(self, documentation, *items): Section.__init__(self, defaultSection, documentation, *items)
[docs]class MagicSection(Section): """A section that creates new keys on the fly. Use this a dictionary-like thing when successive edits are necessary or the DictConfigItem becomes too unwieldy. A MagicSection is constructed with the section name, an item factory, which has to be a subclass of ConfigItem (you may want to write a special constructor to provide documentation, etc.), and defaults as a sequence of pairs of keys and values. And there should be documentation, too, of course. """ def __init__(self, name, documentation="Undocumented", itemFactory=StringConfigItem, defaults=[]): self.itemFactory = itemFactory items = [] for key, value in defaults: items.append(self.itemFactory(key)) items[-1].set(value, origin="defaults") Section.__init__(self, name, documentation, *items)
[docs] def set(self, name, value, origin="user"): if name not in self.items: self.items[name.lower()] = self.itemFactory(name) Section.set(self, name, value, origin)
[docs]class Configuration(object): """A collection of config Sections and provides an interface to access them and their items. You construct it with the Sections you want and then use the get method to access their content. You can either use get(section, name) or just get(name), which implies the defaultSection section defined at the top (right now, "general"). To read configuration items, use addFromFp. addFromFp should only raise subclasses of ConfigError. You can also set individual items using set. The class follows the default behaviour of configparser in that section and item names are lowercased. Note that direct access to sections is not forbidden, but you have to keep case mangling of keys into account when doing so. """ def __init__(self, *sections): self.sections = {} for section in sections: self.sections[section.name.lower()] = section section.setParent(weakref.proxy(self)) def __iter__(self): sectHeads = list(self.sections.keys()) if defaultSection in sectHeads: sectHeads.remove(defaultSection) yield self.sections[defaultSection] for h in sorted(sectHeads): yield self.sections[h]
[docs] def getitem(self, arg1, arg2=None): """returns the *item* described by section, name or just name. """ if arg2 is None: section, name = defaultSection, arg1 else: section, name = arg1, arg2 if section.lower() in self.sections: return self.sections[section.lower()].getitem(name) raise NoConfigItem("No such configuration item: [%s] %s"%( section, name))
[docs] def get(self, arg1, arg2=None, default=_Undefined): try: return self.getitem(arg1, arg2).value except NoConfigItem: if default is _Undefined: raise return default
[docs] def set(self, arg1, arg2, arg3=None, origin="user"): """sets a configuration item to a value. arg1 can be a section, in which case arg2 is a key and arg3 is a value; alternatively, if arg3 is not given, arg1 is a key in the defaultSection, and arg2 is the value. All arguments are strings that must be parseable by the referenced item's _parse method. Origin is a tag you can use to, e.g., determine what to save. """ if arg3 is None: section, name, value = defaultSection, arg1, arg2 else: section, name, value = arg1, arg2, arg3 if section.lower() in self.sections: return self.sections[section.lower()].set(name, value, origin) else: raise NoConfigItem("No such configuration item: [%s] %s"%( section, name))
[docs] def addFromFp(self, fp, origin="user", fName="<internal>"): """adds the config items in the file fp to self. """ p = configparser.ConfigParser() try: p.read_file(fp, fName) except configparser.ParsingError as msg: raise SyntaxError("Config syntax error in %s: %s"%(fName, str(msg))) sections = p.sections() for section in sections: for name, value in p.items(section): try: self.set(section, name, value, origin) except ParseError as msg: raise BadConfigValue("While parsing value of %s in section %s," " file %s:\n%s"% (name, section, fName, str(msg)))
[docs] def getUserConfig(self): """returns a configparser containing the user set config items. """ userConf = configparser.ConfigParser() for section in list(self.sections.values()): for item in section: if item.origin=="user": if not userConf.has_section(section.name): userConf.add_section(section.name) userConf.set(section.name, item.name, item.getAsString()) return userConf
[docs] def saveUserConfig(self, destName): """writes the config items changed by the user to destName. """ uc = self.getUserConfig() fd, tmpName = tempfile.mkstemp("temp", "", dir=os.path.dirname(destName)) f = os.fdopen(fd, "w") uc.write(f) f.flush() os.fsync(fd) f.close() os.rename(tmpName, destName)
def _addToConfig(config, fName, origin): """adds the config items in the file named in fName to the Configuration, tagging them with origin. fName can be None or point to a non-exisiting file. In both cases, the function does nothing. """ if not fName or not os.path.exists(fName): return with codecs.open(fName, "r", "utf8") as f: config.addFromFp(f, origin=origin, fName=fName)
[docs]def readConfiguration(config, systemFName, userFName): """fills the Configuration config with values from the the two locations. File names that are none or point to non-existing locations are ignored. """ try: _addToConfig(config, systemFName, "system") except ConfigError as ex: ex.fileName = systemFName raise try: _addToConfig(config, userFName, "user") except ConfigError as ex: ex.fileName = userFName raise
[docs]def makeTxtDocs(config, underlineChar="."): import textwrap docs = [] for section in config: if isinstance(section, MagicSection): hdr = "Magic Section [%s]"%(section.name) body = (section.documentation+ "\n\nThe items in this section are all of type %s. You can add keys" " as required.\n"% section.itemFactory.typedesc) else: hdr = "Section [%s]"%(section.name) body = section.documentation docs.append("\n%s\n%s\n\n%s\n"%(hdr, underlineChar*len(hdr), textwrap.fill(body, width=72))) for ci in section: docs.append("* %s: %s; "%(ci.name, ci.typedesc)) if ci.default is not None: docs.append(" defaults to '%s' --"%ci.default) docs.append(textwrap.fill(ci.description, width=72, initial_indent=" ", subsequent_indent=" ")) return "\n".join(docs)
def _getTestSuite(): """returns a unittest suite for this module. It's in-file since I want to keep the thing in a single file. """ import unittest from io import StringIO class TestConfigItems(unittest.TestCase): """tests for individual config items. """ def testStringConfigItemDefaultArgs(self): ci = StringConfigItem("foo") self.assertEqual(ci.name, "foo") self.assertEqual(ci.value, "") self.assertEqual(ci.description, "Undocumented") self.assertEqual(ci.origin, "default") ci.set("bar", "user") self.assertEqual(ci.value, "bar") self.assertEqual(ci.origin, "user") self.assertEqual(ci.name, "foo") self.assertEqual(ci.getAsString(), "bar") def testStringConfigItemNoDefaults(self): ci = StringConfigItem("foo", default="quux", description="An expressionist config item") self.assertEqual(ci.name, "foo") self.assertEqual(ci.value, "quux") self.assertEqual(ci.description, "An expressionist config item") self.assertEqual(ci.origin, "default") ci.set("None", "user") self.assertEqual(ci.value, None) self.assertEqual(ci.origin, "user") self.assertEqual(ci.getAsString(), "None") def testStringConfigItemEncoding(self): ci = StringConfigItem("foo", default='Füße') self.assertEqual(ci.value.encode("iso-8859-1"), b'F\xfc\xdfe') self.assertEqual(ci.getAsString(), 'Füße') def testIntConfigItem(self): ci = IntConfigItem("foo") self.assertEqual(ci.value, None) ci = IntConfigItem("foo", default="0") self.assertEqual(ci.value, 0) ci.set("42") self.assertEqual(ci.value, 42) self.assertEqual(ci.getAsString(), "42") def testBooleanConfigItem(self): ci = BooleanConfigItem("foo", default="0") self.assertEqual(ci.value, False) self.assertEqual(ci.getAsString(), "False") ci.set("true") self.assertEqual(ci.value, True) ci.set("on") self.assertEqual(ci.value, True) self.assertEqual(ci.getAsString(), "True") self.assertRaises(ParseError, ci.set, "undecided") def testEnumeratedConfigItem(self): ci = EnumeratedConfigItem("foo", options=["bar", "foo", "Fuß"]) self.assertEqual(ci.value, "bar") self.assertRaises(ParseError, ci.set, "quux") ci.set('Fuß') self.assertEqual(ci.value, "Fuß") self.assertEqual(ci.getAsString(), 'Fuß') def getSampleConfig(): return Configuration( DefaultSection("General Settings", StringConfigItem("emptyDefault", description="is empty by default"), StringConfigItem("fooDefault", default="foo", description="is foo by default"),), Section("types", "Various Types", IntConfigItem("count", default="0", description= "is an integer"), ListConfigItem("enum", description="is a list", default="foo, bar"), IntListConfigItem("intenum", description="is a list of ints", default="1,2,3"), DictConfigItem("map", description="is a mapping", default="intLit:1, floatLit:0.1, bla: wurg"),)) class ReadConfigTest(unittest.TestCase): """tests for reading complete configurations. """ def testDefaults(self): config = getSampleConfig() self.assertEqual(config.get("emptyDefault"), "") self.assertEqual(config.get("fooDefault"), "foo") self.assertEqual(config.get("types", "count"), 0) self.assertEqual(config.get("types", "enum"), ["foo", "bar"]) self.assertEqual(config.get("types", "intenum"), [1,2,3]) self.assertEqual(config.get("types", "map"), {"intLit": "1", "floatLit": "0.1", "bla": "wurg"}) def testSetting(self): config = getSampleConfig() config.set("emptyDefault", "foo") self.assertEqual(config.get("emptyDefault"), "foo") self.assertEqual(config.getitem("emptydefault").origin, "user") self.assertEqual(config.getitem("foodefault").origin, "default") def testReading(self): config = getSampleConfig() config.addFromFp(StringIO("[general]\n" "emptyDefault: bar\n" "fooDefault: quux\n" "[types]\n" "count: 7\n" "enum: one, two,three:3\n" "intenum: 1, 1,3,3\n" "map: Fuß: y, x:Fuß\n")) self.assertEqual(config.get("emptyDefault"), "bar") self.assertEqual(config.get("fooDefault"), "quux") self.assertEqual(config.get("types", "count"), 7) self.assertEqual(config.get("types", "enum"), ["one", "two", "three:3"]) self.assertEqual(config.get("types", "intenum"), [1,1,3,3]) self.assertEqual(config.get("types", "map"), {'Fuß': "y", "x": 'Fuß'}) self.assertEqual(config.getitem("types", "map").origin, "user") def testRaising(self): config = getSampleConfig() self.assertRaises(BadConfigValue, config.addFromFp, StringIO("[types]\nintenum: brasel\n")) self.assertRaises(SyntaxError, config.addFromFp, StringIO("intenum: brasel\n")) self.assertRaises(ParseError, config.getitem("types", "count").set, "abc") def testWarning(self): config = getSampleConfig() with warnings.catch_warnings(record=True) as w: config.addFromFp(StringIO("[types]\nnonexisting: True\n")) self.assertEqual(len(w), 1) self.assertEqual(str(w[0].message), "Unknown configuration item [types] nonexisting ignored.") class MagicFactoryTest(unittest.TestCase): """tests for function of MagicFactories. """ def testMagic(self): config = Configuration( MagicSection("profiles", "Some magic Section", defaults=(('a', 'b'), ('c', 'd')))) self.assertEqual(config.get('profiles', 'c'), 'd') self.assertRaises(NoConfigItem, config.get, 'profiles', 'd') config.set('profiles', 'new', 'shining', origin="user") item = config.getitem('profiles', 'new') self.assertEqual(item.value, 'shining') self.assertEqual(item.origin, 'user') class UserConfigTest(unittest.TestCase): """tests for extraction of user-supplied config items. """ def testNoUserConfig(self): config = getSampleConfig() cp = config.getUserConfig() self.assertEqual(cp.sections(), []) def testSomeUserConfig(self): config = getSampleConfig() config.set("emptyDefault", "not empty any more") config.set("types", "count", "4") config.set("types", "intenum", "3,2,1") cp = config.getUserConfig() self.assertEqual([s for s in sorted(cp.sections())], ["general", "types"]) self.assertEqual(len(cp.items("general")), 1) self.assertEqual(len(cp.items("types")), 2) self.assertEqual(cp.get("general", "emptyDefault"), "not empty any more") self.assertEqual(cp.get("types", "count"), "4") self.assertEqual(cp.get("types", "intenum"), "3, 2, 1, ") l = locals() tests = [l[name] for name in l if isinstance(l[name], type) and issubclass(l[name], unittest.TestCase)] loader = unittest.TestLoader() suite = unittest.TestSuite([loader.loadTestsFromTestCase(t) for t in tests]) return suite, tests
[docs]def load_tests(loader, tests, ignore): import doctest import fancyconfig suite, _ = _getTestSuite() tests.addTest(suite) tests.addTest(doctest.DocTestSuite(fancyconfig)) return tests
if __name__=="__main__": # pragma: no-cover import unittest suite = unittest.TestSuite() unittest.TextTestRunner().run(load_tests(None, suite, None))