diff --git a/bob/extension/config.py b/bob/extension/config.py index ce77ae847c851296b589d6557b0b3edd2f756d4b..91ee9b063d1860a5e233f8082c73479a313ce229 100644 --- a/bob/extension/config.py +++ b/bob/extension/config.py @@ -5,6 +5,7 @@ import os import imp +import six import collections @@ -12,8 +13,8 @@ RCFILENAME = '.bobrc.py' """Default name to be used for the RC file to load""" -def _load_module(path, variables): - '''Loads the Python file as module, returns a proper Python module +def _load_context(path, context): + '''Loads the Python file as module, returns a resolved context This function is implemented in a way that is both Python 2 and Python 3 compatible. It does not directly load the python file, but reads its contents @@ -25,121 +26,110 @@ def _load_module(path, variables): path (str): The full path of the Python file to load the module contents from - variables (dict): A mapping which indicates name -> object relationship to - be established within the file before loading it + context (dict): A mapping which indicates name -> object relationship to + be established within the file before loading it. This dictionary + establishes the context in which the module loading is executed, i.e., + previously existing variables when the readout of the new module starts. Returns: - module: A valid Python module you can use in an Algorithm or Library. + dict: A python dictionary with the new, fully resolved context. ''' retval = imp.new_module('config') # defines symbols - for k, v in variables.items(): retval.__dict__[k] = v + for k, v in context.items(): retval.__dict__[k] = v # executes the module code on the context of previously import modules exec(compile(open(path, "rb").read(), path, 'exec'), retval.__dict__) - return retval + # notice retval.__dict__ is deleted when we return + return dict([(k,v) for k,v in retval.__dict__.items() if not k.startswith('_')]) -def update(d, u): - '''Updates dictionary ``d`` with sparse values from ``u`` - - This function updates the base dictionary ``d`` with values from the - dictionary ``u``, with possible dictionary nesting. Matching keys that - existing in ``d`` and ``u`` will be updated. Others will be added to ``d``. - - If the type of value in ``u`` is not the same as in ``d``, ``d``'s value is - *overriden* with the new value from ``u``. - - This procedure does **not** delete any existing keys in ``d`` +def load(path, context=None): + '''Loads a set of configuration files, in sequence + This method will load one or more configuration files. Everytime a + configuration file is loaded, the context (variables) loaded from the + previous file is made available, so the new configuration file can override + or modify this context. Parameters: - d (dict): Dictionary that will be updated + path (:py:class:`str`, :py:class:`list`): The full path of the Python file + to load the module contents from. If an iterable is passed, then it is + iterated and each configuration file is loaded by creating/modifying the + context generated after each file readout. - u (dict): Dictionary with the updates. + context (:py:class:`dict`, Optional): If passed, start the readout of the + first configuration file with the given context. Otherwise, create a new + internal context. Returns: - dict: The input dictionary ``d``, updated + dict: A dictionary of key-values representing the resolved context, after + loading the provided modules and resolving all variables. ''' - for k, v in u.items(): - if isinstance(v, collections.Mapping): - d[k] = update(d.get(k, {}), v) + if isinstance(path, six.string_types): + + if context is None: + context = dict(defaults={}) else: - d[k] = v + if 'defaults' not in context: + context['defaults'] = {} + + retval = _load_context(os.path.realpath(os.path.expanduser(path)), context) + + return retval - return d + elif isinstance(path, collections.Iterable): + retval = None + for k in path: retval = load(k, retval) + return retval -def load(path=None): + else: + + raise TypeError('path must be either a string or iterable over strings') + + +def loadrc(context=None): '''Loads the default configuration file, or an override if provided - This method will load **exactly** one configuration file in this order or - preference: + This method will load **exactly** one (global) resource configuration file in + this fixed order of preference: - 1. The value passed in ``path``, if it exists - 2. A file named :py:attr:`RCFILENAME` on the current directory - 3. A file named :py:attr:`RCFILENAME` on your HOME directory + 1. A file named :py:attr:`RCFILENAME` on the current directory + 2. A file named :py:attr:`RCFILENAME` on your HOME directory - This function will be available in the global context of the loaded - configuration file. You can use it by calling ``load(path)`` to load objects - from another configuration file. Parameters: - path (:py:class:`str`, Optional): The full path of the Python file to load - the module contents from. + context (:py:class:`dict`, Optional): A dictionary that establishes the + context (existing variables) in which the RC file will be loaded. By + default, this value is set to ``None`` which indicates no previous + context. Returns: - dict: A dictionary of key-values after loading the provided module and - resolving all variables. + dict: A dictionary of key-values representing the resolved context, after + loading the provided modules and resolving all variables. ''' - if path is None: - if os.path.exists(RCFILENAME): - path = os.path.realpath(RCFILENAME) - elif os.path.exists(os.path.expanduser('~' + os.sep + RCFILENAME)): - path = os.path.expanduser('~' + os.sep + RCFILENAME) + if os.path.exists(RCFILENAME): + path = os.path.realpath(RCFILENAME) + elif os.path.exists(os.path.expanduser('~' + os.sep + RCFILENAME)): + path = os.path.expanduser('~' + os.sep + RCFILENAME) else: - # if path is relative, make it relative to the current module - if not os.path.isabs(path): - import inspect - f = inspect.currentframe().f_back - if f.f_back is not None: - # this is a call from another module, use that as base for relpath - basedir = os.path.dirname(f.f_code.co_filename) - path = os.path.join(basedir, path) - - if path is None: return {} - - # symbols that will exist (even if not imported) in every config file - symbols = { - 'load': load, - 'update': update, - 'defaults': {}, - } - - mod = _load_module(os.path.realpath(os.path.expanduser(path)), symbols) - retval = mod.__dict__ - - # cleans-up - for key in symbols: - if key == 'defaults': continue - if key in retval: del retval[key] - for key in list(retval.keys()): - if key.startswith('_'): del retval[key] - - return retval + return {} + + return load(path, context) diff --git a/bob/extension/data/config1.py b/bob/extension/data/config1.py deleted file mode 100644 index 3685d27a102f1cc156bc111ec861a77deb9329c4..0000000000000000000000000000000000000000 --- a/bob/extension/data/config1.py +++ /dev/null @@ -1,7 +0,0 @@ -var1 = 'hello' -var2 = 'world' - -import logging as _L -defaults['bob.core'] = {'verbosity': _L.WARNING} - -var3 = {'crazy': 'dictionary', 'to be': 'replaced'} diff --git a/bob/extension/data/config2.py b/bob/extension/data/config2.py deleted file mode 100644 index fab0d3177605d2e279ef43cd97bf55f67576120b..0000000000000000000000000000000000000000 --- a/bob/extension/data/config2.py +++ /dev/null @@ -1,4 +0,0 @@ -var1 = 'howdy' -var3 = 'foo' - -defaults['bob.db.atnt'] = {'extension': '.jpg'} diff --git a/bob/extension/data/defaults-config.py b/bob/extension/data/defaults-config.py index 89950cca1b0dd3e438f01f2a6289278e0725556a..579f36c105b409532331321e1b4761dce161f489 100644 --- a/bob/extension/data/defaults-config.py +++ b/bob/extension/data/defaults-config.py @@ -13,4 +13,3 @@ defaults['bob.db.atnt'] = { 'directory': '/directory/to/root/of/atnt-database', 'extension': '.ppm', } - diff --git a/bob/extension/data/load-config.py b/bob/extension/data/load-config.py index 5efe7a7a5e36acd48557289de0f5f47991b4cab4..b77b8a9a1371b9eb0bfe1752ff808cd49438f758 100644 --- a/bob/extension/data/load-config.py +++ b/bob/extension/data/load-config.py @@ -1,7 +1 @@ -# relative paths are considered w.r.t. location of the caller -# the following will load the file ``advanced-config.py`` which -# is located alongside this file -defaults = load('defaults-config.py')['defaults'] - -# overrides a particular default or sets it for the first time -update(defaults, {'bob.db.atnt': {'extension': '.hdf5'}}) +defaults['bob.db.atnt']['extension'] = '.hdf5' diff --git a/doc/config.rst b/doc/config.rst index 2f0f9d58df61ad781c28c064bbec1dc403f2c58f..a7d97e12bdeeaa8769496dff05f20fbafe8062c8 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -32,7 +32,7 @@ objects from a given configuration file, like this: If the function :py:func:`bob.extension.config.load` succeeds, it returns a python dictionary containing strings as keys and objects (of any kind) which represent the configuration resource. For example, if the file -``example-config.py`` contained: +``basic-config.py`` contained: .. literalinclude:: ../bob/extension/data/basic-config.py :language: python @@ -44,7 +44,7 @@ Then, the object ``configuration`` would look like this: .. doctest:: basic-config - >>> print(json.dumps(configuration, indent=2, sort_keys=True)) + >>> print(json.dumps(configuration, indent=2, sort_keys=True)) # doctest: +NORMALIZE_WHITESPACE { "a": 1, "b": 3, @@ -55,6 +55,19 @@ Then, the object ``configuration`` would look like this: The configuration file does not have to limit itself to simple Pythonic operations, you can import modules and more. +There is a special function to load global configuration resources, typically +called *run commands* (or "rc" for short files). The function is called +:py:func:`bob.extension.config.loadrc` file and automatically searches for an +RC file named :py:func:`bob.extension.config.RCFILENAME` on the current +directory and, if that does not exist, reads the file with the same name +located on the root of your home directory (or whatever ``${HOME}/.bobrc.py`` +points to). + +Configurable resources in each |project| package should be clearly named so you +can correctly configure them. The next section hints on how to organize such +global resources so they are configured homogeneously across packages in the +|project| echo-system. + Package Defaults ---------------- @@ -78,7 +91,7 @@ elements at the start of the configuration file loading. Here is an example: .. testsetup:: defaults-config - from bob.extension.config import load, update + from bob.extension.config import load When loaded, this configuration file produces the result: @@ -108,31 +121,33 @@ When loaded, this configuration file produces the result: configuration file. -Value Overrides ---------------- +Chain Loading +------------- It is possible to implement chain configuration loading and overriding by -either calling :py:func:`bob.extension.config.load` many times or by nesting -calls to ``load()`` within the same configuration file. Here is an example of -the latter: +either calling :py:func:`bob.extension.config.load` many times or by passing +iterables with filenames to that function. Suppose we have two configuration +files which must be loaded in sequence: -.. literalinclude:: ../bob/extension/data/load-config.py - :caption: "load-config.py" +.. literalinclude:: ../bob/extension/data/defaults-config.py + :caption: "defaults-config.py" (first to be loaded) :language: python :linenos: +.. literalinclude:: ../bob/extension/data/load-config.py + :caption: "load-config.py" (loaded after defaults-config.py) + :language: python + :linenos: -The function :py:func:`bob.extension.config.update` is also bound to the -configuration readout and appears as an object called ``update`` within the -configuration file. It provides an easier handle to update the ``defaults`` -dictionary. -This would produce the following result: +Then, one can chain-load them like this: .. doctest:: defaults-config >>> #the variable `path` points to <path-to-bob.extension's root>/data - >>> configuration = load(os.path.join(path, 'load-config.py')) + >>> file1 = os.path.join(path, 'defaults-config.py') + >>> file2 = os.path.join(path, 'load-config.py') + >>> configuration = load([file1, file2]) >>> print(json.dumps(configuration, indent=2, sort_keys=True)) # doctest: +NORMALIZE_WHITESPACE { "defaults": { @@ -145,50 +160,3 @@ This would produce the following result: The user wanting to override defaults needs to manage the overriding and the order in which the override happens. - -It is possible to implement the same override technique programmatically. For -example, suppose a program that receives various configuration files to read as -input and must override values set, one after the other: - -.. code-block:: sh - - # example application call - $ ./my-application.py config1.py config2.py - - -The configuration files contain settings like these: - -.. literalinclude:: ../bob/extension/data/config1.py - :caption: "config1.py" - :language: python - :linenos: - - -.. literalinclude:: ../bob/extension/data/config2.py - :caption: "config2.py" - :language: python - :linenos: - - -Programmatically, the application and implement the update of the configuration -using :py:func:`bob.extension.config.update`: - -.. doctest:: defaults-config - - >>> #the variable `path` points to <path-to-bob.extension's root>/data - >>> configuration = load(os.path.join(path, 'config1.py')) - >>> _ = update(configuration, load(os.path.join(path, 'config2.py'))) - >>> print(json.dumps(configuration, indent=2, sort_keys=True)) # doctest: +NORMALIZE_WHITESPACE - { - "defaults": { - "bob.core": { - "verbosity": 30 - }, - "bob.db.atnt": { - "extension": ".jpg" - } - }, - "var1": "howdy", - "var2": "world", - "var3": "foo" - } diff --git a/setup.py b/setup.py index 4f96474a18e9ecb07c0f40ae1b9a2a21ff860855..5722d3694bd29479162b427c720f35ea5ad774fa 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ from setuptools import setup, find_packages # Define package version version = open("version.txt").read().rstrip() -requires = ['setuptools'] +requires = ['setuptools', 'six'] import sys if sys.version_info[0] == 2 and sys.version_info[1] <= 6: requires.append('importlib')