diff --git a/Makefile b/Makefile index 6f2492d13..ff560455b 100644 --- a/Makefile +++ b/Makefile @@ -143,6 +143,12 @@ test_redis: watch: ls **/**.py | entr py.test -m "not integration" -s -vvv -l --tb=long --maxfail=1 tests/ +watch_django: + ls {**/**.py,~/.virtualenvs/dynaconf/**/**.py,.venv/**/**.py} | PYTHON_PATH=. DJANGO_SETTINGS_MODULE=foo.settings entr example/django_example/manage.py test polls -v 2 + +watch_coverage: + ls {**/**.py,~/.virtualenvs/dynaconf/**/**.py} | entr -s "make test;coverage html" + test_only: py.test -m "not integration" -v --cov-config .coveragerc --cov=dynaconf -l --tb=short --maxfail=1 tests/ coverage xml diff --git a/dynaconf/base.py b/dynaconf/base.py index 37803d813..84f50aa0c 100644 --- a/dynaconf/base.py +++ b/dynaconf/base.py @@ -1,3 +1,4 @@ +import copy import glob import importlib import inspect @@ -91,10 +92,11 @@ class LazySettings(LazyObject): and all values in a hash called DYNACONF_PROJ in redis """ - def __init__(self, **kwargs): + def __init__(self, wrapped=None, **kwargs): """ handle initialization for the customization cases + :param wrapped: a deepcopy of this object will be wrapped (issue #596) :param kwargs: values that overrides default_settings """ @@ -107,6 +109,13 @@ def __init__(self, **kwargs): self._kwargs = kwargs super(LazySettings, self).__init__() + if wrapped: + if self._django_override: + # This fixes django issue #596 + self._wrapped = copy.deepcopy(wrapped) + else: + self._wrapped = wrapped + def __resolve_config_aliases(self, kwargs): """takes aliases for _FOR_DYNACONF configurations @@ -225,6 +234,7 @@ class Settings: """ dynaconf_banner = BANNER + _store = DynaBox() def __init__(self, settings_module=None, **kwargs): # pragma: no cover """Execute loaders and custom initialization @@ -957,11 +967,9 @@ def loaders(self): # pragma: no cover return [] if not self._loaders: - for loader_module_name in self.LOADERS_FOR_DYNACONF: - loader = importlib.import_module(loader_module_name) - self._loaders.append(loader) + self._loaders = self.LOADERS_FOR_DYNACONF - return self._loaders + return [importlib.import_module(loader) for loader in self._loaders] def reload(self, env=None, silent=None): # pragma: no cover """Clean end Execute all loaders""" @@ -1160,14 +1168,27 @@ def populate_obj(self, obj, keys=None, ignore=None): if value is not empty: setattr(obj, key, value) - def dynaconf(self): # pragma: no cover + def dynaconf_clone(self): + """Clone the current settings object.""" + return copy.deepcopy(self) + + @property + def dynaconf(self): """A proxy to access internal methods and attributes Starting in 3.0.0 Dynaconf now allows first level lower case keys that are not reserved keyword, so this is a proxy to internal methods and attrs. """ - return # TOBE IMPLEMENTED + + class AttrProxy(object): + def __init__(self, obj): + self.obj = obj + + def __getattr__(self, name): + return getattr(self.obj, f"dynaconf_{name}") + + return AttrProxy(self) @property def logger(self): # pragma: no cover diff --git a/dynaconf/contrib/django_dynaconf_v2.py b/dynaconf/contrib/django_dynaconf_v2.py index 5ad395b09..bb86454fe 100644 --- a/dynaconf/contrib/django_dynaconf_v2.py +++ b/dynaconf/contrib/django_dynaconf_v2.py @@ -69,6 +69,9 @@ def load(django_settings_module_name=None, **kwargs): # pragma: no cover "default_settings_paths", dynaconf.DEFAULT_SETTINGS_FILES ) + class UserSettingsHolder(dynaconf.LazySettings): + _django_override = True + lazy_settings = dynaconf.LazySettings(**options) dynaconf.settings = lazy_settings # rebind the settings @@ -100,6 +103,8 @@ class Wrapper: def __getattribute__(self, name): if name == "settings": return lazy_settings + if name == "UserSettingsHolder": + return UserSettingsHolder return getattr(conf, name) # This implementation is recommended by Guido Van Rossum diff --git a/dynaconf/utils/functional.py b/dynaconf/utils/functional.py index ed47f4114..147d26a7e 100644 --- a/dynaconf/utils/functional.py +++ b/dynaconf/utils/functional.py @@ -31,6 +31,7 @@ class LazyObject: # Avoid infinite recursion when tracing __init__. _wrapped = None _kwargs = None + _django_override = False def __init__(self): # Note: if a subclass overrides __init__(), it will likely need to diff --git a/example/django_example/foo/settings.py b/example/django_example/foo/settings.py index 7543a5a12..e366fe7f0 100644 --- a/example/django_example/foo/settings.py +++ b/example/django_example/foo/settings.py @@ -22,6 +22,9 @@ "ANOTHER_DRF_KEY": "VALUE", } +# 596 +TEST_VALUE = "a" +COLORS = ["black", "green"] # HERE STARTS DYNACONF EXTENSION LOAD (Keep at the very bottom of settings.py) # Read more at https://dynaconf.readthedocs.io/en/latest/guides/django.html @@ -42,3 +45,4 @@ assert settings.PASSWORD == "My5up3r53c4et" assert settings.get("PASSWORD") == "My5up3r53c4et" assert settings.FOO == "It overrides every other env" +assert settings.TEST_VALUE == "a" diff --git a/example/django_example/polls/tests.py b/example/django_example/polls/tests.py index 4ee0a430c..caac34cc8 100644 --- a/example/django_example/polls/tests.py +++ b/example/django_example/polls/tests.py @@ -1,11 +1,14 @@ from django.conf import settings from django.test import TestCase +from django.test.utils import override_settings # Create your tests here. +@override_settings(ISSUE=596) class SettingsTest(TestCase): def test_settings(self): + self.assertEqual(settings.ISSUE, 596) self.assertEqual(settings.SERVER, "prodserver.com") # self.assertEqual( # settings.STATIC_URL, "/changed/in/settings.toml/by/dynaconf/" @@ -47,3 +50,40 @@ def test_settings(self): self.assertEqual(settings.PASSWORD, "My5up3r53c4et") self.assertEqual(settings.USERNAME, "admin_user_from_env") self.assertEqual(settings.FOO, "It overrides every other env") + + def test_override_settings_context(self): + self.assertEqual(settings.ISSUE, 596) + with self.settings(DAY=19): + self.assertEqual(settings.DAY, 19) + + +assert settings.TEST_VALUE == "a" + + +@override_settings(TEST_VALUE="c") +class TestOverrideClassDecoratorAndManager(TestCase): + def test_settings(self): + self.assertEqual(settings.TEST_VALUE, "c") + + def test_modified_settings(self): + with self.settings(TEST_VALUE="b"): + self.assertEqual(settings.TEST_VALUE, "b") + + +class TestNoOverride(TestCase): + def test_settings(self): + self.assertEqual(settings.TEST_VALUE, "a") + + +class TestModifySettingsContextManager(TestCase): + def test_settings(self): + self.assertEqual(settings.COLORS, ["black", "green"]) + + with self.modify_settings( + COLORS={ + "append": ["blue"], + "prepend": ["red"], + "remove": ["black"], + } + ): + self.assertEqual(settings.COLORS, ["red", "green", "blue"]) diff --git a/example/django_pure/foo/__init__.py b/example/django_pure/foo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/example/django_pure/foo/settings.py b/example/django_pure/foo/settings.py new file mode 100644 index 000000000..cd2f1eaa8 --- /dev/null +++ b/example/django_pure/foo/settings.py @@ -0,0 +1,23 @@ +import os + +# Where is all the Django's settings? +# Take a look at ../settings.yaml and ../.secrets.yaml +# Dynaconf supports multiple formats that files can be toml, ini, json, py +# Files are also optional, dynaconf can read from envvars, Redis or Vault. + +# Build paths inside the project like this: os.path.join(settings.BASE_DIR, ..) +# Or use the dynaconf helper `settings.path_for('filename')` +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +STATIC_URL = "/etc/foo/" + + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + +# 596 +TEST_VALUE = "a" diff --git a/example/django_pure/foo/urls.py b/example/django_pure/foo/urls.py new file mode 100644 index 000000000..55e6d68a6 --- /dev/null +++ b/example/django_pure/foo/urls.py @@ -0,0 +1,31 @@ +"""foo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/2.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +from django.contrib import admin +from django.urls import include +from django.urls import path + +urlpatterns = [ + path("polls/", include("polls.urls")), + path("admin/", admin.site.urls), +] + +if settings.DEBUG: + import debug_toolbar + + urlpatterns = [ + path("__debug__/", include(debug_toolbar.urls)), + ] + urlpatterns diff --git a/example/django_pure/foo/wsgi.py b/example/django_pure/foo/wsgi.py new file mode 100644 index 000000000..45625459d --- /dev/null +++ b/example/django_pure/foo/wsgi.py @@ -0,0 +1,15 @@ +""" +WSGI config for foo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/ +""" +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foo.settings") + +application = get_wsgi_application() diff --git a/example/django_pure/manage.py b/example/django_pure/manage.py new file mode 100755 index 000000000..db3dbcdf2 --- /dev/null +++ b/example/django_pure/manage.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "foo.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/example/django_pure/polls/__init__.py b/example/django_pure/polls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/example/django_pure/polls/admin.py b/example/django_pure/polls/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/example/django_pure/polls/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/example/django_pure/polls/apps.py b/example/django_pure/polls/apps.py new file mode 100644 index 000000000..292f00d13 --- /dev/null +++ b/example/django_pure/polls/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PollsConfig(AppConfig): + name = "polls" diff --git a/example/django_pure/polls/migrations/__init__.py b/example/django_pure/polls/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/example/django_pure/polls/models.py b/example/django_pure/polls/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/example/django_pure/polls/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/example/django_pure/polls/tests.py b/example/django_pure/polls/tests.py new file mode 100644 index 000000000..b3c912cf2 --- /dev/null +++ b/example/django_pure/polls/tests.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django.test import TestCase +from django.test.utils import override_settings + + +assert settings.TEST_VALUE == "a" + + +@override_settings(TEST_VALUE="c") +class CheckSettings(TestCase): + def test_settings(self): + self.assertEqual(settings.TEST_VALUE, "c") + + def test_modified_settings(self): + with self.settings(TEST_VALUE="b"): + self.assertEqual(settings.TEST_VALUE, "b") diff --git a/example/django_pure/polls/urls.py b/example/django_pure/polls/urls.py new file mode 100644 index 000000000..7ece5c998 --- /dev/null +++ b/example/django_pure/polls/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from . import views + +urlpatterns = [path("", views.index, name="index")] diff --git a/example/django_pure/polls/views.py b/example/django_pure/polls/views.py new file mode 100644 index 000000000..75bd1087d --- /dev/null +++ b/example/django_pure/polls/views.py @@ -0,0 +1,6 @@ +from django.conf import settings +from django.http import HttpResponse + + +def index(request): + return HttpResponse("Hello") diff --git a/example/django_pure/settings.yaml b/example/django_pure/settings.yaml new file mode 100644 index 000000000..115175449 --- /dev/null +++ b/example/django_pure/settings.yaml @@ -0,0 +1,90 @@ +default: + # APP specific settings + server: foo.com + username: default user + password: false + foo: bar + # Django Required starting settings + SECRET_KEY: 1234 + STATIC_ROOT: . + STATIC_URL: /static/ + ALLOWED_HOSTS: + - '*' + INSTALLED_APPS: + - django.contrib.admin + - django.contrib.auth + - django.contrib.contenttypes + - django.contrib.sessions + - django.contrib.messages + - django.contrib.staticfiles + - debug_toolbar + MIDDLEWARE: + - debug_toolbar.middleware.DebugToolbarMiddleware + - django.middleware.security.SecurityMiddleware + - django.contrib.sessions.middleware.SessionMiddleware + - django.middleware.common.CommonMiddleware + - django.middleware.csrf.CsrfViewMiddleware + - django.contrib.auth.middleware.AuthenticationMiddleware + - django.contrib.messages.middleware.MessageMiddleware + - django.middleware.clickjacking.XFrameOptionsMiddleware + ROOT_URLCONF: foo.urls + WSGI_APPLICATION: foo.wsgi.application + LANGUAGE_CODE: en-us + TIME_ZONE: UTC + USE_I18N: true + USE_L10N: true + USE_TZ: true + TEMPLATES: + - BACKEND: django.template.backends.django.DjangoTemplates + DIRS: [] + APP_DIRS: true + OPTIONS: + context_processors: + - django.template.context_processors.debug + - django.template.context_processors.request + - django.contrib.auth.context_processors.auth + - django.contrib.messages.context_processors.messages + + # Password validation + # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators + AUTH_PASSWORD_VALIDATORS: + - NAME: django.contrib.auth.password_validation.UserAttributeSimilarityValidator + - NAME: django.contrib.auth.password_validation.MinimumLengthValidator + - NAME: django.contrib.auth.password_validation.CommonPasswordValidator + - NAME: django.contrib.auth.password_validation.NumericPasswordValidator + # Database + # https://docs.djangoproject.com/en/2.0/ref/settings/#databases + DATABASES: + default: + ENGINE: django.db.backends.sqlite3 + NAME: db.sqlite3 + INTERNAL_IPS: + - '127.0.0.1' + - 'localhost' + +development: + username: dev user + foo: bar dev + server: devserver.com + +production: + server: prodserver.com + username: prod user + foo: bar prod + value: this value is for django app + +staging: + server: stagingserver.com + username: staging user + foo: bar stag + +testing: + server: stagingserver.com + username: testing user + foo: bar testing + +customenv: + server: customserver.com + +global: + foo: It overrides every other env diff --git a/example/django_pure/standalone_script.py b/example/django_pure/standalone_script.py new file mode 100644 index 000000000..e8a127f49 --- /dev/null +++ b/example/django_pure/standalone_script.py @@ -0,0 +1,52 @@ +# You should start your standalone scripts with this: +from django.conf import settings + +# This `DYNACONF.configure()` line may be useful in some cases +# It forces the load of settings +# settings.DYNACONF.configure() + +# Now you have access to: + +# Django normal settings +print(settings.BASE_DIR) +print(settings.DATABASES) + +# Dynaconf methods +print(settings.current_env) +print(settings.get("BASE_DIR")) +print(settings.get("DATABASES.default.ENGINE")) +print(settings.DATABASES.default.ENGINE) + +# App settings (defined in `settings.yaml`) +print(settings.SERVER) + +# App settings (exported as environment variables) + +# `export DJANGO_USERNAME=` +print(settings.USERNAME) + +# `export DJANGO_ENVVAR=` +print(settings.ENVVAR) + + +# test case +expected = { + "DATABASES": { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "db.sqlite3", + } + }, + "current_env": "PRODUCTION", + "DATABASES.DEFAULT.ENGINE": "django.db.backends.sqlite3", + "SERVER": "prodserver.com", + "USERNAME": "admin_user_from_env", + "ENVVAR": "this value exists only in .env", +} + +for k, v in expected.items(): + print("Is", k, "equals", v, "?") + if k.isupper(): + assert settings.get(k) == v + else: + assert getattr(settings, k) == v diff --git a/tests/test_django.py b/tests/test_django.py index de8c329bf..a28948d2c 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -9,7 +9,34 @@ def test_djdt_382(tmpdir): settings_file.write("SECRET_KEY = 'dasfadfds2'") tmpdir.join("__init__.py").write("") os.environ["DJANGO_SETTINGS_MODULE"] = "settings" - settings = dynaconf.DjangoDynaconf(__name__, environments=True) + sys.path.append(str(tmpdir)) + __import__("settings") + settings = dynaconf.DjangoDynaconf("settings", environments=True) settings.configure(settings_module="settings") assert settings.SECRET_KEY == "dasfadfds2" assert settings.is_overridden("FOO") is False + + +def test_override_settings_596(tmpdir): + settings_file = tmpdir.join("other_settings.py") + settings_file.write("SECRET_KEY = 'abcdef'") + tmpdir.join("__init__.py").write("") + os.environ["DJANGO_SETTINGS_MODULE"] = "other_settings" + sys.path.append(str(tmpdir)) + __import__("other_settings") + settings = dynaconf.DjangoDynaconf("other_settings", environments=True) + settings.configure(settings_module="other_settings") + assert settings.SECRET_KEY == "abcdef" + + # mimic what django.test.utils.override_settings does + class UserSettingsHolder(dynaconf.LazySettings): + _django_override = True + + override = UserSettingsHolder(settings._wrapped) + override.SECRET_KEY = "foobar" + + # overriden settings is changed + assert override.SECRET_KEY == "foobar" + + # original not changed + assert settings.SECRET_KEY == "abcdef" diff --git a/tests/test_yaml_loader.py b/tests/test_yaml_loader.py index 11589348b..c5c437921 100644 --- a/tests/test_yaml_loader.py +++ b/tests/test_yaml_loader.py @@ -5,11 +5,14 @@ from dynaconf import LazySettings from dynaconf.loaders.yaml_loader import load -settings = LazySettings( - environments=True, - ENV_FOR_DYNACONF="PRODUCTION", - ROOT_PATH_FOR_DYNACONF=os.path.dirname(os.path.abspath(__file__)), -) + +@pytest.fixture(scope="module") +def settings(): + return LazySettings( + environments=True, + ENV_FOR_DYNACONF="PRODUCTION", + # ROOT_PATH_FOR_DYNACONF=os.path.dirname(os.path.abspath(__file__)), + ) YAML = """ @@ -51,7 +54,7 @@ YAMLS = [YAML, YAML2] -def test_load_from_yaml(): +def test_load_from_yaml(settings): """Assert loads from YAML string""" load(settings, filename=YAML) assert settings.HOST == "prodserver.com" @@ -68,7 +71,7 @@ def test_load_from_yaml(): assert settings.HOST == "prodserver.com" -def test_load_from_multiple_yaml(): +def test_load_from_multiple_yaml(settings): """Assert loads from YAML string""" load(settings, filename=YAMLS) assert settings.HOST == "otheryaml.com" @@ -94,23 +97,23 @@ def test_load_from_multiple_yaml(): assert settings.PASSWORD == 11111 -def test_no_filename_is_none(): +def test_no_filename_is_none(settings): """Assert if passed no filename return is None""" assert load(settings) is None -def test_key_error_on_invalid_env(): +def test_key_error_on_invalid_env(settings): """Assert error raised if env is not found in YAML""" with pytest.raises(KeyError): load(settings, filename=YAML, env="FOOBAR", silent=False) -def test_no_key_error_on_invalid_env(): +def test_no_key_error_on_invalid_env(settings): """Assert error raised if env is not found in YAML""" load(settings, filename=YAML, env="FOOBAR", silent=True) -def test_load_single_key(): +def test_load_single_key(settings): """Test loading a single key""" yaml = """ foo: @@ -123,7 +126,7 @@ def test_load_single_key(): assert settings.exists("ZAZ") is False -def test_extra_yaml(): +def test_extra_yaml(settings): """Test loading extra yaml file""" load(settings, filename=YAML) yaml = """ @@ -135,15 +138,15 @@ def test_extra_yaml(): assert settings.HELLOEXAMPLE == "world" -def test_empty_value(): +def test_empty_value(settings): load(settings, filename="") -def test_multiple_filenames(): +def test_multiple_filenames(settings): load(settings, filename="a.yaml,b.yml,c.yaml,d.yml") -def test_cleaner(): +def test_cleaner(settings): load(settings, filename=YAML) assert settings.HOST == "prodserver.com" assert settings.PORT == 8080 @@ -163,7 +166,7 @@ def test_cleaner(): assert settings.HOST == "prodserver.com" -def test_using_env(tmpdir): +def test_using_env(tmpdir, settings): load(settings, filename=YAML) assert settings.HOST == "prodserver.com" @@ -174,7 +177,7 @@ def test_using_env(tmpdir): assert settings.HOST == "prodserver.com" -def test_load_dunder(): +def test_load_dunder(settings): """Test load with dunder settings""" yaml = """ foo: