diff --git a/.ci/pre.linux.sh b/.ci/pre.linux.sh index 41ff32c..fe77227 100644 --- a/.ci/pre.linux.sh +++ b/.ci/pre.linux.sh @@ -1 +1 @@ -echo "There's no need to execute anything before running" \ No newline at end of file +echo "There's no need to execute anything before running" diff --git a/.ci/pre.osx.sh b/.ci/pre.osx.sh index c9fe7e4..26364aa 100644 --- a/.ci/pre.osx.sh +++ b/.ci/pre.osx.sh @@ -1,2 +1,2 @@ echo "Use pyenv rehash before executing tests" -pyenv rehash \ No newline at end of file +pyenv rehash diff --git a/.travis.yml b/.travis.yml index 6898c66..60dd5c3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,38 +5,34 @@ sudo: false matrix: include: - os: linux - python: 2.7 + python: 3.5 - os: linux - python: 3.4 + python: 3.6 - os: linux - python: 3.5 + dist: xenial + python: 3.7 - os: linux - python: pypy - - os: osx - language: generic - env: - - PYTHON_VERSION=2.7.10 - - PYENV_ROOT=~/.pyenv - - PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH + python: pypy3 - os: osx language: generic env: - - PYTHON_VERSION=3.4.3 + - PYTHON_VERSION=3.5.6 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH - os: osx language: generic env: - - PYTHON_VERSION=3.5.0 + - PYTHON_VERSION=3.6.8 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH - os: osx language: generic env: - - PYTHON_VERSION=pypy-2.6.0 + - PYTHON_VERSION=3.7.2 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH + before_install: - bash .ci/deps.${TRAVIS_OS_NAME}.sh diff --git a/CHANGES.txt b/CHANGES.txt index 4cabef5..6197307 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,11 @@ +2.0.0 +===== + + - Refactor strategy to find configuration files (thanks to @hernantz) + - Lots of improvements on documentation + - Dropped support for py2 + - Dropped tox support + 1.2.3 ===== diff --git a/docs/source/advanced.rst b/docs/source/advanced.rst new file mode 100644 index 0000000..30828c3 --- /dev/null +++ b/docs/source/advanced.rst @@ -0,0 +1,174 @@ +Advanced Usage +-------------- + + +Most of the time you can use the ``prettyconf.config`` function to get your +settings and use the ``prettyconf``'s standard behaviour. But some times +you need to change this behaviour. + +To make this changes possible you can always create your own +``Configuration()`` instance and change it's default behaviour: + +.. code-block:: python + + from prettyconf import Configuration + + config = Configuration() + +.. warning:: ``prettyconf`` will skip configuration files inside ``.zip``, + ``.egg`` or wheel packages. + + +.. _discovery-customization: + +Customizing the configuration discovery ++++++++++++++++++++++++++++++++++++++++ + +By default the library will use the envrionment and the directory of the file +where ``config()`` was called as the start directory to look for a ``.env`` +configuration file. Consider the following file structure: + +.. code-block:: text + + project/ + app/ + .env + config.ini + settings.py + +If you call ``config()`` from ``project/app/settings.py`` the library will +inspect the envrionment and then look for configuration files at +``project/app``. + +You can change that behaviour, by customizing configuration loaders to look at +a different ``path`` when instantiating your ``Configuration()``: + +.. code-block:: python + + # Code example in project/app/settings.py + import os + + from prettyconf import Configuration + from prettyconf.loaders import Environment, EnvFile + + project_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) + env_file = f"{project_path}/.env" + config = Configuration(loaders=[Environment(), EnvFile(filename=env_file)]) + +The example above will start looking for configuration in the environment and +then in a ``.env`` file at ``project/`` instead of ``project/app``. + +Because ``config`` is nothing but an already instantiated ``Configuration`` object, +you can also alter this ``loaders`` attribute in ``prettyconf.config`` before use it: + +.. code-block:: python + + # Code example in project/app/settings.py + import os + + from prettyconf import config + from prettyconf.loaders import Environment, EnvFile + + project_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) + env_file = f"{project_path}/.env" + config.loaders = [Environment(), EnvFile(filename=env_file)] + +Read more about how loaders can be configured in the :doc:`loaders section`. + +.. _variable-naming: + +Naming conventions for variables +++++++++++++++++++++++++++++++++ + +There happen to be some formating conventions for configuration paramenters +based on where they are set. For example, it is common to name environment +variables in uppercase: + +.. code-block:: sh + + $ DEBUG=yes OTHER_CONFIG=10 ./app.py + +but if you were to set this config in an ``.ini`` file, it should probably be +in lower case: + +.. code-block:: ini + + [settings] + debug=yes + other_config=10 + +command line argments have yet another conventions: + +.. code-block:: sh + + $ ./app.py --debug=yes --another-config=10 + +Prettyconf let's you follow these aesthetics patterns by setting a +``var_format`` function when instantiating the :doc:`loaders`. + +By default, the :py:class:`Environment` is +instantiated with ``var_format=str.upper`` so that lookups play nice with the +env variables. + +.. code-block:: python + + from prettyconf import Configuration + from prettyconf.loaders import Environment + + config = Configuration(loaders=[Environment(var_format=str.upper)]) + debug = config('debug', default=False, cast=config.boolean) # lookups for DEBUG=[yes|no] + + +Writing your own loader ++++++++++++++++++++++++ + +If you need a custom loader, you should just extend the :py:class:`AbstractConfigurationLoader`. + +.. autoclass:: prettyconf.loaders.AbstractConfigurationLoader + +For example, say you want to write a Yaml loader. It is important to note +that by raising a ``KeyError`` exception from the loader, prettyconf knows +that it has to keep looking down the loaders chain for a specific config. + +.. code-block:: python + + import yaml + from prettyconf.loaders import AbstractConfigurationLoader + + class YamlFile(AbstractConfigurationLoader): + def __init__(self, filename): + self.filename = filename + self.config = None + + def _parse(self): + if self.config is not None: + return + with open(self.filename, 'r') as f: + self.config = yaml.load(f) + + def __contains__(self, item): + try: + self._parse() + except: + return False + + return item in self.config + + def __getitem__(self, item): + try: + self._parse() + except: + # KeyError tells prettyconf to keep looking elsewhere! + raise KeyError("{!r}".format(item)) + + return self.config[item] + + +Then configure prettyconf to use it. + +.. code-block:: python + + from prettyconf import config + config.loaders = [YamlFile('config.yml')] + + diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 0000000..4c2f019 --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,10 @@ +Changelog +--------- + +All notable changes to this project will be documented in this file. + +This project adheres to `Semantic Versioning`_. + +.. _`Semantic Versioning`: https://semver.org/spec/v2.0.0.html + +.. include:: ../../CHANGES.txt diff --git a/docs/source/conf.py b/docs/source/conf.py index 3fa1dbc..67381a8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,7 +22,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('../..')) # -- General configuration ------------------------------------------------ @@ -32,7 +32,10 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = ['sphinx.ext.autodoc'] + +# Both the class’ and the __init__ method’s docstring are concatenated and inserted. +autoclass_content = 'both' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/docs/source/faq.rst b/docs/source/faq.rst index d7148b4..5a7e34a 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -26,6 +26,16 @@ But this code have some issues: file that will be used if `DEBUG` *envvar* is not defined. +Is prettyconf tied to Django_ or Flask_? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +No, prettyconf was designed to be framework agnostic, be it for the web or cli +applications. + +.. _`Django`: https://www.djangoproject.com/ +.. _`Flask`: http://flask.pocoo.org/ + + What is the difference between prettyconf and python-decouple_? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -35,6 +45,11 @@ highly inspired in ``python-decouple`` and provides almost the same API. The implementation of ``prettyconf`` is more extensible and flexible to make behaviour configurations easier. +You can use any of them. Both are good libraries and provides a similar set of +features. + +.. _`python-decouple`: https://github.com/henriquebastos/python-decouple + Why you created a library similar to python-decouple instead of use it? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -45,25 +60,33 @@ incompatible, so, it could break software that relies on the old behaviour. Besides that it's hard to make this change on ``python-decouple`` due to the way it's implemented. -See the lookup order of configurations below:: +See the lookup order of configurations below - +---------------+-----------------+------------------------+-------------------------+ - | Lookup Order | prettyconf | python-decouple (<3.0) | python-decouple (>=3.0) | - +---------------+-----------------+------------------------+-------------------------+ - | 1 | ENVVAR | .env | ENVVAR | - | 2 | .env | settings.ini | .env | - | 3 | *.cfg or *.ini | ENVVAR | settings.ini | - +---------------+-----------------+------------------------+-------------------------+ ++---------------+------------------+------------------------+-------------------------+ +| Lookup Order | prettyconf | python-decouple (<3.0) | python-decouple (>=3.0) | ++===============+==================+========================+=========================+ +| 1 | ENVVAR | .env | ENVVAR | ++---------------+------------------+------------------------+-------------------------+ +| 2 | .env | settings.ini | .env | ++---------------+------------------+------------------------+-------------------------+ +| 3 | \*.cfg or \*.ini | ENVVAR | settings.ini | ++---------------+------------------+------------------------+-------------------------+ .. _some: https://github.com/henriquebastos/python-decouple/pull/4 .. _contributions: https://github.com/henriquebastos/python-decouple/pull/5 -Why use ``prettyconf`` instead of ``python-decouple``? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +How does prettyconf compare to python-dotenv? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can use any of them. Both are good libraries and provides a similar set of -features. +python-dotenv_ reads the key, value pair from .env file and adds them to +environment variable. It is good for some tools that simply proxy the env to +some other process, like docker-compose_ or pipenv_. +On the other hand, prettyconf does not populate the ``os.environ`` dictionary, +because it is designed to discover configuration from diferent sources, the +environment being just one of them. -.. _`python-decouple`: https://github.com/henriquebastos/python-decouple +.. _`python-dotenv`: https://github.com/theskumar/python-dotenv +.. _`pipenv`: https://pipenv.readthedocs.io/en/latest/advanced/#automatic-loading-of-env +.. _`docker-compose`: https://docs.docker.com/compose/env-file/ diff --git a/docs/source/index.rst b/docs/source/index.rst index 0027eb9..935cb1c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,7 +14,10 @@ Contents: introduction.rst installation.rst usage.rst + advanced.rst + loaders.rst faq.rst + changelog.rst Indices and tables diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 6f2c4f3..815fc03 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -1,8 +1,39 @@ What's prettyconf ----------------- -Pretty Conf is a Python library created to make easy the separation of -configuration and code following the recomendations of `12 Factor`_'s topic -about configs. +Prettyconf is a framework agnostic python library created to make easy the +separation of configuration and code following the recomendations of `12 +Factor`_'s topic about configs. + + +Motivation +++++++++++ + +Configuration is just another API of you app, aimed for users who will install +and run it, that allows them to *preset* the state of a program, without having +to interact with it, only through static files or environment variables. + +It is an important aspect of the architecture of any system, yet it is +sometimes overlooked. + +It is important to provide a clear separation of configuration and code. This +is because config varies substantially across deploys and executions, code +should not. The same code can be run inside a container or in a regular +machine, it can be executed in production or in testing environments. + +Well designed applications allow different ways to be configured. A proper +settings-discoverability chain goes as follows: + +1. First CLI args are checked. +2. Then Environment variables. +3. Config files in different directories, that also imply some hierarchy. For + example: config files in ``/etc/myapp/settings.ini`` are applied + system-wide, while ``~/.config/myapp/settings.ini`` take precedence and are + user-specific. +4. Hardcoded constants. + +This raises the need to consolidate configuration in a single source of truth +to avoid having config management scattered all over the codebase. + .. _`12 Factor`: http://12factor.net/ diff --git a/docs/source/loaders.rst b/docs/source/loaders.rst new file mode 100644 index 0000000..7357d07 --- /dev/null +++ b/docs/source/loaders.rst @@ -0,0 +1,199 @@ +Configuration Loaders +--------------------- + +Loaders are in charge of loading configuration from various sources, like +``.ini`` files or *environment* variables. Loaders are ment to chained, so that +prettyconf checks one by one for a given configuration variable. + +Prettyconf comes with some loaders already included in ``prettyconf.loaders``. + +.. seealso:: + Some loaders include a ``var_format`` callable argument, see + :ref:`variable-naming` to read more about it's purpose. + + + +Environment ++++++++++++ + +.. autoclass:: prettyconf.loaders.Environment + +The ``Environment`` loader gets configuration from ``os.environ``. Since it +is a common pattern to write env variables in caps, the loader accepts a +``var_format`` function to pre-format the variable name before the lookup +occurs. By default it is ``str.upper()``. + +.. code-block:: python + + from prettyconf import config + from prettyconf.loaders import Environment + + + config.loaders = [Environment(var_format=str.upper)] + config('debug') # will look for a `DEBUG` variable + + +EnvFile ++++++++ + +.. autoclass:: prettyconf.loaders.EnvFile + +The ``EnvFile`` loader gets configuration from ``.env`` file. If the file +doesn't exist, this loader will be skipped without raising any errors. + +.. code-block:: text + + # .env file + DEBUG=1 + + +.. code-block:: python + + from prettyconf import config + from prettyconf.loaders import EnvFile + + + config.loaders = [EnvFile(file='.env', required=True, var_format=str.upper)] + config('debug') # will look for a `DEBUG` variable + + +.. note:: + You might want to use dump-env_, a utility to create ``.env`` files. + + +.. _`dump-env`: https://github.com/sobolevn/dump-env + + +IniFile ++++++++ + +.. autoclass:: prettyconf.loaders.IniFile + +The ``IniFile`` loader gets configuration from ``.ini`` or ``.cfg`` files. If +the file doesn't exist, this loader will be skipped without raising any errors. + + +CommandLine ++++++++++++ + +.. autoclass:: prettyconf.loaders.CommandLine + +This loader lets you extract configuration variables from parsed CLI arguments. +By default it works with `argparse`_ parsers. + + +.. code-block:: python + + from prettyconf import Configuration, NOT_SET + from prettyconf.loaders import CommandLine + + import argparse + + + parser = argparse.ArgumentParser(description='Does something useful.') + parser.add_argument('--debug', '-d', dest='debug', default=NOT_SET, help='set debug mode') + + config = Configuration(loaders=[CommandLine(parser=parser)]) + print(config('debug', default=False, cast=config.boolean)) + + +Something to notice here is the :py:const:`NOT_SET` value. CLI parsers often force you +to put a default value so that they don't fail. In that case, to play nice with +prettyconf, you must set one. But that would break the discoverability chain +that prettyconf encourages. So by setting this special default value, you will +allow prettyconf to keep the lookup going. + +The :py:func:`get_args` function converts the +argparse parser's values to a dict that ignores +:py:const:`NOT_SET` values. + + +.. _argparse: https://docs.python.org/3/library/argparse.html + + +RecursiveSearch ++++++++++++++++ + +.. autoclass:: prettyconf.loaders.RecursiveSearch + +This loader tries to find ``.env`` or ``*.ini|*.cfg`` files and load them with +the :py:class:`EnvFile` and +:py:class:`IniFile` loaders respectively. It will +start at the ``starting_path`` directory to look for configuration files. + +.. warning:: + It is important to note that this loader uses the glob module internally to + discover ``.env`` and ``*.ini|*.cfg`` files. This could be problematic if + the project includes many files that are unrelated, like a ``pytest.ini`` + file along side with a ``settings.ini``. An unexpected file could be found + and be considered as the configuration to use. + +Consider the following file structure: + +.. code-block:: text + + project/ + settings.ini + app/ + settings.py + +When instantiating your +:py:class:`RecursiveSearch`, if you pass +``/absolute/path/to/project/app/`` as ``starting_path`` the loader will start +looking for configuration files at ``project/app``. + +.. code-block:: python + + # Code example in project/app/settings.py + import os + + from prettyconf import config + from prettyconf.loaders import RecursiveSearch + + app_path = os.path.dirname(__file__) + config.loaders = [RecursiveSearch(starting_path=app_path)] + +By default, the loader will try to look for configuration files until it finds +valid configuration files **or** it reaches ``root_path``. The ``root_path`` is +set to the root directory ``/`` initialy. + +Consider the following file structure: + +.. code-block:: text + + /projects/ + any_settings.ini + project/ + app/ + settings.py + +You can change this behaviour by setting any parent directory of the +``starting_path`` as the ``root_path`` when instantiating +:py:class:`RecursiveSearch`: + +.. code-block:: python + + # Code example in project/app/settings.py + import os + + from prettyconf import Configuration + from prettyconf.loaders import RecursiveSearch + + app_path = os.path.dirname(__file__) + project_path = os.path.realpath(os.path.join(app_path, '..')) + rs = RecursiveSearch(starting_path=app_path, root_path=project_path) + config = Configuration(loaders=[rs]) + +The example above will start looking for files at ``project/app/`` and will stop looking +for configuration files at ``project/``, actually never looking at ``any_settings.ini`` +and no configuration being loaded at all. + +The ``root_path`` must be a parent directory of ``starting_path``: + +.. code-block:: python + + # Code example in project/app/settings.py + from prettyconf.loaders import RecursiveSearch + + # /baz is not parent of /foo/bar, so this raises an InvalidPath exception here + rs = RecursiveSearch(starting_path="/foo/bar", root_path="/baz") diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 1340966..51e182a 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -33,6 +33,31 @@ The ``boolean`` cast converts strings values like ``On|Off``, ``1|0``, ``yes|no``, ``true|False`` into Python boolean ``True`` or ``False``. +Configuration files discovery +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default library will use the directory of the file where ``config()`` was +called as the start directory to look for configuration files. Consider the +following file structure: + +.. code-block:: text + + project/ + settings.ini + app/ + settings.py + +If you call ``config()`` from ``project/app/settings.py`` the library will +start looking for configuration files at ``project/app`` until it finds +``.env|*.ini|*.cfg`` files. + +.. seealso:: + This behavior is described more deeply on the + :py:class:`RecursiveSearch` loader. + :doc:`Loaders` will help you customize how configuration + discovery works. Find out more at :ref:`discovery-customization`. + + Casts ~~~~~ @@ -88,127 +113,6 @@ Useful third-parties casts parameters used in Django ``EMAIL_*`` configurations. -Advanced usage -~~~~~~~~~~~~~~ - -Most of the time you can use the ``prettyconf.config`` function to get your -settings and use the ``prettyconf``'s standard behaviour. But some times -you need to change this behaviour. - -To make this changes possible you can always create your own -``Configuration()`` instance and change it's default behaviour: - -.. code-block:: python - - from prettyconf.configuration import Configuration - - config = Configuration() - -.. warning:: ``prettyconf`` will skip configuration files inside ``.zip``, - ``.egg`` or wheel packages. - - -Set the starting path -+++++++++++++++++++++ - -By default library will use the directory of the file where ``config()`` was -called as the start directory to look for configuration files. Consider the -following file structure: - -.. code-block:: text - - project/ - settings.ini - app/ - settings.py - -If you call ``config()`` from ``project/app/settings.py`` the library will start looking -for configuration files at ``project/app``. - -You can change that behaviour, by setting a different ``starting_path`` when instantiating -your ``Configuration()``: - -.. code-block:: python - - # Code example in project/app/settings.py - import os - - from prettyconf.configuration import Configuration - - project_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) - config = Configuration(starting_path=project_path) - -The example above will start looking for files at ``project/`` instead of ``project/app``. - -You can also set ``starting_path`` attribute in ``prettyconf.config`` before use it: - -.. code-block:: python - - # Code example in project/app/settings.py - import os - - from prettyconf import config - - project_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) - config.starting_path = project_path - - -Set a different root path -+++++++++++++++++++++++++ - -By default, the library will try to look for configuration files until it finds -valid configuration files **or** it reaches ``root_path``. The default -``root_path`` is set to the root directory "``/``". - -Consider the following file structure: - -.. code-block:: text - - /projects/ - any_settings.ini - project/ - app/ - settings.py - -You can change this behaviour by setting any parent directory of the -``starting_path`` as the ``root_path`` when instantiating ``Configuration``: - -.. code-block:: python - - # Code example in project/app/settings.py - import os - - from prettyconf.configuration import Configuration - - project_path = os.path.realpath(os.path.join(app_path), '..')) - config = Configuration(root_path=project_path) - -The example above will start looking for files at ``project/app/`` and will stop looking -for configuration files at ``project/``, actually never looking at ``any_settings.ini`` -and no configuration being loaded at all. - -You can also set ``root_path`` attribute in ``prettyconf.config`` before use it: - -.. code-block:: python - - # Code example in project/app/settings.py - from prettyconf import config - - project_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..')) - config.root_path = project_path - -The ``root_path`` must be a parent directory of ``starting_path``: - -.. code-block:: python - - # Code example in project/app/settings.py - from prettyconf import config - - config.starting_path = "/foo/bar" - config.root_path = "/baz" # /baz is not parent of /foo/bar - - MY_CONFIG = config("PROJECT_MY_CONFIG") # raises an InvalidPath exception here - .. _dj-database-url: https://github.com/kennethreitz/dj-database-url .. _django-cache-url: https://github.com/ghickman/django-cache-url diff --git a/prettyconf/__init__.py b/prettyconf/__init__.py index 909bb43..db5a87f 100644 --- a/prettyconf/__init__.py +++ b/prettyconf/__init__.py @@ -1,7 +1,4 @@ -# coding: utf-8 - -# Use from prettyconf import config - from .configuration import Configuration +from .loaders import NOT_SET config = Configuration() diff --git a/prettyconf/casts.py b/prettyconf/casts.py index 1230ae6..ec4c06e 100644 --- a/prettyconf/casts.py +++ b/prettyconf/casts.py @@ -1,6 +1,3 @@ -# coding: utf-8 - - from .exceptions import InvalidConfiguration @@ -87,6 +84,7 @@ class Option(AbstractCast): "local": _INSTALLED_APPS + ("baz",) })) """ + def __init__(self, options): self.options = options diff --git a/prettyconf/configuration.py b/prettyconf/configuration.py index c167fd6..5cfac89 100644 --- a/prettyconf/configuration.py +++ b/prettyconf/configuration.py @@ -1,74 +1,20 @@ -# coding: utf-8 - - +import ast import os import sys -import ast - -from .loaders import EnvFileConfigurationLoader, IniFileConfigurationLoader, EnvVarConfigurationLoader -from .exceptions import InvalidConfigurationFile, InvalidPath, InvalidConfigurationCast, UnknownConfiguration -from .casts import Boolean, Option, List, Tuple - - -MAGIC_FRAME_DEPTH = 3 - - -class ConfigurationDiscovery(object): - default_filetypes = (EnvFileConfigurationLoader, IniFileConfigurationLoader) - - def __init__(self, starting_path, filetypes=None, root_path="/"): - """ - Setup the configuration file discovery. - - :param starting_path: The path to begin looking for configuration files - :param filetypes: tuple with configuration loaders. Defaults to - ``(EnvFileConfigurationLoader, IniFileConfigurationLoader)`` - :param root_path: Configuration lookup will stop at the given path. Defaults to - the current user directory - """ - self.starting_path = os.path.realpath(os.path.abspath(starting_path)) - self.root_path = os.path.realpath(root_path) - - if not self.starting_path.startswith(self.root_path): - raise InvalidPath('Invalid root path given') - if filetypes is None: - filetypes = self.default_filetypes +from .casts import Boolean, List, Option, Tuple +from .exceptions import UnknownConfiguration +from .loaders import Environment, RecursiveSearch - self.filetypes = filetypes - self._config_files = None +MAGIC_FRAME_DEPTH = 2 - def _scan_path(self, path): - config_files = [] - for file_type in self.filetypes: - for filename in file_type.get_filenames(path): - try: - config_files.append(file_type(filename)) - except InvalidConfigurationFile: - continue - - return config_files - - def _discover(self): - self._config_files = [] - - path = self.starting_path - while not self._config_files: - if os.path.isdir(path): - self._config_files += self._scan_path(path) - - if path == self.root_path: - break - - path = os.path.dirname(path) - - @property - def config_files(self): - if self._config_files is None: - self._discover() - - return self._config_files +def _caller_path(): + # MAGIC! Get the caller's module path. + # noinspection PyProtectedMember + frame = sys._getframe(MAGIC_FRAME_DEPTH) + path = os.path.dirname(frame.f_code.co_filename) + return path class Configuration(object): @@ -79,43 +25,25 @@ class Configuration(object): option = Option eval = staticmethod(ast.literal_eval) - def __init__(self, configs=None, starting_path=None, root_path="/"): - if configs is None: - configs = [EnvVarConfigurationLoader()] - self.configurations = configs - self.starting_path = starting_path - self.root_path = root_path - self._initialized = False - - @staticmethod - def _caller_path(): - # MAGIC! Get the caller's module path. - # noinspection PyProtectedMember - frame = sys._getframe(MAGIC_FRAME_DEPTH) - path = os.path.dirname(frame.f_code.co_filename) - return path - - def _init_configs(self): - if self._initialized: - return + def __init__(self, loaders=None): + if loaders is None: + loaders = [ + Environment(), + RecursiveSearch(starting_path=_caller_path()) + ] + self.loaders = loaders - if self.starting_path is None: - self.starting_path = self._caller_path() - - discovery = ConfigurationDiscovery(self.starting_path, root_path=self.root_path) - self.configurations.extend(discovery.config_files) - - self._initialized = True + def __repr__(self): + loaders = ', '.join([repr(l) for l in self.loaders]) + return 'Configuration(loaders=[{}])'.format(loaders) def __call__(self, item, cast=lambda v: v, **kwargs): if not callable(cast): - raise InvalidConfigurationCast("Cast must be callable") - - self._init_configs() + raise TypeError("Cast must be callable") - for configuration in self.configurations: + for loader in self.loaders: try: - return cast(configuration[item]) + return cast(loader[item]) except KeyError: continue diff --git a/prettyconf/exceptions.py b/prettyconf/exceptions.py index 78408d6..d5913f3 100644 --- a/prettyconf/exceptions.py +++ b/prettyconf/exceptions.py @@ -1,6 +1,3 @@ -# coding: utf-8 - - class ConfigurationException(Exception): pass @@ -19,7 +16,3 @@ class UnknownConfiguration(ConfigurationException): class InvalidConfiguration(ConfigurationException): pass - - -class InvalidConfigurationCast(ConfigurationException): - pass diff --git a/prettyconf/loaders.py b/prettyconf/loaders.py index acbab27..d1bb6b8 100644 --- a/prettyconf/loaders.py +++ b/prettyconf/loaders.py @@ -1,18 +1,42 @@ -# coding: utf-8 - -import sys import os +from configparser import ConfigParser, MissingSectionHeaderError, NoOptionError from glob import glob -try: - from ConfigParser import SafeConfigParser as ConfigParser, NoOptionError, MissingSectionHeaderError -except ImportError: - from configparser import ConfigParser, NoOptionError, MissingSectionHeaderError +from .exceptions import InvalidConfigurationFile, InvalidPath + + +class NotSet(str): + """ + A special type that behaves as a replacement for None. + We have to put a new default value to know if a variable has been set by + the user explicitly. This is useful for the ``CommandLine`` loader, when + CLI parsers force you to set a default value, and thus, break the discovery + chain. + """ + pass + + +NOT_SET = NotSet() + -from .exceptions import InvalidConfigurationFile +def get_args(parser): + """ + Converts arguments extracted from a parser to a dict, + and will dismiss arguments which default to NOT_SET. + + :param parser: an ``argparse.ArgumentParser`` instance. + :type parser: argparse.ArgumentParser + :return: Dictionary with the configs found in the parsed CLI arguments. + :rtype: dict + """ + args = vars(parser.parse_args()).items() + return {key: val for key, val in args if not isinstance(val, NotSet)} class AbstractConfigurationLoader(object): + def __repr__(self): + raise NotImplementedError() # pragma: no cover + def __contains__(self, item): raise NotImplementedError() # pragma: no cover @@ -20,42 +44,122 @@ def __getitem__(self, item): raise NotImplementedError() # pragma: no cover -class EnvVarConfigurationLoader(AbstractConfigurationLoader): +class CommandLine(AbstractConfigurationLoader): + """ + Extract configuration from an ``argparse`` parser. + """ + + # noinspection PyShadowingNames + def __init__(self, parser, get_args=get_args): + """ + :param parser: An `argparse` parser instance to extract variables from. + :param function get_args: A function to extract args from the parser. + :type parser: argparse.ArgumentParser + """ + self.parser = parser + self.configs = get_args(self.parser) + + def __repr__(self): + return 'CommandLine(parser={})'.format(self.parser) + def __contains__(self, item): - return item in os.environ + return item in self.configs def __getitem__(self, item): - return os.environ[item] + return self.configs[item] -class AbstractFileConfigurationLoader(AbstractConfigurationLoader): - patterns = () +class IniFile(AbstractConfigurationLoader): + def __init__(self, filename, section="settings", var_format=lambda x: x): + """ + :param str filename: Path to the ``.ini/.cfg`` file. + :param str section: Section name inside the config file. + :param function var_format: A function to pre-format variable names. + """ + self.filename = filename + self.section = section + self.var_format = var_format + self.parser = ConfigParser(allow_no_value=True) + self._initialized = False - @classmethod - def get_filenames(cls, path): - filenames = [] - for pattern in cls.patterns: - filenames += glob(os.path.join(path, pattern)) - return filenames + def __repr__(self): + return 'IniFile("{}")'.format(self.filename) + + def _parse(self): + + if self._initialized: + return + + with open(self.filename) as inifile: + try: + self.parser.read_file(inifile) + except (UnicodeDecodeError, MissingSectionHeaderError): + raise InvalidConfigurationFile() + + if not self.parser.has_section(self.section): + raise InvalidConfigurationFile("Missing [{}] section in {}".format(self.section, self.filename)) + + self._initialized = True + + def __contains__(self, item): + try: + self._parse() + except FileNotFoundError: + return False + + return self.parser.has_option(self.section, self.var_format(item)) def __getitem__(self, item): - raise NotImplementedError() # pragma: no cover + try: + self._parse() + except FileNotFoundError: + raise KeyError("{!r}".format(item)) + + try: + return self.parser.get(self.section, self.var_format(item)) + except NoOptionError: + raise KeyError("{!r}".format(item)) + + +class Environment(AbstractConfigurationLoader): + """ + Get's configuration from the environment, by inspecting ``os.environ``. + """ + + def __init__(self, var_format=str.upper): + """ + :param function var_format: A function to pre-format variable names. + """ + self.var_format = var_format + + def __repr__(self): + return 'Environment(var_format={}>'.format(self.var_format) def __contains__(self, item): - raise NotImplementedError() # pragma: no cover + return self.var_format(item) in os.environ + def __getitem__(self, item): + # Uses `os.environ` because it raises an exception if the environmental + # variable does not exist, whilst `os.getenv` doesn't. + return os.environ[self.var_format(item)] -class EnvFileConfigurationLoader(AbstractFileConfigurationLoader): - patterns = (".env", ) - def __init__(self, filename): +class EnvFile(AbstractConfigurationLoader): + def __init__(self, filename='.env', var_format=str.upper): + """ + :param str filename: Path to the ``.env`` file. + :param function var_format: A function to pre-format variable names. + """ self.filename = filename + self.var_format = var_format self.configs = None + def __repr__(self): + return 'EnvFile("{}")'.format(self.filename) + @staticmethod def _parse_line(line): key = [] - pos = 0 comment = "" # parse key @@ -116,6 +220,10 @@ def _parse_line(line): return key, value def _parse(self): + + if self.configs is not None: + return + self.configs = {} with open(self.filename) as envfile: for line in envfile: @@ -127,51 +235,97 @@ def _parse(self): self.configs[key] = value def __contains__(self, item): - if self.configs is None: + try: self._parse() + except FileNotFoundError: + return False - return item in self.configs + return self.var_format(item) in self.configs def __getitem__(self, item): - if self.configs is None: + try: self._parse() + except FileNotFoundError: + raise KeyError("{!r}".format(item)) - return self.configs[item] + return self.configs[self.var_format(item)] -class IniFileConfigurationLoader(AbstractFileConfigurationLoader): - default_section = "settings" - patterns = ("*.ini", "*.cfg") +class RecursiveSearch(AbstractConfigurationLoader): + def __init__(self, starting_path, filetypes=(('.env', EnvFile), (('*.ini', '*.cfg',), IniFile),), root_path="/"): + """ + :param str starting_path: The path to begin looking for configuration files. + :param tuple filetypes: tuple of tuples with configuration loaders, order matters. + Defaults to + ``(('*.env', EnvFile), (('*.ini', *.cfg',), IniFile)`` + :param str root_path: Configuration lookup will stop at the given path. Defaults to + the current user directory + """ + self.starting_path = os.path.realpath(os.path.abspath(starting_path)) + self.root_path = os.path.realpath(root_path) - def __init__(self, filename, section=None): - self.filename = filename + if not self.starting_path.startswith(self.root_path): + raise InvalidPath('Invalid root path given') - if not section: - section = self.default_section + self.filetypes = filetypes + self._config_files = None - self.section = section + @staticmethod + def get_filenames(path, patterns): + filenames = [] + if type(patterns) is str: + patterns = (patterns,) - self.parser = ConfigParser(allow_no_value=True) + for pattern in patterns: + filenames += glob(os.path.join(path, pattern)) + return filenames - with open(self.filename) as inifile: - try: - if sys.version_info[0] < 3: - # ConfigParser.readfp is deprecated for Python3, read_file replaces it - # noinspection PyDeprecation - self.parser.readfp(inifile) - else: - self.parser.read_file(inifile) - except (UnicodeDecodeError, MissingSectionHeaderError): - raise InvalidConfigurationFile() + def _scan_path(self, path): + config_files = [] - if not self.parser.has_section(self.section): - raise InvalidConfigurationFile("Missing [{}] section in {}".format(self.section, self.filename)) + for patterns, Loader in self.filetypes: + for filename in self.get_filenames(path, patterns): + try: + config_files.append(Loader(filename=filename)) + except InvalidConfigurationFile: + continue + + return config_files + + def _discover(self): + self._config_files = [] + + path = self.starting_path + while not self._config_files: + if os.path.isdir(path): + self._config_files += self._scan_path(path) + + if path == self.root_path: + break + + path = os.path.dirname(path) + + @property + def config_files(self): + if self._config_files is None: + self._discover() + + return self._config_files + + def __repr__(self): + return 'RecursiveSearch(starting_path={})'.format(self.starting_path) def __contains__(self, item): - return self.parser.has_option(self.section, item) + for config_file in self.config_files: + if item in config_file: + return True + return False def __getitem__(self, item): - try: - return self.parser.get(self.section, item) - except NoOptionError: + for config_file in self.config_files: + try: + return config_file[item] + except KeyError: + continue + else: raise KeyError("{!r}".format(item)) diff --git a/requirements.txt b/requirements.txt index 4f69723..3015234 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ nose==1.3.7 Sphinx==1.3.1 sphinx-rtd-theme==0.1.8 -tox==2.3.1 diff --git a/setup.py b/setup.py index 19eff28..2e95911 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,3 @@ -# coding: utf-8 - import os import re @@ -11,7 +9,7 @@ with open(os.path.join(here, "CHANGES.txt")) as changes: for line in changes: version = line.strip() - if re.search('^[0-9]+\.[0-9]+(\.[0-9]+)?$', version): + if re.search(r'^[0-9]+\.[0-9]+(\.[0-9]+)?$', version): break diff --git a/tests/base.py b/tests/base.py index d3d352c..589d7df 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,10 +1,6 @@ -# coding: utf-8 - - import os from unittest import TestCase - TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "files") diff --git a/tests/files/config.ini b/tests/files/config.ini index 03c5d1a..94d0c6d 100644 --- a/tests/files/config.ini +++ b/tests/files/config.ini @@ -11,3 +11,5 @@ SINGLE_QUOTE_SPACE = ' text' DOUBLE_QUOTE_SPACE = " text" UPDATED=text #COMMENTED_KEY=None + +_var=test diff --git a/tests/files/envfile b/tests/files/envfile index 078ef19..b522c34 100644 --- a/tests/files/envfile +++ b/tests/files/envfile @@ -17,3 +17,5 @@ CACHE_URL_QUOTES="cache+memcached://foo:bar@localhost:11211/?n=1&x=2,5" CACHE_URL=cache+memcached://foo:bar@localhost:11211/?n=1&x=2,5 DOUBLE_QUOTE_INSIDE_QUOTE='foo "bar" baz' SINGLE_QUOTE_INSIDE_QUOTE="foo 'bar' baz" + +_var=test diff --git a/tests/files/recursive/valid/.env b/tests/files/recursive/valid/.env new file mode 100644 index 0000000..c075a74 --- /dev/null +++ b/tests/files/recursive/valid/.env @@ -0,0 +1 @@ +FOO=bar diff --git a/tests/files/recursive/valid/invalid/invalid.txt b/tests/files/recursive/valid/invalid/invalid.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/files/recursive/valid/settings.ini b/tests/files/recursive/valid/settings.ini new file mode 100644 index 0000000..01e8d82 --- /dev/null +++ b/tests/files/recursive/valid/settings.ini @@ -0,0 +1,2 @@ +[settings] +foo=bar diff --git a/tests/test_casts.py b/tests/test_casts.py index 0a5f68f..fde7fd9 100644 --- a/tests/test_casts.py +++ b/tests/test_casts.py @@ -1,10 +1,8 @@ -# coding: utf-8 - - from unittest import TestCase -from prettyconf.casts import Boolean, Option, InvalidConfiguration, List, Tuple from prettyconf import config +from prettyconf.casts import Boolean, List, Option, Tuple +from prettyconf.exceptions import InvalidConfiguration class BooleanCastTestCase(TestCase): diff --git a/tests/test_cfgfile.py b/tests/test_cfgfile.py index 6380733..b54f4b1 100644 --- a/tests/test_cfgfile.py +++ b/tests/test_cfgfile.py @@ -1,8 +1,6 @@ -# coding: utf-8 - +from prettyconf.loaders import IniFile, InvalidConfigurationFile from .base import BaseTestCase -from prettyconf.loaders import IniFileConfigurationLoader, InvalidConfigurationFile class IniFileTestCase(BaseTestCase): @@ -10,12 +8,17 @@ def setUp(self): super(IniFileTestCase, self).setUp() self.inifile = self.test_files_path + "/config.ini" + def test_basic_config_object(self): + config = IniFile(self.inifile) + + self.assertEqual(repr(config), 'IniFile("{}")'.format(self.inifile)) + def test_fail_invalid_settings_file(self): with self.assertRaises(InvalidConfigurationFile): - IniFileConfigurationLoader(self.test_files_path + "/invalid_section.ini") + return IniFile(self.test_files_path + "/invalid_section.ini")['some_value'] def test_config_file_parsing(self): - config = IniFileConfigurationLoader(self.inifile) + config = IniFile(self.inifile) self.assertEqual(config["KEY"], "Value") self.assertEqual(config["KEY_EMPTY"], "") @@ -36,8 +39,36 @@ def test_config_file_parsing(self): # ConfigParser did not allow empty configs with inline comment # self.assertEqual(config["KEY_EMPTY_WITH_COMMENTS"], "") - def test_list_config_filenames(self): - filenames = IniFileConfigurationLoader.get_filenames(self.test_files_path) - self.assertEqual(len(filenames), 3) - self.assertIn(self.test_files_path + "/config.ini", filenames) - self.assertIn(self.test_files_path + "/invalid_section.ini", filenames) + def test_skip_missing_key(self): + with self.assertRaises(KeyError): + return IniFile(self.inifile)['some_value'] + + def test_skip_invalid_ini_file(self): + config = IniFile(self.test_files_path + "/invalid_chars.cfg") + + with self.assertRaises(InvalidConfigurationFile): + return config['some_value'] + + def test_default_var_format(self): + config = IniFile(self.inifile) + + self.assertIn("_var", config) + self.assertEqual("test", config["_var"]) + + def test_custom_var_format(self): + def formatter(x): + return '_{}'.format(str.lower(x)) + + config = IniFile(self.inifile, var_format=formatter) + + self.assertIn("VAR", config) + self.assertEqual("test", config["VAR"]) + + def test_fail_missing_envfile_contains(self): + config = IniFile("does-not-exist.ini") + self.assertNotIn('error', config) + + def test_fail_missing_envfile_get_item(self): + config = IniFile("does-not-exist.ini") + with self.assertRaises(KeyError): + return config['error'] diff --git a/tests/test_commandline.py b/tests/test_commandline.py new file mode 100644 index 0000000..8806221 --- /dev/null +++ b/tests/test_commandline.py @@ -0,0 +1,52 @@ +import argparse + +from prettyconf.loaders import CommandLine, NOT_SET +from .base import BaseTestCase + + +def parser_factory(): + parser = argparse.ArgumentParser(description='Process some integers.') + parser.add_argument('--var', '-v', dest='var', default=NOT_SET, help='set var') + parser.add_argument('--var2', '-b', dest='var2', default='foo', help='set var2') + return parser + + +def test_parse_args(): + # mock function used to always parse known args + parser = parser_factory() + return parser.parse_args([]) + + +class CommandLineTestCase(BaseTestCase): + def setUp(self): + super(CommandLineTestCase, self).setUp() + parser = parser_factory() + parser.parse_args = test_parse_args + self.config = CommandLine(parser=parser) + + def test_basic_config(self): + self.assertTrue(repr(self.config).startswith('CommandLine(parser=')) + self.assertEquals(self.config['var2'], 'foo') + + def test_ignores_NOT_SET_values(self): + with self.assertRaises(KeyError): + return self.config['var'] + + def test_ignores_missing_keys(self): + with self.assertRaises(KeyError): + return self.config['var3'] + + def test_does_not_ignore_set_values(self): + parser = parser_factory() + + def test_args(): + _parser = parser_factory() + return _parser.parse_args(['--var=bar', '-b', 'bar2']) + + parser.parse_args = test_args + config = CommandLine(parser=parser) + self.assertEquals(config['var'], 'bar') + self.assertEquals(config['var2'], 'bar2') + + def test_contains_missing_keys(self): + self.assertNotIn('var3', self.config) diff --git a/tests/test_config.py b/tests/test_config.py index 64cdcb8..933b7e9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,12 +1,9 @@ -# coding: utf-8 - - import os -from .base import BaseTestCase -from prettyconf.exceptions import UnknownConfiguration, InvalidConfigurationCast from prettyconf.configuration import Configuration - +from prettyconf.exceptions import UnknownConfiguration +from prettyconf.loaders import EnvFile, Environment, IniFile +from .base import BaseTestCase ENVFILE_CONTENT = """ ENVFILE=Environment File Value @@ -23,33 +20,38 @@ class ConfigTestCase(BaseTestCase): def setUp(self): super(ConfigTestCase, self).setUp() - self._create_file(self.test_files_path + "/../.env", ENVFILE_CONTENT) - self._create_file(self.test_files_path + "/../settings.ini", INIFILE_CONTENT) + self.envfile = self.test_files_path + "/../.env" + self.inifile = self.test_files_path + "/../settings.ini" + self._create_file(self.envfile, ENVFILE_CONTENT) + self._create_file(self.inifile, INIFILE_CONTENT) def test_basic_config(self): os.environ["ENVVAR"] = "Environment Variable Value" config = Configuration() + self.assertTrue(repr(config).startswith("Configuration(loaders=[")) self.assertEqual(config("ENVVAR"), "Environment Variable Value") self.assertEqual(config("ENVFILE"), "Environment File Value") self.assertEqual(config("INIFILE"), "INI File Value") - self.assertEqual(len(config.configurations), 3) # envvar + .env + settings.ini + self.assertEqual(len(config.loaders), 2) # Environment + RecursiveSearch + del os.environ["ENVVAR"] - def test_basic_config_with_starting_path(self): + def test_customized_loaders(self): os.environ["ENVVAR"] = "Environment Variable Value" - starting_path = os.path.dirname(__file__) - config = Configuration(starting_path=starting_path) - self.assertEqual(config("ENVVAR"), "Environment Variable Value") + os.environ["ENVVAR2"] = "Foo" + loaders = [EnvFile(self.envfile), Environment(), IniFile(self.inifile)] + config = Configuration(loaders=loaders) + self.assertEqual(config("ENVVAR"), "Must be overrided") + self.assertEqual(config("ENVVAR2"), "Foo") self.assertEqual(config("ENVFILE"), "Environment File Value") self.assertEqual(config("INIFILE"), "INI File Value") - self.assertEqual(len(config.configurations), 3) # envvar + .env + settings.ini + self.assertEqual(len(config.loaders), 3) + del os.environ["ENVVAR"] + del os.environ["ENVVAR2"] def test_from_import_basic_config(self): from prettyconf import config - os.environ["ENVVAR"] = "Environment Variable Value" - self.assertEqual(config("ENVVAR"), "Environment Variable Value") - self.assertEqual(config("ENVFILE"), "Environment File Value") - self.assertEqual(config("INIFILE"), "INI File Value") - self.assertEqual(len(config.configurations), 3) # envvar + .env + settings.ini + + self.assertIsInstance(config, Configuration) def test_config_default_values(self): config = Configuration() @@ -63,7 +65,7 @@ def test_config_cast_value(self): def test_fail_invalid_cast_type(self): os.environ["INTEGER"] = "42" config = Configuration() - with self.assertRaises(InvalidConfigurationCast): + with self.assertRaises(TypeError): config("INTEGER", cast="not callable") def test_fail_unknown_config_without_default_value(self): diff --git a/tests/test_envfile.py b/tests/test_envfile.py index 526211b..e99ed57 100644 --- a/tests/test_envfile.py +++ b/tests/test_envfile.py @@ -1,10 +1,7 @@ -# coding: utf-8 - - import os +from prettyconf.loaders import EnvFile from .base import BaseTestCase -from prettyconf.loaders import EnvFileConfigurationLoader class EnvFileTestCase(BaseTestCase): @@ -12,8 +9,13 @@ def setUp(self): super(EnvFileTestCase, self).setUp() self.envfile = os.path.join(self.test_files_path, "envfile") + def test_basic_config_object(self): + config = EnvFile(self.envfile) + + self.assertEqual(repr(config), 'EnvFile("{}")'.format(self.envfile)) + def test_config_file_parsing(self): - config = EnvFileConfigurationLoader(self.envfile) + config = EnvFile(self.envfile) self.assertEqual(config["KEY"], "Value") self.assertEqual(config["KEY_EMPTY"], "") @@ -32,15 +34,32 @@ def test_config_file_parsing(self): self.assertEqual(config["SINGLE_QUOTE_INSIDE_QUOTE"], "foo 'bar' baz") def test_missing_invalid_keys_in_config_file_parsing(self): - config = EnvFileConfigurationLoader(self.envfile) + config = EnvFile(self.envfile) self.assertNotIn("COMMENTED_KEY", config) self.assertNotIn("INVALID_KEY", config) self.assertNotIn("OTHER_INVALID_KEY", config) - def test_list_config_filenames(self): - self._create_file(self.test_files_path + "/.env") - filenames = EnvFileConfigurationLoader.get_filenames(self.test_files_path) + def test_default_var_format(self): + config = EnvFile(self.envfile) + + self.assertIn("key", config) + self.assertEqual("Value", config["key"]) + + def test_custom_var_format(self): + def formatter(x): + return '_{}'.format(str.lower(x)) + + config = EnvFile(self.envfile, var_format=formatter) + + self.assertIn("VAR", config) + self.assertEqual("test", config["VAR"]) + + def test_fail_missing_envfile_contains(self): + config = EnvFile("does-not-exist.env") + self.assertNotIn('error', config) - self.assertEqual(len(filenames), 1) - self.assertEqual(self.test_files_path + "/.env", filenames[0]) + def test_fail_missing_envfile_get_item(self): + config = EnvFile("does-not-exist.env") + with self.assertRaises(KeyError): + return config['error'] diff --git a/tests/test_envvar.py b/tests/test_envvar.py index f4da6a7..4c13c75 100644 --- a/tests/test_envvar.py +++ b/tests/test_envvar.py @@ -1,25 +1,44 @@ -# coding: utf-8 - - import os +from prettyconf.loaders import Environment from .base import BaseTestCase -from prettyconf.loaders import EnvVarConfigurationLoader -class EnvVarTestCase(BaseTestCase): +class EnvironmentTestCase(BaseTestCase): def test_basic_config(self): os.environ["TEST"] = "test" - config = EnvVarConfigurationLoader() + config = Environment() self.assertIn("TEST", config) self.assertEqual("test", config["TEST"]) + self.assertTrue(repr(config).startswith('Environment(var_format=')) del os.environ["TEST"] def test_fail_missing_config(self): - config = EnvVarConfigurationLoader() + config = Environment() self.assertNotIn("UNKNOWN", config) with self.assertRaises(KeyError): _ = config["UNKNOWN"] + + def test_default_var_format(self): + os.environ["TEST"] = "test" + config = Environment() + + self.assertIn("test", config) + self.assertEqual("test", config["test"]) + + del os.environ["TEST"] + + def test_custom_var_format(self): + def formatter(x): + return '_{}'.format(x) + + os.environ["_TEST"] = "test" + config = Environment(var_format=formatter) + + self.assertIn("TEST", config) + self.assertEqual("test", config["TEST"]) + + del os.environ["_TEST"] diff --git a/tests/test_filediscover.py b/tests/test_filediscover.py index 19e9386..253e9b8 100644 --- a/tests/test_filediscover.py +++ b/tests/test_filediscover.py @@ -1,66 +1,58 @@ -# coding: utf-8 - -from __future__ import unicode_literals - import os import shutil import tempfile -from prettyconf.configuration import ConfigurationDiscovery from prettyconf.exceptions import InvalidPath -from prettyconf.loaders import IniFileConfigurationLoader +from prettyconf.loaders import RecursiveSearch from .base import BaseTestCase -# noinspection PyStatementEffect -class ConfigFilesDiscoveryTestCase(BaseTestCase): +class RecursiveSearchTestCase(BaseTestCase): def setUp(self): - super(ConfigFilesDiscoveryTestCase, self).setUp() + super(RecursiveSearchTestCase, self).setUp() self.tmpdirs = [] def tearDown(self): - super(ConfigFilesDiscoveryTestCase, self).tearDown() + super(RecursiveSearchTestCase, self).tearDown() for tmpdir in self.tmpdirs: shutil.rmtree(tmpdir, ignore_errors=True) def test_config_file_parsing(self): self._create_file(self.test_files_path + "/../.env") - self._create_file(self.test_files_path + "/../setup.cfg") # invalid settings - self._create_file(self.test_files_path + "/../settings.ini", "[settings]") - discovery = ConfigurationDiscovery(os.path.dirname(self.test_files_path)) + self._create_file(self.test_files_path + "/../setup.txt") # invalid settings + self._create_file(self.test_files_path + "/../settings.ini", "[settings]\nfoo=bar") + discovery = RecursiveSearch(os.path.dirname(self.test_files_path)) + self.assertTrue(repr(discovery).startswith("RecursiveSearch(starting_path=")) self.assertEqual(len(discovery.config_files), 2) # 2 *valid* files created - def test_should_not_look_for_parent_directory_when_it_finds_valid_configurations(self): - self._create_file(self.test_files_path + '/../../settings.ini', '[settings]') - self._create_file(self.test_files_path + '/../../.env') - self._create_file(self.test_files_path + '/../.env') - self._create_file(self.test_files_path + '/../settings.ini', '[settings]') + self.assertIn('foo', discovery) + self.assertEqual(discovery['foo'], 'bar') + self.assertNotIn('not_found', discovery) - discovery = ConfigurationDiscovery(os.path.dirname(self.test_files_path)) + def test_should_not_look_for_parent_directory_when_it_finds_valid_configurations(self): + starting_path = self.test_files_path + '/recursive/valid/' + discovery = RecursiveSearch(starting_path, root_path=self.test_files_path) self.assertEqual(len(discovery.config_files), 2) filenames = [cfg.filename for cfg in discovery.config_files] - self.assertIn(os.path.abspath(self.test_files_path + '/../.env'), filenames) - self.assertIn(os.path.abspath(self.test_files_path + '/../settings.ini'), filenames) + self.assertIn(starting_path + '.env', filenames) + self.assertIn(starting_path + 'settings.ini', filenames) def test_should_look_for_parent_directory_when_it_finds_invalid_configurations(self): - self._create_file(self.test_files_path + '/../../settings.ini', '[settings]') - self._create_file(self.test_files_path + '/../../.env') - self._create_file(self.test_files_path + '/../invalid.cfg', '') - self._create_file(self.test_files_path + '/../settings.ini', '') - - discovery = ConfigurationDiscovery(os.path.dirname(self.test_files_path)) + starting_path = self.test_files_path + '/recursive/valid/invalid/' + valid_path = self.test_files_path + '/recursive/valid/' + discovery = RecursiveSearch(starting_path, root_path=self.test_files_path) self.assertEqual(len(discovery.config_files), 2) filenames = [cfg.filename for cfg in discovery.config_files] - self.assertIn(os.path.abspath(self.test_files_path + '/../../.env'), filenames) - self.assertIn(os.path.abspath(self.test_files_path + '/../../settings.ini'), filenames) + self.assertIn(valid_path + '.env', filenames) + self.assertIn(valid_path + 'settings.ini', filenames) def test_default_root_path_should_default_to_root_directory(self): - discovery = ConfigurationDiscovery(os.path.dirname(self.test_files_path)) + discovery = RecursiveSearch(os.path.dirname(self.test_files_path)) assert discovery.root_path == "/" def test_root_path_should_be_parent_of_starting_path(self): with self.assertRaises(InvalidPath): - ConfigurationDiscovery('/foo', root_path='/foo/bar/baz/') + RecursiveSearch('/foo', root_path='/foo/bar/baz/') def test_use_configuration_from_root_path_when_no_other_was_found(self): root_dir = tempfile.mkdtemp() @@ -74,7 +66,7 @@ def test_use_configuration_from_root_path_when_no_other_was_found(self): file_.write('[settings]') self.files.append(test_file) # Required to removed it at tearDown - discovery = ConfigurationDiscovery(start_path, root_path=root_dir) + discovery = RecursiveSearch(start_path, root_path=root_dir) filenames = [cfg.filename for cfg in discovery.config_files] self.assertEqual([test_file], filenames) @@ -93,22 +85,5 @@ def test_lookup_should_stop_at_root_path(self): root_dir = os.path.join(test_dir, 'some', 'dirs') # No settings here - discovery = ConfigurationDiscovery(start_path, root_path=root_dir) - self.assertEqual(discovery.config_files, []) - - def test_inifile_discovery_should_ignore_invalid_files_without_raising_exception(self): - root_dir = tempfile.mkdtemp() - self.tmpdirs.append(root_dir) - - cfg_dir = os.path.join(root_dir, "some/strange") - os.makedirs(cfg_dir) - - with open(os.path.join(cfg_dir, "config.cfg"), "wb") as cfg_file: - cfg_file.write('&ˆ%$#$%ˆ&*()(*&ˆ'.encode('utf8')) - self.files.append(cfg_file.name) - - with open(os.path.join(root_dir, "some/config.ini"), "wb") as cfg_file: - cfg_file.write('$#%ˆ&*((*&ˆ%'.encode('utf8')) - - discovery = ConfigurationDiscovery(cfg_dir, filetypes=(IniFileConfigurationLoader,)) + discovery = RecursiveSearch(start_path, root_path=root_dir) self.assertEqual(discovery.config_files, []) diff --git a/tests/test_loaders.py b/tests/test_loaders.py deleted file mode 100644 index 3f100cc..0000000 --- a/tests/test_loaders.py +++ /dev/null @@ -1,19 +0,0 @@ -# coding: utf-8 - -from __future__ import unicode_literals - -import tempfile - -from prettyconf.exceptions import InvalidConfigurationFile -from .base import BaseTestCase - - -class IniFileConfigurationLoaderTestCase(BaseTestCase): - def test_skip_invalid_ini_file(self): - from prettyconf.loaders import IniFileConfigurationLoader - - with tempfile.NamedTemporaryFile() as temp: - temp.write(u'*&ˆ%$#$%ˆ&*('.encode("utf-8")) - - with self.assertRaises(InvalidConfigurationFile): - IniFileConfigurationLoader(temp.name) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ca8f594..0000000 --- a/tox.ini +++ /dev/null @@ -1,6 +0,0 @@ -[tox] -envlist = py27, py34, py35, pypy - -[testenv] -commands = python setup.py test -deps = -rrequirements.txt