diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0227795 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = configurations +branch = 1 + +[report] +omit = *tests*,*migrations* diff --git a/.gitignore b/.gitignore index 9602a1f..03bc604 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .tox/ htmlcov/ *.pyc +dist/ diff --git a/.travis.yml b/.travis.yml index c63d152..8a92e21 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python env: - - TOXENV=coverage-py27-dj16 - TOXENV=flake8-py27 - TOXENV=flake8-py33 - TOXENV=py26-dj14 diff --git a/MANIFEST.in b/MANIFEST.in index caa1d1a..3cf031f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,6 @@ include README.rst include AUTHORS include .travis.yml -include manage.py include tasks.py recursive-include tests * recursive-include docs * diff --git a/configurations/__init__.py b/configurations/__init__.py index 1ae1e42..a2cde93 100644 --- a/configurations/__init__.py +++ b/configurations/__init__.py @@ -13,3 +13,14 @@ def load_ipython_extension(ipython): from . import importer importer.install() + + +def setup(app): + """ + The callback for Sphinx that acts as a Sphinx extension. + + Add this to the ``extensions`` config variable in your ``conf.py``. + """ + from . import importer + + importer.install() diff --git a/configurations/base.py b/configurations/base.py index 8601c73..11cd630 100644 --- a/configurations/base.py +++ b/configurations/base.py @@ -1,3 +1,5 @@ +import os +import re import warnings from django.utils import six @@ -66,9 +68,53 @@ class Configuration(six.with_metaclass(ConfigurationBase)): to the name of the class. """ + DOTENV_LOADED = None + + @classmethod + def load_dotenv(cls): + """ + Pulled from Honcho code with minor updates, reads local default + environment variables from a .env file located in the project root + or provided directory. + + http://www.wellfireinteractive.com/blog/easier-12-factor-django/ + https://gist.github.com/bennylope/2999704 + """ + # check if the class has DOTENV set wether with a path or None + dotenv = getattr(cls, 'DOTENV', None) + + # if DOTENV is falsy we want to disable it + if not dotenv: + return + + # now check if we can access the file since we know we really want to + try: + with open(dotenv, 'r') as f: + content = f.read() + except IOError as e: + raise ImproperlyConfigured("Couldn't read .env file " + "with the path {}. Error: " + "{}".format(dotenv, e)) + else: + for line in content.splitlines(): + m1 = re.match(r'\A([A-Za-z_0-9]+)=(.*)\Z', line) + if not m1: + continue + key, val = m1.group(1), m1.group(2) + m2 = re.match(r"\A'(.*)'\Z", val) + if m2: + val = m2.group(1) + m3 = re.match(r'\A"(.*)"\Z', val) + if m3: + val = re.sub(r'\\(.)', r'\1', m3.group(1)) + os.environ.setdefault(key, val) + + cls.DOTENV_LOADED = dotenv + @classmethod def pre_setup(cls): - pass + if cls.DOTENV_LOADED is None: + cls.load_dotenv() @classmethod def post_setup(cls): @@ -88,4 +134,4 @@ class Settings(Configuration): # make sure to remove the handling of the Settings class above when deprecating warnings.warn("configurations.Settings was renamed to " "settings.Configuration and will be " - "removed in 1.0", PendingDeprecationWarning) + "removed in 1.0", DeprecationWarning) diff --git a/configurations/importer.py b/configurations/importer.py index cc4ad57..a76e773 100644 --- a/configurations/importer.py +++ b/configurations/importer.py @@ -5,10 +5,9 @@ import sys from optparse import make_option from django.core.exceptions import ImproperlyConfigured -from django.core.management import LaxOptionParser from django.conf import ENVIRONMENT_VARIABLE as SETTINGS_ENVIRONMENT_VARIABLE -from .utils import uppercase_attributes, reraise +from .utils import uppercase_attributes, reraise, LaxOptionParser from .values import Value, setup_value installed = False diff --git a/configurations/management.py b/configurations/management.py index 01a7d58..e718ef5 100644 --- a/configurations/management.py +++ b/configurations/management.py @@ -2,4 +2,5 @@ from . import importer importer.install(check_options=True) -from django.core.management import execute_from_command_line # noqa +from django.core.management import (execute_from_command_line, # noqa + call_command) diff --git a/configurations/utils.py b/configurations/utils.py index 59c51db..ce5e598 100644 --- a/configurations/utils.py +++ b/configurations/utils.py @@ -1,3 +1,4 @@ +import inspect import sys from django.core.exceptions import ImproperlyConfigured @@ -60,3 +61,138 @@ def reraise(exc, prefix=None, suffix=None): suffix = '(' + suffix + ')' exc.args = ('{0} {1} {2}'.format(prefix, exc.args[0], suffix),) + args[1:] raise + +try: + from django.core.management import LaxOptionParser +except ImportError: + from optparse import OptionParser + + class LaxOptionParser(OptionParser): + """ + An option parser that doesn't raise any errors on unknown options. + + This is needed because the --settings and --pythonpath options affect + the commands (and thus the options) that are available to the user. + + Backported from Django 1.7.x + + """ + def error(self, msg): + pass + + def print_help(self): + """Output nothing. + + The lax options are included in the normal option parser, so under + normal usage, we don't need to print the lax options. + """ + pass + + def print_lax_help(self): + """Output the basic options available to every command. + + This just redirects to the default print_help() behavior. + """ + OptionParser.print_help(self) + + def _process_args(self, largs, rargs, values): + """ + Overrides OptionParser._process_args to exclusively handle default + options and ignore args and other options. + + This overrides the behavior of the super class, which stop parsing + at the first unrecognized option. + """ + while rargs: + arg = rargs[0] + try: + if arg[0:2] == "--" and len(arg) > 2: + # process a single long option (possibly with value(s)) + # the superclass code pops the arg off rargs + self._process_long_opt(rargs, values) + elif arg[:1] == "-" and len(arg) > 1: + # process a cluster of short options (possibly with + # value(s) for the last one only) + # the superclass code pops the arg off rargs + self._process_short_opts(rargs, values) + else: + # it's either a non-default option or an arg + # either way, add it to the args list so we can keep + # dealing with options + del rargs[0] + raise Exception + except: # Needed because we might need to catch a SystemExit + largs.append(arg) + + +# Copied over from Sphinx +if sys.version_info >= (3, 0): + from functools import partial + + def getargspec(func): + """Like inspect.getargspec but supports functools.partial as well.""" + if inspect.ismethod(func): + func = func.__func__ + if type(func) is partial: + orig_func = func.func + argspec = getargspec(orig_func) + args = list(argspec[0]) + defaults = list(argspec[3] or ()) + kwoargs = list(argspec[4]) + kwodefs = dict(argspec[5] or {}) + if func.args: + args = args[len(func.args):] + for arg in func.keywords or (): + try: + i = args.index(arg) - len(args) + del args[i] + try: + del defaults[i] + except IndexError: + pass + except ValueError: # must be a kwonly arg + i = kwoargs.index(arg) + del kwoargs[i] + del kwodefs[arg] + return inspect.FullArgSpec(args, argspec[1], argspec[2], + tuple(defaults), kwoargs, + kwodefs, argspec[6]) + while hasattr(func, '__wrapped__'): + func = func.__wrapped__ + if not inspect.isfunction(func): + raise TypeError('%r is not a Python function' % func) + return inspect.getfullargspec(func) + +else: # 2.6, 2.7 + from functools import partial + + def getargspec(func): + """Like inspect.getargspec but supports functools.partial as well.""" + if inspect.ismethod(func): + func = func.im_func + parts = 0, () + if type(func) is partial: + keywords = func.keywords + if keywords is None: + keywords = {} + parts = len(func.args), keywords.keys() + func = func.func + if not inspect.isfunction(func): + raise TypeError('%r is not a Python function' % func) + args, varargs, varkw = inspect.getargs(func.func_code) + func_defaults = func.func_defaults + if func_defaults is None: + func_defaults = [] + else: + func_defaults = list(func_defaults) + if parts[0]: + args = args[parts[0]:] + if parts[1]: + for arg in parts[1]: + i = args.index(arg) - len(args) + del args[i] + try: + del func_defaults[i] + except IndexError: + pass + return inspect.ArgSpec(args, varargs, varkw, func_defaults) diff --git a/configurations/values.py b/configurations/values.py index 0ff6775..456d0d8 100644 --- a/configurations/values.py +++ b/configurations/values.py @@ -8,7 +8,7 @@ from django.core import validators from django.core.exceptions import ValidationError, ImproperlyConfigured from django.utils import six -from .utils import import_by_path +from .utils import import_by_path, getargspec def setup_value(target, name, value): @@ -44,7 +44,7 @@ class Value(object): def __new__(cls, *args, **kwargs): """ checks if the creation can end up directly in the final value. - That is the case whenever environ = False or environ_name is given + That is the case whenever environ = False or environ_name is given. """ instance = object.__new__(cls) instance.__init__(*args, **kwargs) @@ -59,7 +59,7 @@ class Value(object): environ_prefix='DJANGO', *args, **kwargs): if 'late_binding' in kwargs: self.late_binding = kwargs.get('late_binding') - if isinstance(default, Value) and default.default: + if isinstance(default, Value) and default.default is not None: self.default = copy.copy(default.default) else: self.default = default @@ -140,10 +140,20 @@ class CastingMixin(object): error = 'Cannot use caster of {0} ({1!r})'.format(self, self.caster) raise ValueError(error) + try: + arg_names = getargspec(self._caster)[0] + self._params = dict((name, kwargs[name]) + for name in arg_names + if name in kwargs) + except TypeError: + self._params = {} def to_python(self, value): try: - return self._caster(value) + if self._params: + return self._caster(value, **self._params) + else: + return self._caster(value) except self.exception: raise ValueError(self.message.format(value)) diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 4575b8d..0cb5d54 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -1,6 +1,25 @@ Cookbook ======== +Calling a Django management command +----------------------------------- + +.. versionadded:: 0.9 + +If you want to call a Django management command programmatically, say +from a script outside of your usual Django code, you can use the +equivalent of Django's :func:`~django.core.management.call_command` function +with django-configurations, too. + +Simply import it from ``configurations.management`` instead: + +.. code-block:: python + :emphasize-lines: 1 + + from configurations.management import call_command + + call_command('dumpdata', exclude=['contenttypes', 'auth']) + Envdir ------ @@ -14,7 +33,9 @@ Imagine for example you want to set a few environment variables, all you have to do is to create a directory with files that have capitalized names and contain the values you want to set. -Example:: +Example: + +.. code-block:: console $ tree mysite_env/ mysite_env/ @@ -30,7 +51,9 @@ Example:: $ Then, to enable the ``mysite_env`` environment variables, simply use the -``envdir`` command line tool as a prefix for your program, e.g.:: +``envdir`` command line tool as a prefix for your program, e.g.: + +.. code-block:: console $ envdir mysite_env python manage.py runserver @@ -51,13 +74,17 @@ using pip_ to install packages. Django 1.5.x ^^^^^^^^^^^^ -First install Django 1.5.x and django-configurations:: +First install Django 1.5.x and django-configurations: - pip install -r https://raw.github.com/jezdez/django-configurations/templates/1.5.x/requirements.txt +.. code-block:: console -Then create your new Django project with the provided template:: + $ pip install -r https://raw.github.com/jezdez/django-configurations/templates/1.5.x/requirements.txt - django-admin.py startproject mysite -v2 --template https://github.com/jezdez/django-configurations/archive/templates/1.5.x.zip +Then create your new Django project with the provided template: + +.. code-block:: console + + $ django-admin.py startproject mysite -v2 --template https://github.com/jezdez/django-configurations/archive/templates/1.5.x.zip See the repository of the template for more information: @@ -66,13 +93,17 @@ See the repository of the template for more information: Django 1.6.x ^^^^^^^^^^^^ -First install Django 1.6.x and django-configurations:: +First install Django 1.6.x and django-configurations: - pip install -r https://raw.github.com/jezdez/django-configurations/templates/1.6.x/requirements.txt +.. code-block:: console -Or Django 1.6:: + $ pip install -r https://raw.github.com/jezdez/django-configurations/templates/1.6.x/requirements.txt - django-admin.py startproject mysite -v2 --template https://github.com/jezdez/django-configurations/archive/templates/1.6.x.zip +Or Django 1.6: + +.. code-block:: console + + $ django-admin.py startproject mysite -v2 --template https://github.com/jezdez/django-configurations/archive/templates/1.6.x.zip Now you have a default Django 1.5.x or 1.6.x project in the ``mysite`` directory that uses django-configurations. @@ -90,7 +121,9 @@ Celery ^^^^^ Given Celery's way to load Django settings in worker processes you should -probably just add the following to the **beginning** of your settings module:: +probably just add the following to the **beginning** of your settings module: + +.. code-block:: python from configurations import importer importer.install() @@ -145,26 +178,32 @@ enable an extension in your IPython configuration. See the IPython documentation for how to create and `manage your IPython profile`_ correctly. Here's a quick how-to in case you don't have a profile yet. Type in your -command line shell:: +command line shell: - ipython profile create +.. code-block:: console + + $ ipython profile create Then let IPython show you where the configuration file ``ipython_config.py`` -was created:: +was created: - ipython locate profile +.. code-block:: console + + $ ipython locate profile That should print a directory path where you can find the ``ipython_config.py`` configuration file. Now open that file and extend the ``c.InteractiveShellApp.extensions`` configuration value. It may be commented out from when IPython created the file or it may not exist in the file at all. -In either case make sure it's not a Python comment anymore and reads like this:: +In either case make sure it's not a Python comment anymore and reads like this: - # A list of dotted module names of IPython extensions to load. - c.InteractiveShellApp.extensions = [ - # .. your other extensions if available - 'configurations', - ] +.. code-block:: python + + # A list of dotted module names of IPython extensions to load. + c.InteractiveShellApp.extensions = [ + # .. your other extensions if available + 'configurations', + ] That will tell IPython to load django-configurations correctly on startup. It also works with django-extensions's shell_plus_ management command. @@ -179,12 +218,14 @@ FastCGI In case you use FastCGI for deploying Django (you really shouldn't) and aren't allowed to use Django's runfcgi_ management command (that would automatically handle the setup for your if you've followed the quickstart guide above), make -sure to use something like the following script:: +sure to use something like the following script: + +.. code-block:: python #!/usr/bin/env python import os - + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings') os.environ.setdefault('DJANGO_CONFIGURATION', 'MySiteConfiguration') @@ -196,3 +237,33 @@ As you can see django-configurations provides a helper module ``configurations.fastcgi`` that handles the setup of your configurations. .. _runfcgi: https://docs.djangoproject.com/en/1.5/howto/deployment/fastcgi/ + + +Sphinx +------ + +.. versionadded: 0.9 + +In case you would like to user the amazing `autodoc` feature of the +documentation tool `Sphinx `_, you need add +django-configurations to your ``extensions`` config variable and set +the environment variable accordingly: + +.. code-block:: python + :emphasize-lines: 2-3, 12 + + # My custom Django environment variables + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings') + os.environ.setdefault('DJANGO_CONFIGURATION', 'Dev') + + # Add any Sphinx extension module names here, as strings. They can be extensions + # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. + extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + # ... + 'configurations', + ] + + # ... \ No newline at end of file diff --git a/manage.py b/manage.py deleted file mode 100755 index 3dd6dcb..0000000 --- a/manage.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -if __name__ == "__main__": - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings.main') - os.environ.setdefault('DJANGO_CONFIGURATION', 'Test') - - from configurations.management import execute_from_command_line - - execute_from_command_line(sys.argv) diff --git a/setup.cfg b/setup.cfg index 5e40900..68af234 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,11 @@ +[pytest] +django_find_project = false +addopts = --cov configurations +DJANGO_SETTINGS_MODULE = tests.settings.main +DJANGO_CONFIGURATION = Test + [wheel] universal = 1 + +[flake8] +ignore = E124,E501,E127,E128 diff --git a/test_project/.env b/test_project/.env new file mode 100644 index 0000000..507755e --- /dev/null +++ b/test_project/.env @@ -0,0 +1 @@ +DJANGO_DOTENV_VALUE='is set' \ No newline at end of file diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 334cee6..1ffe526 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -160,20 +160,6 @@ class Base(Configuration): } } - INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - # Uncomment the next line to enable the admin: - # 'django.contrib.admin', - # Uncomment the next line to enable admin documentation: - # 'django.contrib.admindocs', - 'configurations', - ) - class Debug(Base): YEAH = True diff --git a/tests/settings/dot_env.py b/tests/settings/dot_env.py new file mode 100644 index 0000000..eab4237 --- /dev/null +++ b/tests/settings/dot_env.py @@ -0,0 +1,8 @@ +from configurations import Configuration, values + + +class DotEnvConfiguration(Configuration): + + DOTENV = 'test_project/.env' + + DOTENV_VALUE = values.Value() diff --git a/tests/settings/main.py b/tests/settings/main.py index dd425fd..c0bb716 100644 --- a/tests/settings/main.py +++ b/tests/settings/main.py @@ -3,9 +3,13 @@ import uuid import django from configurations import Configuration, pristinemethod +from configurations.values import BooleanValue class Test(Configuration): + + ENV_LOADED = BooleanValue(False) + DEBUG = True SITE_ID = 1 @@ -30,9 +34,6 @@ class Test(Configuration): ROOT_URLCONF = 'tests.urls' - if django.VERSION[:2] < (1, 6): - TEST_RUNNER = 'discover_runner.DiscoverRunner' - def TEMPLATE_CONTEXT_PROCESSORS(self): return Configuration.TEMPLATE_CONTEXT_PROCESSORS + ( 'tests.settings.base.test_callback', diff --git a/tests/test_env.py b/tests/test_env.py new file mode 100644 index 0000000..47286d0 --- /dev/null +++ b/tests/test_env.py @@ -0,0 +1,14 @@ +import os +from django.test import TestCase +from mock import patch + + +class DotEnvLoadingTests(TestCase): + + @patch.dict(os.environ, clear=True, + DJANGO_CONFIGURATION='DotEnvConfiguration', + DJANGO_SETTINGS_MODULE='tests.settings.dot_env') + def test_env_loaded(self): + from tests.settings import dot_env + self.assertEqual(dot_env.DOTENV_VALUE, 'is set') + self.assertEqual(dot_env.DOTENV_LOADED, dot_env.DOTENV) diff --git a/tests/test_values.py b/tests/test_values.py index e1410b1..e1c4bf4 100644 --- a/tests/test_values.py +++ b/tests/test_values.py @@ -109,6 +109,12 @@ class ValueTests(TestCase): with env(DJANGO_TEST='nonboolean'): self.assertRaises(ValueError, value.setup, 'TEST') + def test_boolean_values_assign_false_to_another_booleanvalue(self): + value1 = BooleanValue(False) + value2 = BooleanValue(value1) + self.assertFalse(value1.setup('TEST1')) + self.assertFalse(value2.setup('TEST2')) + def test_integer_values(self): value = IntegerValue(1) with env(DJANGO_TEST='2'): @@ -352,6 +358,21 @@ class ValueTests(TestCase): 'USER': '', }}) + def test_database_url_additional_args(self): + + def mock_database_url_caster(self, url, engine=None): + return { 'URL': url, 'ENGINE': engine } + + with patch('configurations.values.DatabaseURLValue.caster', mock_database_url_caster): + value = DatabaseURLValue(engine='django_mysqlpool.backends.mysqlpool') + with env(DATABASE_URL='sqlite://'): + self.assertEqual(value.setup('DATABASE_URL'), { + 'default': { + 'URL': 'sqlite://', + 'ENGINE': 'django_mysqlpool.backends.mysqlpool' + } + }) + def test_email_url_value(self): value = EmailURLValue() self.assertEqual(value.default, {}) diff --git a/tox.ini b/tox.ini index 310f521..aac06f9 100644 --- a/tox.ini +++ b/tox.ini @@ -20,32 +20,27 @@ basepython = pypy: pypy usedevelop = true deps = - django-discover-runner mock dj-database-url dj-email-url dj-search-url django-cache-url>=0.6.0 six + https://github.com/pytest-dev/pytest-django/archive/master.zip#egg=pytest-django + pytest-cov dj14: https://github.com/django/django/archive/stable/1.4.x.zip#egg=django dj15: https://github.com/django/django/archive/stable/1.5.x.zip#egg=django dj16: https://github.com/django/django/archive/stable/1.6.x.zip#egg=django dj17: https://github.com/django/django/archive/stable/1.7.x.zip#egg=django dj18: https://github.com/django/django/archive/master.zip#egg=django - flake8: flake8 - coverage: coverage commands = - python manage.py test -v2 {posargs:tests} --failfast + py.test {posargs:} [testenv:flake8-py27] -commands = flake8 configurations --ignore=E501,E127,E128,E124 +commands = flake8 configurations +deps = flake8 [testenv:flake8-py33] -commands = flake8 configurations --ignore=E501,E127,E128,E124 - -[testenv:coverage-py27-dj16] -commands = coverage erase - coverage run --source=. manage.py test -v2 {posargs:tests} - coverage report -m configurations/*py - coverage html configurations/*py +commands = flake8 configurations +deps = flake8