diff --git a/bob/extension/config.py b/bob/extension/config.py index cae8b63938c7ab3cbb640ff50667d97dedbf63c2..117a9914363c917be0a80650d01136377f02cadc 100644 --- a/bob/extension/config.py +++ b/bob/extension/config.py @@ -5,11 +5,15 @@ ''' import imp +import pkg_resources +import pkgutil +from os.path import isfile import logging logger = logging.getLogger(__name__) -loaded_configs = [] +LOADED_CONFIGS = [] + def _load_context(path, mod): '''Loads the Python file as module, returns a resolved context @@ -18,23 +22,22 @@ def _load_context(path, mod): compatible. It does not directly load the python file, but reads its contents in memory before Python-compiling it. It leaves no traces on the file system. - - Parameters: - - path (str): The full path of the Python file to load the module contents + Parameters + ---------- + path : str + The full path of the Python file to load the module contents from - - mod (module): A preloaded module to use as context for the next module + mod : module + A preloaded module to use as context for the next module loading. You can create a new module using :py:mod:`imp` as in ``m = imp.new_module('name'); m.__dict__.update(ctxt)`` where ``ctxt`` is a python dictionary with string -> object values representing the contents of the module to be created. - - Returns: - - module: A python module with the fully resolved context - + Returns + ------- + mod : :any:`module` + A python module with the fully resolved context ''' # executes the module code on the context of previously imported modules @@ -43,43 +46,120 @@ def _load_context(path, mod): return mod -def load(paths, context=None): +def _get_module_filename(module_name): + """Resolves a module name to an actual Python file. + + Parameters + ---------- + module_name : str + The name of the module + + Returns + ------- + str + The Python files that corresponds to the module name. + """ + loader = pkgutil.get_loader(module_name) + if loader is None: + return '' + return loader.filename + + +def _resolve_entry_point_or_modules(paths, entry_point_group): + """Resolves a mixture of paths, entry point names, and module names to just + paths. For example paths can be: + ``paths = ['/tmp/config.py', 'config1', 'bob.extension.config2']``. + + Parameters + ---------- + paths : [str] + An iterable strings that either point to actual files, are entry point + names, or are module names. + entry_point_group : str + The entry point group name to search in entry points. + + Raises + ------ + ValueError + If one of the paths cannot be resolved to an actual path to a file. + + Returns + ------- + paths : [str] + The resolved paths pointing to existing files. + """ + entries = {e.name: e for e in + pkg_resources.iter_entry_points(entry_point_group)} + files = [] + + for i, path in enumerate(paths): + old_path = path + # if it is already a file + if isfile(path): + pass + # If it is an entry point name + elif path in entries: + module_name = entries[path].module_name + path = _get_module_filename(module_name) + if not isfile(path): + raise ValueError( + "The specified entry point: `{}' pointing to module: `{}' and " + "resolved to: `{}' does not point to an existing " + "file.".format(old_path, module_name, path)) + # If it is not a path nor an entry point name, it is a module name then? + else: + path = _get_module_filename(path) + if not isfile(path): + raise ValueError( + "The specified path: `{}' resolved to: `{}' is not a file, entry " + "point name, or a module name".format(old_path, path)) + files.append(path) + return files + + +def load(paths, context=None, entry_point_group=None): '''Loads a set of configuration files, in sequence - This method will load one or more configuration files. Everytime a + This method will load one or more configuration files. Every time 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: - - paths (:py:class:`list`): A list or iterable containing paths (relative or - absolute) of configuration files that need to be loaded in sequence. - Each configuration file is loaded by creating/modifying the context - generated after each file readout. - - 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: A dictionary of key-values representing the resolved context, after - loading the provided modules and resolving all variables. - + Parameters + ---------- + paths : [str] + A list or iterable containing paths (relative or absolute) of + configuration files that need to be loaded in sequence. Each + configuration file is loaded by creating/modifying the context generated + after each file readout. + context : :py:class:`dict`, optional + If provided, start the readout of the first configuration file with the + given context. Otherwise, create a new internal context. + entry_point_group : :py:class:`str`, optional + If provided, it will treat non-existing file paths as entry point names + under the ``entry_point_group`` name. + + Returns + ------- + mod : :any:`module` + A module representing the resolved context, after loading the provided + modules and resolving all variables. ''' mod = imp.new_module('config') if context is not None: mod.__dict__.update(context) + # resolve entry points to paths + if entry_point_group is not None: + paths = _resolve_entry_point_or_modules(paths, entry_point_group) + for k in paths: logger.debug("Loading configuration file `%s'...", k) mod = _load_context(k, mod) # Small gambiarra (https://www.urbandictionary.com/define.php?term=Gambiarra) - # to avoid the gc to collect some already imported modules - loaded_configs.append(mod) - + # to avoid the garbage collector to collect some already imported modules. + LOADED_CONFIGS.append(mod) + return mod diff --git a/bob/extension/data/__init__.py b/bob/extension/data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bob/extension/data/basic-config.py b/bob/extension/data/basic_config.py similarity index 100% rename from bob/extension/data/basic-config.py rename to bob/extension/data/basic_config.py diff --git a/bob/extension/data/config-with-module.py b/bob/extension/data/config_with_module.py similarity index 100% rename from bob/extension/data/config-with-module.py rename to bob/extension/data/config_with_module.py diff --git a/bob/extension/data/load-config.py b/bob/extension/data/load-config.py deleted file mode 100644 index 063cdb7fd067e022d0d0dd7e14776fe377d6953b..0000000000000000000000000000000000000000 --- a/bob/extension/data/load-config.py +++ /dev/null @@ -1 +0,0 @@ -b = b + 3 diff --git a/bob/extension/data/load_config.py b/bob/extension/data/load_config.py new file mode 100644 index 0000000000000000000000000000000000000000..21abd01b385338741042deb0c5a554810c2fdde8 --- /dev/null +++ b/bob/extension/data/load_config.py @@ -0,0 +1,3 @@ +# the b variable from the last config file is available here +c = b + 1 +b = b + 3 diff --git a/bob/extension/test_config.py b/bob/extension/test_config.py index e3e182b132d3f96a2fdeb5e2bb4880440ef147c0..4d4bd837cba913dfca2dd27e822c73e30ffc9262 100644 --- a/bob/extension/test_config.py +++ b/bob/extension/test_config.py @@ -13,14 +13,14 @@ path = pkg_resources.resource_filename('bob.extension', 'data') def test_basic(): - c = load([os.path.join(path, 'basic-config.py')]) + c = load([os.path.join(path, 'basic_config.py')]) assert hasattr(c, "a") and c.a == 1 assert hasattr(c, "b") and c.b == 3 - + def test_basic_with_context(): - c = load([os.path.join(path, 'basic-config.py')], {'d': 35, 'a': 0}) + c = load([os.path.join(path, 'basic_config.py')], {'d': 35, 'a': 0}) assert hasattr(c, "a") and c.a == 1 assert hasattr(c, "b") and c.b == 3 assert hasattr(c, "d") and c.d == 35 @@ -28,15 +28,27 @@ def test_basic_with_context(): def test_chain_loading(): - file1 = os.path.join(path, 'basic-config.py') - file2 = os.path.join(path, 'load-config.py') + file1 = os.path.join(path, 'basic_config.py') + file2 = os.path.join(path, 'load_config.py') c = load([file1, file2]) assert hasattr(c, "a") and c.a == 1 assert hasattr(c, "b") and c.b == 6 - + def test_config_with_module(): - c = load([os.path.join(path, 'config-with-module.py')]) - assert hasattr(c, "return_zeros") and numpy.allclose(c.return_zeros(), numpy.zeros(shape=(2,))) + c = load([os.path.join(path, 'config_with_module.py')]) + assert hasattr(c, "return_zeros") and numpy.allclose( + c.return_zeros(), numpy.zeros(shape=(2,))) + +def test_entry_point_configs(): + + # test when all kinds of paths + c = load([ + os.path.join(path, 'basic_config.py'), + 'basic_config', + 'bob.extension.data.basic_config', + ], entry_point_group='bob.extension.test_config_load') + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 3 diff --git a/doc/config.rst b/doc/config.rst index 643d20a193279661dc8116ead36a3d80ec7d836d..2c8431241de6cbf6ad63b1bac6baf4e28426f767 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -21,30 +21,31 @@ objects from one or more configuration files, like this: import pkg_resources path = pkg_resources.resource_filename('bob.extension', 'data') import json + from bob.extension.config import load -.. doctest:: basic-config +.. doctest:: >>> from bob.extension.config import load >>> #the variable `path` points to <path-to-bob.extension's root>/data - >>> configuration = load([os.path.join(path, 'basic-config.py')]) + >>> configuration = load([os.path.join(path, 'basic_config.py')]) 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 -``basic-config.py`` contained: +``basic_config.py`` contained: -.. literalinclude:: ../bob/extension/data/basic-config.py +.. literalinclude:: ../bob/extension/data/basic_config.py :language: python :linenos: - :caption: "basic-config.py" + :caption: "basic_config.py" Then, the object ``configuration`` would look like this: -.. doctest:: basic-config +.. doctest:: - >>> print("a = %d\nb = %d"%(configuration.a, configuration.b)) # doctest: +NORMALIZE_WHITESPACE + >>> print("a = %d\nb = %d"%(configuration.a, configuration.b)) a = 1 b = 3 @@ -62,34 +63,24 @@ passing iterables with more than one filename to :py:func:`bob.extension.config.load`. Suppose we have two configuration files which must be loaded in sequence: -.. literalinclude:: ../bob/extension/data/basic-config.py - :caption: "basic-config.py" (first to be loaded) +.. literalinclude:: ../bob/extension/data/basic_config.py + :caption: "basic_config.py" (first to be loaded) :language: python :linenos: -.. literalinclude:: ../bob/extension/data/load-config.py - :caption: "load-config.py" (loaded after basic-config.py) +.. literalinclude:: ../bob/extension/data/load_config.py + :caption: "load_config.py" (loaded after basic_config.py) :language: python :linenos: Then, one can chain-load them like this: -.. testsetup:: basic-config - - import os - import pkg_resources - path = pkg_resources.resource_filename('bob.extension', 'data') - import json - - from bob.extension.config import load - - -.. doctest:: basic-config +.. doctest:: >>> #the variable `path` points to <path-to-bob.extension's root>/data - >>> file1 = os.path.join(path, 'basic-config.py') - >>> file2 = os.path.join(path, 'load-config.py') + >>> file1 = os.path.join(path, 'basic_config.py') + >>> file2 = os.path.join(path, 'load_config.py') >>> configuration = load([file1, file2]) >>> print("a = %d \nb = %d"%(configuration.a, configuration.b)) # doctest: +NORMALIZE_WHITESPACE a = 1 @@ -98,3 +89,23 @@ Then, one can chain-load them like this: The user wanting to override the values needs to manage the overriding and the order in which the override happens. + + +Entry Points +------------ + +The function :py:func:`bob.extension.config.load` can also load config files +through `Setuptools`_ entry points and module names. It is only needed +to provide the group name of the entry points: + +.. doctest:: entry_point + + >>> group = 'bob.extension.test_config_load' # the group name of entry points + >>> file1 = 'basic_config' # an entry point name + >>> file2 = 'bob.extension.data.load_config' # module name + >>> configuration = load([file1, file2], entry_point_group=group) + >>> print("a = %d \nb = %d"%(configuration.a, configuration.b)) # doctest: +NORMALIZE_WHITESPACE + a = 1 + b = 6 + +.. include:: links.rst diff --git a/doc/links.rst b/doc/links.rst index 03b095aaa262615ebaaf72c8702a8a091f44e7f9..a6f6d0d858bb31e398ce4b339514273aede3032c 100644 --- a/doc/links.rst +++ b/doc/links.rst @@ -18,6 +18,7 @@ .. _python: http://www.python.org .. _pypi: http://pypi.python.org .. _bob packages: https://www.idiap.ch/software/bob/packages +.. _setuptools documentation: .. _setuptools: https://setuptools.readthedocs.io .. _sphinx: http://sphinx.pocoo.org .. _zc.buildout: http://pypi.python.org/pypi/zc.buildout/ diff --git a/doc/pure_python.rst b/doc/pure_python.rst index d13bb7897b3bce92dd410aec7c8460be35398069..685444a95306246eccf8986594804cb602b6952c 100644 --- a/doc/pure_python.rst +++ b/doc/pure_python.rst @@ -114,7 +114,7 @@ In detail, it defines the name and the version of this package, which files belong to the package (those files are automatically collected by the ``find_packages`` function), other packages that we depend on, namespaces and console scripts. The full set of options can be inspected in the -`Setuptools documentation <https://setuptools.readthedocs.io>`_. +`Setuptools documentation`_. .. warning:: diff --git a/setup.py b/setup.py index 745adf7705743bb0cf34ca240df2bee6b57b0d12..f1629339486fef641ec1f1130a8d6e0ac3b2633d 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,10 @@ setup( 'bob_new_version.py = bob.extension.scripts:new_version', 'bob_dependecy_graph.py = bob.extension.scripts:dependency_graph', ], + # some test entry_points + 'bob.extension.test_config_load': [ + 'basic_config = bob.extension.data.basic_config', + ], }, classifiers = [