├── tests ├── __init__.py ├── test_django.py ├── test_urls.py ├── test_core.py ├── settings.py ├── test_cast.py ├── test_use.py └── test_env.py ├── src └── cbs │ ├── version.py │ ├── __init__.py │ ├── cast.py │ ├── urls.py │ ├── settings.py │ └── env.py ├── .readthedocs.yaml ├── docs ├── api.rst ├── changelog.rst ├── Makefile ├── make.bat ├── conf.py └── index.rst ├── .gitignore ├── README.md ├── LICENSE ├── .circleci └── config.yml └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cbs/version.py: -------------------------------------------------------------------------------- 1 | # This must be external to __init__ as it pulls in bits that depend on django 2 | __version__ = "3.0.7" 3 | -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from django.conf import Settings 4 | 5 | 6 | class DjangoTestCase(TestCase): 7 | def test_settings(self): 8 | Settings("tests.settings") 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | 2 | version: 2 3 | 4 | build: 5 | os: "ubuntu-22.04" 6 | tools: 7 | python: "3.11" 8 | 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | 17 | -------------------------------------------------------------------------------- /src/cbs/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main interface for `cbs`. 3 | 4 | Re-exports `cbs.cast`, and everything from `cbs.env` and `cbs.settings`. 5 | """ 6 | 7 | from . import cast # noqa: F401 8 | from .env import * # noqa: F403 9 | from .settings import * # noqa: F403 10 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | === 2 | API 3 | === 4 | 5 | 6 | ``cbs`` 7 | ------- 8 | 9 | .. automodule:: cbs 10 | 11 | ``cbs.settings`` 12 | ---------------- 13 | 14 | .. automodule:: cbs.settings 15 | 16 | .. autoclass:: cbs.settings.BaseSettings 17 | :members: 18 | 19 | ``cbs.env`` 20 | ----------- 21 | 22 | .. automodule:: cbs.env 23 | 24 | .. autoclass:: cbs.env.env 25 | :members: 26 | 27 | 28 | ``cbs.cast`` 29 | ------------ 30 | 31 | .. automodule:: cbs.cast 32 | 33 | .. autofunction:: cbs.cast.as_bool 34 | 35 | .. autofunction:: cbs.cast.as_list 36 | 37 | .. autofunction:: cbs.cast.as_tuple 38 | 39 | ``cbs.urls`` 40 | ------------ 41 | 42 | .. automodule:: cbs.urls 43 | 44 | .. autofunction:: cbs.urls.parse_dburl 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # Vim 57 | .*.swp 58 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from cbs.urls import parse_dburl 4 | 5 | 6 | class TestUrlParse(TestCase): 7 | def test_simple(self): 8 | result = parse_dburl("postgres://user:password@hostname:1234/dbname?conn_max_age=15&local_option=test") 9 | 10 | self.assertEqual( 11 | result, 12 | { 13 | "ENGINE": "django.db.backends.postgresql", 14 | "NAME": "dbname", 15 | "HOST": "hostname", 16 | "PORT": 1234, 17 | "PASSWORD": "password", 18 | "USER": "user", 19 | "CONN_MAX_AGE": 15, 20 | "OPTIONS": { 21 | "local_option": "test", 22 | }, 23 | }, 24 | ) 25 | 26 | def test_sqlite(self): 27 | result = parse_dburl("sqlite:///db.sqlite") 28 | 29 | self.assertEqual( 30 | result, 31 | { 32 | "ENGINE": "django.db.backends.sqlite3", 33 | "NAME": "db.sqlite", 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import unittest 4 | 5 | from . import settings # So reload works first time 6 | 7 | 8 | class TestPython(unittest.TestCase): 9 | def test_precedence(self): 10 | os.environ["DJANGO_MODE"] = "global" 11 | 12 | importlib.reload(settings) 13 | 14 | self.assertEqual(settings.GLOBAL, "global") 15 | 16 | def test_non_upper(self): 17 | """We only allow access to SHOUTY_SNAKE_CASE names.""" 18 | with self.assertRaises(AttributeError): 19 | settings.private 20 | 21 | def test_nested(self): 22 | """ 23 | Ensure when we access nested SHOUTY_SNAKE_CASE we still treat methods as properties. 24 | """ 25 | self.assertEqual(settings.METHOD, settings.NESTED) 26 | self.assertEqual(settings.NESTED, str(settings.DEBUG)) 27 | 28 | def test_late(self): 29 | """ 30 | Don't hide any module level values defined after `BaseSettings.use()` is called. 31 | """ 32 | self.assertTrue(hasattr(settings, "LATE_SETTING")) 33 | self.assertTrue(settings.LATE_SETTING) 34 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # File for testing __getattr__ factory 2 | import os 3 | 4 | from cbs import BaseSettings, env 5 | 6 | GLOBAL = "global" 7 | 8 | 9 | class Settings(BaseSettings): 10 | DEBUG = True 11 | 12 | private = True 13 | 14 | IMMEDIATE_INT = env.int(5432) 15 | 16 | @env 17 | def STR_ENV(self): 18 | return "default" 19 | 20 | @env.bool 21 | def BOOL_ENV(self): 22 | return False 23 | 24 | def METHOD(self): 25 | return str(self.DEBUG) 26 | 27 | def NESTED(self): 28 | return self.METHOD 29 | 30 | 31 | class ProdSettings(Settings): 32 | DEBUG = False 33 | 34 | @env.bool 35 | def BOOL_ENV(self): 36 | return True 37 | 38 | 39 | class RequiredSettings(BaseSettings): 40 | STR_REQUIRED = env(env.Required) 41 | 42 | 43 | class IncompleteSettings(BaseSettings): 44 | # Must have a default, or a getter (e.g. used as decorator) 45 | INCOMPLETE = env(key="FOO") 46 | 47 | 48 | class GlobalSettings(Settings): 49 | GLOBAL = "local" 50 | 51 | IMMEDIATE_INT = Settings.Unset 52 | 53 | 54 | __getattr__, __dir__ = BaseSettings.use(default=os.environ.get("mode", "")) 55 | 56 | 57 | LATE_SETTING = True 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-classy-settings 2 | ====================== 3 | 4 | Minimalist approach to class-based settings for Django 5 | 6 | https://django-classy-settings.readthedocs.io/latest/ 7 | 8 | 9 | Quick Start 10 | ----------- 11 | 12 | In your `settings.py` 13 | 14 | from cbs import BaseSettings, env 15 | 16 | 17 | ... 18 | # For env settings with a DJANGO_ prefix 19 | denv = env['DJANGO_'] 20 | 21 | class Settings(BaseSettings): 22 | 23 | DEBUG = denv.bool(True) # Controlled by DJANGO_DEBUG env var 24 | 25 | DEFAULT_DATABASE = denv.dburl('sqlite://db.sqlite') 26 | 27 | def DATABASES(self): 28 | return { 29 | 'default': self.DEFAULT_DATABASE, 30 | } 31 | 32 | 33 | class ProdSettings(Settings): 34 | DEBUG = False 35 | 36 | @env 37 | def STATIC_ROOT(self): 38 | raise ValueError("Must set STATIC_ROOT!") 39 | 40 | __getattr__, __dir__ = BaseSettings.use() 41 | 42 | 43 | Switch between ``Settings`` and ``ProdSettings`` using the ``DJANGO_MODE`` env var: 44 | 45 | # Run default Settings 46 | $ ./manage.py test 47 | 48 | # Run ProdSettings 49 | $ DJANGO_MODE=prod ./manage.py test 50 | -------------------------------------------------------------------------------- /src/cbs/cast.py: -------------------------------------------------------------------------------- 1 | """Type-casting helper functions.""" 2 | 3 | 4 | def as_bool(value: str) -> bool: 5 | """Smart cast value to bool 6 | 7 | :param str value: Value to cast. 8 | Value will be stripped and ``.lower()``. 9 | True values: ``("y", "yes", "on", "t", "true", "1")`` 10 | False values: ``("n", "no", "off", "f", "false", "0")`` 11 | All other values raise a ``ValueError`` 12 | """ 13 | if isinstance(value, bool): 14 | return value 15 | value = value.strip().lower() 16 | if value in ("y", "yes", "on", "t", "true", "1"): 17 | return True 18 | if value in ("n", "no", "off", "f", "false", "0"): 19 | return False 20 | raise ValueError(f"Unrecognised value for bool: {value !r}") 21 | 22 | 23 | def as_list(value: str) -> list: 24 | """ 25 | Smart cast value to list by splitting the input on ",". 26 | """ 27 | if isinstance(value, list): 28 | return value 29 | return [x.strip() for x in value.split(",") if x.strip()] 30 | 31 | 32 | def as_tuple(value: str) -> tuple: 33 | """ 34 | Smart cast value to tuple by splitting the input on ",". 35 | """ 36 | if isinstance(value, tuple): 37 | return value 38 | return tuple(as_list(value)) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Curtis Maloney 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /tests/test_cast.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from cbs.cast import as_bool, as_list, as_tuple 4 | 5 | 6 | class UtilsEnv(unittest.TestCase): 7 | def test_as_bool(self): 8 | yes = ("Y", "yes", "ON", "t", "TrUe", "1", True) 9 | no = ("N", "no", "OFF", "f", "FaLsE", "0", False) 10 | for value in yes: 11 | self.assertTrue(as_bool(value)) 12 | for value in no: 13 | self.assertFalse(as_bool(value)) 14 | with self.assertRaisesRegex( 15 | ValueError, 16 | "Unrecognised value for bool: 'blub blah'", 17 | ): 18 | as_bool("blub blah") 19 | 20 | def test_as_list(self): 21 | values = ( 22 | (["foo"], ["foo"]), 23 | ("", []), 24 | ("foo", ["foo"]), 25 | ("foo,bar", ["foo", "bar"]), 26 | ("FOO, bar , buz", ["FOO", "bar", "buz"]), 27 | ( 28 | "example.com,www.example.com,other.com", 29 | ["example.com", "www.example.com", "other.com"], 30 | ), 31 | ) 32 | for given, expected in values: 33 | self.assertEqual(as_list(given), expected) 34 | 35 | def test_as_tuple(self): 36 | values = ( 37 | (("foo",), ("foo",)), 38 | ("", ()), 39 | ("foo", ("foo",)), 40 | ("foo,bar", ("foo", "bar")), 41 | ("FOO, bar , buz", ("FOO", "bar", "buz")), 42 | ( 43 | "example.com,www.example.com,other.com", 44 | ("example.com", "www.example.com", "other.com"), 45 | ), 46 | ) 47 | for given, expected in values: 48 | self.assertEqual(as_tuple(given), expected) 49 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. 2 | # See: https://circleci.com/docs/2.0/configuration-reference 3 | version: 2.1 4 | 5 | # Define a job to be invoked later in a workflow. 6 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs 7 | jobs: 8 | test: 9 | parameters: 10 | python-version: 11 | type: string 12 | django-version: 13 | type: string 14 | 15 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 16 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 17 | docker: 18 | - image: cimg/python:<< parameters.python-version >> 19 | # Add steps to the job 20 | # See: https://circleci.com/docs/2.0/configuration-reference/#steps 21 | steps: 22 | - checkout 23 | - run: 24 | name: "Install..." 25 | command: "pip install -e .[dev]" 26 | - run: 27 | name: "Install Django..." 28 | command: "pip install -U 'Django~=<< parameters.django-version >>'" 29 | - run: 30 | name: "Test" 31 | command: "coverage run" 32 | - run: 33 | name: "Report" 34 | command: "coverage report" 35 | 36 | # Invoke jobs via workflows 37 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows 38 | workflows: 39 | qa-workflow: 40 | jobs: 41 | - test: 42 | matrix: 43 | parameters: 44 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 45 | django-version: ["4.2", "5.0", "5.1"] 46 | exclude: 47 | - python-version: "3.8" 48 | django-version: "5.0" 49 | - python-version: "3.9" 50 | django-version: "5.0" 51 | - python-version: "3.8" 52 | django-version: "5.1" 53 | - python-version: "3.9" 54 | django-version: "5.1" 55 | -------------------------------------------------------------------------------- /src/cbs/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL style config parsing. 3 | 4 | Inspired by dj_database_url 5 | """ 6 | 7 | from urllib.parse import parse_qs, unquote, urlparse 8 | 9 | from .cast import as_bool 10 | 11 | ENGINE_MAP = { 12 | "postgres": "django.db.backends.postgresql", 13 | "postgresql": "django.db.backends.postgresql", 14 | "mysql": "django.db.backends.mysql", 15 | "mariadb": "django.db.backends.mysql", 16 | "sqlite": "django.db.backends.sqlite3", 17 | "oracle": "django.db.backends.oracle", 18 | } 19 | 20 | OPTS = { 21 | "ATOMIC_REQUESTS": as_bool, 22 | "AUTOCOMMIT": as_bool, 23 | "CONN_MAX_AGE": int, 24 | "CONN_HEALTH_CHECKS": as_bool, 25 | "TIME_ZONE": str, 26 | "DISABLE_SERVER_SIDE_CURSORS": as_bool, 27 | "CHARSET": str, 28 | "COLLATION": str, 29 | } 30 | 31 | 32 | def parse_dburl(url: str) -> dict: 33 | """A light-weight implementation of dj_database_url 34 | 35 | :param str url: A db-url format string 36 | 37 | :return: A Django DATABASES compatible configuration dict. 38 | Unknown keys in the querystring will be placed verbatim in the 39 | ``OPTIONS`` sub-dict. 40 | """ 41 | url = urlparse(url) 42 | 43 | config = { 44 | "ENGINE": ENGINE_MAP.get(url.scheme, url.scheme), 45 | "NAME": unquote(url.path or "").lstrip("/"), 46 | } 47 | 48 | if url.hostname: 49 | config["HOST"] = url.hostname 50 | 51 | if url.username: 52 | config["USER"] = unquote(url.username) 53 | 54 | if url.password: 55 | config["PASSWORD"] = unquote(url.password) 56 | 57 | if url.port: 58 | config["PORT"] = url.port 59 | 60 | opts = parse_qs(url.query) 61 | 62 | options = {} 63 | 64 | for key, values in opts.items(): 65 | _key = key.upper() 66 | try: 67 | caster = OPTS[_key] 68 | config[_key] = caster(*values) 69 | except KeyError: 70 | options[key] = values[0] 71 | 72 | if options: 73 | config["OPTIONS"] = options 74 | 75 | return config 76 | -------------------------------------------------------------------------------- /tests/test_use.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import unittest 4 | 5 | from . import settings # So reload works first time 6 | 7 | 8 | class TestSettingsUse(unittest.TestCase): 9 | def setUp(self): 10 | os.environ.clear() 11 | 12 | def test_use(self): 13 | importlib.reload(settings) 14 | 15 | self.assertEqual(settings.GLOBAL, "global") 16 | 17 | self.assertTrue(settings.DEBUG) 18 | self.assertEqual(settings.STR_ENV, "default") 19 | self.assertFalse(settings.BOOL_ENV) 20 | self.assertEqual(settings.METHOD, "True") 21 | 22 | self.assertEqual(settings.IMMEDIATE_INT, 5432) 23 | 24 | def test_use_prod(self): 25 | os.environ["DJANGO_MODE"] = "prod" 26 | os.environ["IMMEDIATE_INT"] = "2345" 27 | 28 | importlib.reload(settings) 29 | 30 | self.assertFalse(settings.DEBUG) 31 | self.assertEqual(settings.STR_ENV, "default") 32 | self.assertTrue(settings.BOOL_ENV) 33 | self.assertEqual(settings.METHOD, "False") 34 | 35 | self.assertEqual(settings.IMMEDIATE_INT, 2345) 36 | 37 | def test_use_default(self): 38 | os.environ["mode"] = "prod" 39 | 40 | importlib.reload(settings) 41 | 42 | self.assertFalse(settings.DEBUG) 43 | 44 | def test_use_required(self): 45 | os.environ["DJANGO_MODE"] = "required" 46 | 47 | with self.assertRaises(ValueError): 48 | importlib.reload(settings) 49 | 50 | def test_use_env(self): 51 | os.environ["DJANGO_MODE"] = "prod" 52 | os.environ["STR_ENV"] = "override" 53 | os.environ["BOOL_ENV"] = "f" 54 | 55 | importlib.reload(settings) 56 | 57 | self.assertFalse(settings.DEBUG) 58 | self.assertEqual(settings.STR_ENV, "override") 59 | self.assertFalse(settings.BOOL_ENV) 60 | self.assertEqual(settings.METHOD, "False") 61 | 62 | def test_use_unknown(self): 63 | os.environ["DJANGO_MODE"] = "mystery" 64 | 65 | with self.assertRaises( 66 | ValueError, 67 | msg="Could not find Settings class for mode 'mystery' (Known: Settings, ProdSettings, GlobalSettings)", 68 | ): 69 | importlib.reload(settings) 70 | 71 | def test_unset(self): 72 | os.environ["DJANGO_MODE"] = "global" 73 | 74 | importlib.reload(settings) 75 | 76 | with self.assertRaises(AttributeError): 77 | settings.IMMEDIATE_INT 78 | 79 | def test_warning(self): 80 | os.environ["DJANGO_MODE"] = "global" 81 | 82 | with self.assertWarns(UserWarning): 83 | importlib.reload(settings) 84 | dir(settings) 85 | 86 | def test_incomplete(self): 87 | os.environ["DJANGO_MODE"] = "incomplete" 88 | 89 | with self.assertRaises(RuntimeError): 90 | importlib.reload(settings) 91 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-classy-settings" 3 | description = "Simple class-based settings for Django" 4 | readme = "README.md" 5 | 6 | authors = [ 7 | {name = "Curtis Maloney", email = "curtis@tinbrain.net" }, 8 | ] 9 | 10 | license.text = "BSD-2-Clause" 11 | 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Framework :: Django", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.7", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: Implementation :: PyPy", 24 | "Intended Audience :: Developers", 25 | ] 26 | 27 | requires-python = ">= 3.7" 28 | 29 | dependencies = [ 30 | "django", 31 | ] 32 | 33 | dynamic = ["version"] 34 | 35 | [project.urls] 36 | "Source Code" = "https://github.com/funkybob/django-classy-settings" 37 | "Documentation" = "https://django-classy-settings.readthedocs.io/en/latest/" 38 | 39 | [tool.setuptools.dynamic] 40 | version.attr = "cbs.version.__version__" 41 | 42 | [build-system] 43 | requires = ["setuptools"] 44 | build-backend = "setuptools.build_meta" 45 | 46 | [project.optional-dependencies] 47 | dev = [ 48 | "pytest", 49 | "coverage", 50 | "ruff", 51 | "Sphinx", 52 | "build", 53 | ] 54 | 55 | [tool.poetry] 56 | 57 | packages = [ 58 | { include = "cbs" }, 59 | ] 60 | 61 | [tool.coverage.run] 62 | command_line = "-m pytest" 63 | branch = true 64 | 65 | [tool.ruff] 66 | line-length = 119 67 | 68 | [tool.ruff.lint] 69 | select = [ 70 | 'E', 71 | 'F', 72 | 'W', 73 | 'I', # isort 74 | 'N', 75 | 'UP', 76 | 'S', # bandit (security) 77 | 'A', # builtins 78 | 'C', # commas 79 | 'C4', # comprehensions 80 | 'DTZ', # datetimez 81 | 'ISC', # implicit string concat 82 | 'PIE', 83 | 'ARG', # unused arguments 84 | 'PL', # pylint 85 | 'FLY', # f-string usages 86 | 'PERF', 87 | 'RUF', # ruff specific 88 | ] 89 | 90 | ignore = [ 91 | "ISC001", # conflicts with ruff formatter 92 | ] 93 | 94 | fixable = ["ALL"] 95 | 96 | [tool.ruff.format] 97 | quote-style = "double" # Lower chance of needing to escape; ' appears in strings more often than " 98 | 99 | docstring-code-format = false 100 | 101 | [tool.ruff.lint.isort] 102 | section-order = [ 103 | 'future', 104 | 'standard-library', 105 | 'third-party', 106 | 'first-party', 107 | 'local-folder', 108 | ] 109 | 110 | [tool.ruff.lint.per-file-ignores] 111 | "tests/*" = [ 112 | "N802", "N806", # lots of Setttings.NAMES 113 | 114 | ] 115 | -------------------------------------------------------------------------------- /src/cbs/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | from warnings import warn 4 | 5 | __all__ = ["BaseSettings"] 6 | 7 | 8 | class Unset: 9 | pass 10 | 11 | 12 | class BaseSettings: 13 | """Base class for env switchable settings configuration.""" 14 | 15 | __children = {} # noqa: RUF012 16 | 17 | Unset = Unset 18 | 19 | def __init_subclass__(cls, **kwargs): 20 | cls.__children[cls.__name__] = cls 21 | super().__init_subclass__(**kwargs) 22 | 23 | def __getattribute__(self, name): 24 | val = super().__getattribute__(name) 25 | if val is Unset: 26 | raise AttributeError(name) 27 | if isinstance(val, partial): 28 | raise RuntimeError(f"{name} needs default or getter.") 29 | if name.isupper() and callable(val): 30 | val = val() 31 | return val 32 | 33 | @classmethod 34 | def use(cls, default="", env="DJANGO_MODE"): 35 | """Helper for accessing sub-classes via env var name. 36 | 37 | Gets a sub-class instance using ``get_settings_instance``, and returns 38 | the results of calling ``getattr_factory`` and ``dir_factory`` on it. 39 | 40 | :param str default: Default value for DJANGO_MODE if not set. 41 | :param str env: Envirionment variable to get settings mode name from. 42 | :return: functions suitable for module-level ``__getattr__`` and 43 | ``__dir__`` 44 | """ 45 | settings = cls.get_settings_instance(default, env) 46 | 47 | return ( 48 | settings.getattr_factory(), 49 | settings.dir_factory(), 50 | ) 51 | 52 | @classmethod 53 | def get_settings_instance(cls, default="", env="DJANGO_MODE"): 54 | """Create an instance of the appropriate Settings sub-class. 55 | 56 | Takes the value of ``os.environ[env]``, calls ``.title()`` on it, then 57 | appends `"Settings"`. If there is no value in ``os.environ``, it will 58 | use ``default`` instead. 59 | 60 | It will then find a sub-class of that name, and return an instance of 61 | it. 62 | """ 63 | base = os.environ.get(env, default) 64 | name = f"{base.title()}Settings" 65 | 66 | try: 67 | return cls.__children[name]() 68 | except KeyError: 69 | raise ValueError( 70 | f'Could not find Settings class for mode {base!r} ' f'(Known: {", ".join(cls.__children)})', 71 | ) 72 | 73 | def getattr_factory(self): 74 | """Returns a function to be used as __getattr__ in a module. 75 | 76 | :return: function suitable for module-level ``__getattr__`` 77 | """ 78 | 79 | def __getattr__(key, self=self): # noqa: N807 80 | if not key.isupper(): 81 | raise AttributeError(key) 82 | return getattr(self, key) 83 | 84 | return __getattr__ 85 | 86 | def dir_factory(self): 87 | """Returns a function to be used as __dir__ in a module. 88 | 89 | :return: function suitable for module-level ``__dir__`` 90 | """ 91 | from inspect import getmembers, getmodule 92 | 93 | pkg = getmodule(self.__class__) 94 | 95 | class_settings = [ 96 | name 97 | for name, value in getmembers(self) 98 | if name.isupper() 99 | and value is not Unset 100 | ] # fmt: skip 101 | 102 | def __dir__(): # noqa: N807 103 | package_settings = [ 104 | name 105 | for name in vars(pkg).keys() 106 | if name.isupper() 107 | ] # fmt: skip 108 | 109 | overlap = set(package_settings).intersection(class_settings) 110 | 111 | if overlap: 112 | warn(f"Masked settings in {self.__class__.__name__}: {overlap}") 113 | 114 | return package_settings + class_settings 115 | 116 | return __dir__ 117 | -------------------------------------------------------------------------------- /src/cbs/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | 4 | from django.utils.functional import cached_property 5 | 6 | from . import cast 7 | from .urls import parse_dburl 8 | 9 | __all__ = ["env"] 10 | 11 | # Target supported env types: 12 | # + str : noop 13 | # + int : int() 14 | # + bool: as_bool 15 | # + list 16 | # - list 17 | # + tuple 18 | # + DB Config: db-url 19 | # - Cache Config: db-url 20 | 21 | 22 | class env: # noqa: N801 23 | """property to make environment variable based settings simpler. 24 | 25 | :param Any default: default value 26 | If it's a string, it will be passed to the ``cast`` function 27 | When used as a decorator, this is the method. 28 | :param str key: Override environment variable name 29 | (Defaults to class attribute name) 30 | :param str prefix: Prefix to ``key`` when looking up ``os.environ`` 31 | :param func cast: Function to cast ``str`` values. 32 | 33 | """ 34 | 35 | class Required: 36 | pass 37 | 38 | PREFIX = "" 39 | 40 | def __new__(cls, *args, **kwargs): 41 | """ 42 | Catch case when we're used as a decorator with keyword arguments, or 43 | used to pre-set some defaults. 44 | """ 45 | if not args and not kwargs: 46 | raise TypeError("env requires positional or keyword arguments") 47 | if not args: 48 | return partial(cls, **kwargs) 49 | return object.__new__(cls) 50 | 51 | def __class_getitem__(cls, key): 52 | """Helper to allow creating env sub-classes with PREFIX pre-set.""" 53 | return type(f"{cls.__name__}__{key}", (cls,), {"PREFIX": key}) 54 | 55 | def __init__(self, getter, key=None, cast=None, prefix=None): 56 | self.cast = cast 57 | self.key = key 58 | self.prefix = prefix or self.PREFIX 59 | 60 | if getter is not self.Required and callable(getter): 61 | self.getter = getter 62 | else: 63 | self.getter = None 64 | self.default = getter 65 | 66 | @cached_property 67 | def env_name(self): 68 | return f"{self.prefix}{self.key}" 69 | 70 | def __set_name__(self, owner, name): 71 | if self.key is None: 72 | self.key = name 73 | 74 | def __get__(self, obj, cls=None): 75 | if obj is None: 76 | return self 77 | 78 | try: 79 | value = os.environ[self.env_name] 80 | except KeyError: 81 | if self.getter is None: 82 | if self.default is self.Required: 83 | raise ValueError(f"Environment variable {self.env_name} is required but not set.") 84 | value = self.default 85 | else: 86 | try: 87 | value = self.getter(obj) 88 | except Exception as e: 89 | raise e from None 90 | 91 | if self.cast and isinstance(value, str): 92 | value = self.cast(value) 93 | 94 | return value 95 | 96 | def __call__(self): 97 | return self.__get__(self) 98 | 99 | @classmethod 100 | def bool(cls, *args, **kwargs): 101 | """Helper for bool-cast settings. 102 | 103 | Uses :py:func:`.cast.as_bool` 104 | """ 105 | return cls(cast=cast.as_bool, *args, **kwargs) 106 | 107 | @classmethod 108 | def int(cls, *args, **kwargs): 109 | """Helper for int-cast settings. 110 | 111 | Uses ``int`` 112 | """ 113 | return cls(cast=int, *args, **kwargs) 114 | 115 | @classmethod 116 | def dburl(cls, *args, **kwargs): 117 | """Helper for DB-Url cast settings. 118 | 119 | Uses :py:func:`.urls.parse_dburl` 120 | """ 121 | return cls(cast=parse_dburl, *args, **kwargs) 122 | 123 | @classmethod 124 | def list(cls, *args, **kwargs): 125 | """Helper for list-cast settings. 126 | 127 | Uses :py:func:`.cast.as_list` 128 | """ 129 | return cls(cast=cast.as_list, *args, **kwargs) 130 | 131 | @classmethod 132 | def tuple(cls, *args, **kwargs): 133 | """Helper for tuple-cast settings. 134 | 135 | Uses :py:func:`.cast.as_tuple` 136 | """ 137 | return cls(cast=cast.as_tuple, *args, **kwargs) 138 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | 3.0.7 (2024-10-17) 5 | ------------------ 6 | 7 | Features Added: 8 | 9 | - `env` will now raise a `RuntimeError` when configured without a default and a getter. 10 | This can happen, for instance, when doing: 11 | ```py 12 | FOO = env(key="BAR") 13 | ``` 14 | 15 | - A default for `DJANGO_MODE` can now be specified when calling `Settings.use()` 16 | 17 | - `__dir__` now re-inspects the module every time it's invoked for global properties. 18 | This avoids hiding any values defined after `.use()` is called. 19 | 20 | 3.0.6 (2024-09-18) 21 | ------------------ 22 | 23 | Features Added: 24 | 25 | - Added `BaseSettings.Unset` to allow a class to un-set an inherited setting. 26 | This is useful to allow overriding Django defaults in development, and 27 | reverting in other configurations. 28 | 29 | - An `env` can now be explicitly marked as mandatory by using `NAME = env(env.Required)` 30 | 31 | - `use()` will now `warn` when any setting on a `BaseSetting` is masked by a 32 | setting defined outside a settings class. 33 | Because of the rules of `__getattr__` these Class Settings would never be used. 34 | 35 | 3.0.5 (2024-03-15) 36 | ------------------ 37 | 38 | Features Added: 39 | 40 | - All accesses to SHOUTY_SNAKE_CASE methods on a `BaseSettings` class will now 41 | be treated as properties. 42 | Previously accessing these from within a method would require remembering to 43 | call it. 44 | 45 | 3.0.4 (2023-11-16) 46 | ------------------ 47 | 48 | Features Added: 49 | 50 | - Guarantee a `Settings` class is only instantiated once per call to `use()` 51 | Note: `getattr_factory` and `dir_factory` are no longer classmethods. 52 | - Break out `get_settings_instance` method from `use` for more flexibility. 53 | - Add more helpful error message when a sub-class can't be found. 54 | 55 | Cleanup: 56 | 57 | - Moved `BaseSettings` out of `__init__` into its own file. 58 | 59 | 3.0.3 (2023-06-28) 60 | ------------------ 61 | 62 | Features Added: 63 | 64 | - Raise exception when `env()` is called without any arguments at all. 65 | 66 | 3.0.2 (2023-04-20) 67 | ------------------ 68 | 69 | Housekeeping: 70 | 71 | - Added Django 4.2 to CI 72 | - Added Python 3.11 to CI 73 | - Dropped Django 2.2 from CI 74 | - Documentation corrections and clarifications 75 | 76 | Features Added: 77 | 78 | - `__dir__` now calculates visible attributes every call, instead of when it's constructed. 79 | - `env` instances are now callable, making them somewhat usable outside of classes 80 | 81 | 3.0.1 (2022-09-15) 82 | ------------------ 83 | 84 | .. warning:: The previous release does not work with Django. 85 | 86 | This release is to fix that glaring mistake. 87 | 88 | Bugs Fixed: 89 | 90 | - Changed to using lookup syntax to creat prefixed env classes 91 | - Added __dir__ as second return value from `use()` to satisfy Django settings 92 | 93 | 3.0.0 (2022-09-08) 94 | ------------------ 95 | 96 | 97 | .. admonition:: Backwards incompatible changes! 98 | 99 | This release makes major API changes and drops support for some features. 100 | 101 | Major overhaul of the code base. 102 | 103 | - Supports Python 3.7+ 104 | - Removed `toggle` feature entirely. 105 | 106 | Features Added: 107 | 108 | - Pre-canned typed `env` helpers: `env.bool`, `env.int`, `env.dburl`, 109 | `env.list`, `env.tuple` 110 | - `BaseSettings` class, with `getattr_factory` and auto-subclass resolution. 111 | 112 | 2.1.1 (2017-09-22) 113 | ------------------ 114 | 115 | - Renamed @env(type) to cast 116 | 117 | Bugs Fixed: 118 | 119 | - Fix required env vars not having a name to look up (Thanks pgcd!) 120 | 121 | 2.1.0 (2017-08-10) 122 | ------------------ 123 | 124 | Removed: 125 | 126 | - Dropped Python2 support. 127 | - Removed deprecated ``cbs.BaseSettings`` 128 | 129 | 2.0.1 (2016-04-16) 130 | ------------------ 131 | 132 | Features Added: 133 | 134 | - You can now have an env var that _must_ have an env set. 135 | - New `as_list` and `as_tuple` utility functions for casting values 136 | [Thanks MarkusH] 137 | 138 | 2.0.0 (2016-02-08) 139 | ------------------ 140 | 141 | The 'softer-touch' branch. 142 | 143 | As of 2.0, `django-classy-settings` is advocating a "minimal impact" approach. 144 | 145 | As such, it's recommended you retain the default ``settings.py`` as provided by 146 | Django, and only move to a class those settings which change based on 147 | environment. 148 | 149 | Deprecation: 150 | 151 | - Remove ``cbs.base`` 152 | - No longer import ``BaseSettings`` in ``cbs`` 153 | - Purged ``cbs.base`` 154 | - Moved ``cbs.base.GlobalSettings`` into ``cbs`` 155 | 156 | 1.1.8 (2015-12-??) 157 | ------------------ 158 | 159 | Features Added: 160 | 161 | - Use `inspect.ismethod` instead of just `callable` for detecting methods on 162 | settings classes. 163 | 164 | Deprecation: 165 | 166 | - Removed ill concieved `@cbs.returns_callable`. Document a solution instead. 167 | 168 | 1.1.7 (2015-12-02) 169 | ------------------ 170 | 171 | Features Added: 172 | 173 | - Added `@cbs.returns_callable` for settings that return callables 174 | 175 | 1.1.6 (2015-11-29) 176 | ------------------ 177 | 178 | Features Added: 179 | 180 | - Tox config was overhauled, and hooked into "setup.py test" (Thanks TC) 181 | 182 | Bugs Fixed: 183 | 184 | - Fix case where @env(...) would return a partial on env, instead of the 185 | sub-class, which broke envbool (Thanks TC) 186 | 187 | 1.1.5 (2015-11-05) 188 | ------------------ 189 | 190 | Features Added: 191 | 192 | - Added Django 1.9 base settings 193 | 194 | 1.1.4 (2015-09-25) 195 | ------------------ 196 | 197 | Features Added: 198 | 199 | - Added Travis CI and tox config files 200 | - Match Django for detecting settings 201 | - Added GlobalSettings mixin 202 | 203 | Bugs Fixed: 204 | 205 | - Only apply type cast in @env to values sourced from environment. 206 | - Correct call to setdefault in envbool 207 | 208 | 1.1.3 (2015-08-19) 209 | ------------------ 210 | 211 | Bugs Fixed: 212 | 213 | - Moved TEMPLATE_DEBUG setting to Django 1.6/1.7 settings, as it's no longer 214 | valid in Django 1.8. 215 | 216 | 1.1.2 (2015-07-22) 217 | ------------------ 218 | 219 | Bugs Fixed: 220 | 221 | - Type-cast values before caching 222 | 223 | 1.1.1 (2015-07-04) 224 | ------------------ 225 | 226 | Features Added: 227 | 228 | + Added tests (Thanks David Sanders) 229 | 230 | Bugs Fixed: 231 | 232 | - Fixed bug where we passed the env class instead of the settings object to the 233 | default method. 234 | 235 | 1.1.0 (2015-03-31) 236 | ------------------ 237 | 238 | Features Added: 239 | 240 | + Added type casting to @cbs.env 241 | + Added Django 1.8 default settings 242 | + Move settings into separate modules 243 | + Added Feature Toggle tool. 244 | 245 | 1.0.3 (2015-02-18) 246 | ------------------ 247 | 248 | Features Added: 249 | 250 | + Added cbs.boolenv 251 | 252 | 1.0.2 (2015-02-05) 253 | ------------------ 254 | 255 | Features Added: 256 | 257 | + Support different BaseSettings for different Django versions 258 | + Use Django's bundled version of six 259 | + Raise a ValueError if we can't find the settings class 260 | 261 | Bugs fixed: 262 | 263 | + Fixed packaging for requirements 264 | 265 | 1.0.1 (2014-08-15) 266 | ------------------ 267 | 268 | Features Added: 269 | 270 | + Added DEFAULT_ENV_PREFIX 271 | 272 | 273 | 1.0.0 (2014-08-12) 274 | ------------------ 275 | 276 | Initial Release 277 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoClassySettings.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoClassySettings.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoClassySettings" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoClassySettings" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DjangoClassySettings.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DjangoClassySettings.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Django Classy Settings documentation build configuration file, created by 3 | # sphinx-quickstart on Thu Jul 24 13:53:10 2014. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import sys 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | # -- General configuration ------------------------------------------------ 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | # needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | "sphinx.ext.autodoc", 32 | ] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ["_templates"] 36 | 37 | # The suffix of source filenames. 38 | source_suffix = ".rst" 39 | 40 | # The encoding of source files. 41 | # source_encoding = 'utf-8-sig' 42 | 43 | # The master toctree document. 44 | master_doc = "index" 45 | 46 | # General information about the project. 47 | project = "Django Classy Settings" 48 | copyright = "2022, Curtis Maloney" 49 | 50 | # The version info for the project you're documenting, acts as replacement for 51 | # |version| and |release|, also used in various other places throughout the 52 | # built documents. 53 | # 54 | # The short X.Y version. 55 | version = "3.0" 56 | # The full version, including alpha/beta/rc tags. 57 | release = "3.0.7" 58 | 59 | # The language for content autogenerated by Sphinx. Refer to documentation 60 | # for a list of supported languages. 61 | # language = None 62 | 63 | # There are two options for replacing |today|: either, you set today to some 64 | # non-false value, then it is used: 65 | # today = '' 66 | # Else, today_fmt is used as the format for a strftime call. 67 | # today_fmt = '%B %d, %Y' 68 | 69 | # List of patterns, relative to source directory, that match files and 70 | # directories to ignore when looking for source files. 71 | exclude_patterns = ["_build"] 72 | 73 | # The reST default role (used for this markup: `text`) to use for all 74 | # documents. 75 | # default_role = None 76 | 77 | # If true, '()' will be appended to :func: etc. cross-reference text. 78 | # add_function_parentheses = True 79 | 80 | # If true, the current module name will be prepended to all description 81 | # unit titles (such as .. function::). 82 | # add_module_names = True 83 | 84 | # If true, sectionauthor and moduleauthor directives will be shown in the 85 | # output. They are ignored by default. 86 | # show_authors = False 87 | 88 | # The name of the Pygments (syntax highlighting) style to use. 89 | pygments_style = "sphinx" 90 | 91 | # A list of ignored prefixes for module index sorting. 92 | # modindex_common_prefix = [] 93 | 94 | # If true, keep warnings as "system message" paragraphs in the built documents. 95 | # keep_warnings = False 96 | 97 | 98 | # -- Options for HTML output ---------------------------------------------- 99 | 100 | # The theme to use for HTML and HTML Help pages. See the documentation for 101 | # a list of builtin themes. 102 | html_theme = "alabaster" 103 | 104 | # Theme options are theme-specific and customize the look and feel of a theme 105 | # further. For a list of options available for each theme, see the 106 | # documentation. 107 | # html_theme_options = {} 108 | 109 | # Add any paths that contain custom themes here, relative to this directory. 110 | # html_theme_path = [] 111 | 112 | # The name for this set of Sphinx documents. If None, it defaults to 113 | # " v documentation". 114 | # html_title = None 115 | 116 | # A shorter title for the navigation bar. Default is the same as html_title. 117 | # html_short_title = None 118 | 119 | # The name of an image file (relative to this directory) to place at the top 120 | # of the sidebar. 121 | # html_logo = None 122 | 123 | # The name of an image file (within the static path) to use as favicon of the 124 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 125 | # pixels large. 126 | # html_favicon = None 127 | 128 | # Add any paths that contain custom static files (such as style sheets) here, 129 | # relative to this directory. They are copied after the builtin static files, 130 | # so a file named "default.css" will overwrite the builtin "default.css". 131 | html_static_path = ["_static"] 132 | 133 | # Add any extra paths that contain custom files (such as robots.txt or 134 | # .htaccess) here, relative to this directory. These files are copied 135 | # directly to the root of the documentation. 136 | # html_extra_path = [] 137 | 138 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 139 | # using the given strftime format. 140 | # html_last_updated_fmt = '%b %d, %Y' 141 | 142 | # If true, SmartyPants will be used to convert quotes and dashes to 143 | # typographically correct entities. 144 | # html_use_smartypants = True 145 | 146 | # Custom sidebar templates, maps document names to template names. 147 | # html_sidebars = {} 148 | 149 | # Additional templates that should be rendered to pages, maps page names to 150 | # template names. 151 | # html_additional_pages = {} 152 | 153 | # If false, no module index is generated. 154 | # html_domain_indices = True 155 | 156 | # If false, no index is generated. 157 | # html_use_index = True 158 | 159 | # If true, the index is split into individual pages for each letter. 160 | # html_split_index = False 161 | 162 | # If true, links to the reST sources are added to the pages. 163 | # html_show_sourcelink = True 164 | 165 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 166 | # html_show_sphinx = True 167 | 168 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 169 | # html_show_copyright = True 170 | 171 | # If true, an OpenSearch description file will be output, and all pages will 172 | # contain a tag referring to it. The value of this option must be the 173 | # base URL from which the finished HTML is served. 174 | # html_use_opensearch = '' 175 | 176 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 177 | # html_file_suffix = None 178 | 179 | # Output file base name for HTML help builder. 180 | htmlhelp_basename = "DjangoClassySettingsdoc" 181 | 182 | 183 | # -- Options for LaTeX output --------------------------------------------- 184 | 185 | latex_elements = { 186 | # The paper size ('letterpaper' or 'a4paper'). 187 | #'papersize': 'letterpaper', 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | # Additional stuff for the LaTeX preamble. 191 | #'preamble': '', 192 | } 193 | 194 | # Grouping the document tree into LaTeX files. List of tuples 195 | # (source start file, target name, title, 196 | # author, documentclass [howto, manual, or own class]). 197 | latex_documents = [ 198 | ("index", "DjangoClassySettings.tex", "Django Classy Settings Documentation", "Curtis Maloney", "manual"), 199 | ] 200 | 201 | # The name of an image file (relative to this directory) to place at the top of 202 | # the title page. 203 | # latex_logo = None 204 | 205 | # For "manual" documents, if this is true, then toplevel headings are parts, 206 | # not chapters. 207 | # latex_use_parts = False 208 | 209 | # If true, show page references after internal links. 210 | # latex_show_pagerefs = False 211 | 212 | # If true, show URL addresses after external links. 213 | # latex_show_urls = False 214 | 215 | # Documents to append as an appendix to all manuals. 216 | # latex_appendices = [] 217 | 218 | # If false, no module index is generated. 219 | # latex_domain_indices = True 220 | 221 | 222 | # -- Options for manual page output --------------------------------------- 223 | 224 | # One entry per manual page. List of tuples 225 | # (source start file, name, description, authors, manual section). 226 | man_pages = [("index", "djangoclassysettings", "Django Classy Settings Documentation", ["Curtis Maloney"], 1)] 227 | 228 | # If true, show URL addresses after external links. 229 | # man_show_urls = False 230 | 231 | 232 | # -- Options for Texinfo output ------------------------------------------- 233 | 234 | # Grouping the document tree into Texinfo files. List of tuples 235 | # (source start file, target name, title, author, 236 | # dir menu entry, description, category) 237 | texinfo_documents = [ 238 | ( 239 | "index", 240 | "DjangoClassySettings", 241 | "Django Classy Settings Documentation", 242 | "Curtis Maloney", 243 | "DjangoClassySettings", 244 | "One line description of project.", 245 | "Miscellaneous", 246 | ), 247 | ] 248 | 249 | # Documents to append as an appendix to all manuals. 250 | # texinfo_appendices = [] 251 | 252 | # If false, no module index is generated. 253 | # texinfo_domain_indices = True 254 | 255 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 256 | # texinfo_show_urls = 'footnote' 257 | 258 | # If true, do not generate a @detailmenu in the "Top" node's menu. 259 | # texinfo_no_detailmenu = False 260 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from cbs import env 5 | 6 | 7 | class EnvTestCase(unittest.TestCase): 8 | def setUp(self): 9 | """ 10 | Clear the env dict before each test. 11 | """ 12 | os.environ.clear() 13 | 14 | 15 | class TestProperty(EnvTestCase): 16 | def test_class_access(self): 17 | """ 18 | Accessing the property on the class returns the property, not its 19 | value. 20 | """ 21 | _env = env("value") 22 | 23 | class Settings: 24 | ENV = _env 25 | 26 | self.assertIs(Settings.ENV, _env) 27 | 28 | 29 | class TestPartial(EnvTestCase): 30 | def test_prefix(self): 31 | denv = env["DJANGO_"] 32 | 33 | class Settings: 34 | SETTING = denv("value") 35 | BOOL = denv.bool(True) 36 | 37 | os.environ["DJANGO_SETTING"] = "override" 38 | self.assertEqual(Settings().SETTING, "override") 39 | 40 | 41 | class TestRequired(EnvTestCase): 42 | def test_required(self): 43 | TEST = env(env.Required) 44 | TEST.key = "TEST" # This is normally set via __set_name__ 45 | 46 | with self.assertRaises(ValueError, msg="Env var TEST is required but not set"): 47 | TEST() 48 | 49 | 50 | class TestCallable(EnvTestCase): 51 | def test_default(self): 52 | TEST = env("default", key="TEST") 53 | 54 | os.environ["TEST"] = "test" 55 | 56 | self.assertEqual(TEST(), "test") 57 | 58 | def test_no_arguments(self): 59 | """ 60 | env() can't sensibly be called without _any_ arguments. 61 | 62 | Proper usage is one of: 63 | FOO = env(default) 64 | 65 | FOO = env(key=value, ...) 66 | 67 | # Implicitly called with foo as argument 68 | @env 69 | def foo(...): 70 | 71 | @env(key=value, ...) 72 | def foo(...): 73 | """ 74 | with self.assertRaises(TypeError): 75 | env() 76 | 77 | 78 | class TestImmediate(EnvTestCase): 79 | def test_default(self): 80 | class Settings: 81 | SETTING = env("value") 82 | 83 | self.assertEqual(Settings().SETTING, "value") 84 | 85 | def test_override(self): 86 | class Settings: 87 | SETTING = env("value") 88 | 89 | os.environ["SETTING"] = "override" 90 | self.assertEqual(Settings().SETTING, "override") 91 | 92 | def test_with_key(self): 93 | class Settings: 94 | SETTING = env("value", key="OTHER") 95 | 96 | os.environ["OTHER"] = "override" 97 | self.assertEqual(Settings().SETTING, "override") 98 | 99 | def test_with_prefix(self): 100 | class Settings: 101 | SETTING = env("value", prefix="PREFIX_") 102 | 103 | os.environ["PREFIX_SETTING"] = "override" 104 | self.assertEqual(Settings().SETTING, "override") 105 | 106 | def test_with_prefix_and_key(self): 107 | class Settings: 108 | SETTING = env("value", key="OTHER", prefix="PREFIX_") 109 | 110 | os.environ["PREFIX_OTHER"] = "override" 111 | self.assertEqual(Settings().SETTING, "override") 112 | 113 | 114 | class TestMethod(EnvTestCase): 115 | def test_default(self): 116 | class Settings: 117 | @env 118 | def SETTING(self): 119 | return "value" 120 | 121 | self.assertEqual(Settings().SETTING, "value") 122 | 123 | def test_override(self): 124 | class Settings: 125 | @env 126 | def SETTING(self): 127 | raise ValueError() # pragma: no cover 128 | 129 | os.environ["SETTING"] = "override" 130 | self.assertEqual(Settings().SETTING, "override") 131 | 132 | def test_with_key(self): 133 | class Settings: 134 | @env(key="OTHER") 135 | def SETTING(self): 136 | raise ValueError() # pragma: no cover 137 | 138 | os.environ["OTHER"] = "override" 139 | self.assertEqual(Settings().SETTING, "override") 140 | 141 | def test_with_prefix(self): 142 | class Settings: 143 | @env(prefix="PREFIX_") 144 | def SETTING(self): 145 | raise ValueError() # pragma: no cover 146 | 147 | os.environ["PREFIX_SETTING"] = "override" 148 | self.assertEqual(Settings().SETTING, "override") 149 | 150 | def test_with_prefix_and_key(self): 151 | class Settings: 152 | @env(key="OTHER", prefix="PREFIX_") 153 | def SETTING(self): 154 | raise ValueError() # pragma: no cover 155 | 156 | os.environ["PREFIX_OTHER"] = "override" 157 | self.assertEqual(Settings().SETTING, "override") 158 | 159 | def test_refer_to_other_setting(self): 160 | class Settings: 161 | OTHER = True 162 | 163 | @env 164 | def SETTING(self): 165 | return self.OTHER 166 | 167 | self.assertEqual(Settings().SETTING, True) 168 | 169 | 170 | class EnvBoolTest(EnvTestCase): 171 | def test_immediate(self): 172 | class Settings: 173 | DEBUG = env.bool(False) 174 | 175 | os.environ["DEBUG"] = "y" 176 | self.assertTrue(Settings().DEBUG) 177 | 178 | def test_default(self): 179 | class Settings: 180 | @env.bool 181 | def SETTING(self): 182 | return None 183 | 184 | self.assertEqual(Settings().SETTING, None) 185 | 186 | def test_env_bool_casting(self): 187 | class Settings: 188 | @env.bool 189 | def SETTING(self): 190 | return None 191 | 192 | s = Settings() 193 | 194 | # Verify default, and prime cache 195 | self.assertIsNone(s.SETTING) 196 | 197 | # True values 198 | for tval in ("y", "yes", "on", "t", "true", "1"): 199 | os.environ["SETTING"] = tval 200 | self.assertTrue(s.SETTING) 201 | 202 | os.environ["SETTING"] = tval.title() 203 | self.assertTrue(s.SETTING) 204 | 205 | os.environ["SETTING"] = tval.upper() 206 | self.assertTrue(s.SETTING) 207 | 208 | for fval in ("n", "no", "off", "f", "false", "0"): 209 | os.environ["SETTING"] = fval 210 | self.assertFalse(s.SETTING) 211 | 212 | os.environ["SETTING"] = fval.title() 213 | self.assertFalse(s.SETTING) 214 | 215 | os.environ["SETTING"] = fval.upper() 216 | self.assertFalse(s.SETTING) 217 | 218 | def test_env_bool_set_invalid(self): 219 | class Settings: 220 | @env.bool 221 | def SETTING(self): 222 | raise ValueError() # pragma: no cover 223 | 224 | s = Settings() 225 | 226 | for value in [ 227 | "yep", 228 | "nah", 229 | "-1", 230 | "10", 231 | "00", 232 | "", 233 | "Y Y", 234 | ]: 235 | os.environ["SETTING"] = value 236 | # Since it raises an exception, we don't have to clear the cache 237 | with self.assertRaises(ValueError): 238 | s.SETTING 239 | 240 | 241 | class EnvIntTest(EnvTestCase): 242 | def test_immediate(self): 243 | class Settings: 244 | SETTING = env.int("5432") 245 | 246 | self.assertEqual(Settings().SETTING, 5432) 247 | 248 | def test_override(self): 249 | class Settings: 250 | SETTING = env.int("5432") 251 | 252 | os.environ["SETTING"] = "2345" 253 | self.assertEqual(Settings().SETTING, 2345) 254 | 255 | 256 | class EndDbUrlTest(EnvTestCase): 257 | def test_default(self): 258 | class Settings: 259 | SETTING = env.dburl("postgres://hostname/dbname") 260 | 261 | value = Settings().SETTING 262 | 263 | self.assertEqual( 264 | value, 265 | { 266 | "ENGINE": "django.db.backends.postgresql", 267 | "HOST": "hostname", 268 | "NAME": "dbname", 269 | }, 270 | ) 271 | 272 | def test_override(self): 273 | class Settings: 274 | SETTING = env.dburl("default") 275 | 276 | os.environ["SETTING"] = "postgres://hostname/dbname" 277 | value = Settings().SETTING 278 | 279 | self.assertEqual( 280 | value, 281 | { 282 | "ENGINE": "django.db.backends.postgresql", 283 | "HOST": "hostname", 284 | "NAME": "dbname", 285 | }, 286 | ) 287 | 288 | 289 | class EnvListTest(EnvTestCase): 290 | def test_immediate(self): 291 | class Settings: 292 | SETTING = env.list(["foo", "bar"]) 293 | 294 | self.assertEqual(Settings().SETTING, ["foo", "bar"]) 295 | 296 | def test_override(self): 297 | class Settings: 298 | SETTING = env.list([1]) 299 | 300 | os.environ["SETTING"] = "one, two" 301 | 302 | self.assertEqual(Settings().SETTING, ["one", "two"]) 303 | 304 | 305 | class EnvTupleTest(EnvTestCase): 306 | def test_immediate(self): 307 | class Settings: 308 | SETTING = env.tuple( 309 | ( 310 | "foo", 311 | "bar", 312 | ) 313 | ) 314 | 315 | self.assertEqual( 316 | Settings().SETTING, 317 | ( 318 | "foo", 319 | "bar", 320 | ), 321 | ) 322 | 323 | def test_override(self): 324 | class Settings: 325 | SETTING = env.tuple((1,)) 326 | 327 | os.environ["SETTING"] = "one, two" 328 | 329 | self.assertEqual( 330 | Settings().SETTING, 331 | ( 332 | "one", 333 | "two", 334 | ), 335 | ) 336 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Classy Settings documentation master file, created by 2 | sphinx-quickstart on Thu Jul 24 13:53:10 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Django Classy Settings 7 | ====================== 8 | 9 | .. rubric:: Stay classy, Django. 10 | 11 | Credits 12 | ------- 13 | 14 | This work was originally inspired by the work of Jessie O'Connor. 15 | 16 | Overview 17 | -------- 18 | 19 | Class-based settings make it easy for you to manage multiple settings profiles 20 | for your Django project, without ever copying values. 21 | 22 | Interdependant values, values sourced from the env, even calculated values are 23 | no problem, since you have the full power of Python and class inheritance. 24 | 25 | 26 | Contents 27 | -------- 28 | 29 | .. toctree:: 30 | :maxdepth: 1 31 | 32 | api 33 | changelog 34 | 35 | Examples 36 | -------- 37 | 38 | Below is a default Django `settings.py` that has been adjusted to show how you 39 | might use `django-classy-settings`: 40 | 41 | .. code-block:: python 42 | :caption: settings.py 43 | :linenos: 44 | :emphasize-lines: 15,89-132,135-143,145-147 45 | 46 | """ 47 | Django settings for dummy project. 48 | 49 | Generated by 'django-admin startproject' using Django 3.2.15. 50 | 51 | For more information on this file, see 52 | https://docs.djangoproject.com/en/3.2/topics/settings/ 53 | 54 | For the full list of settings and their values, see 55 | https://docs.djangoproject.com/en/3.2/ref/settings/ 56 | """ 57 | 58 | from pathlib import Path 59 | 60 | from cbs import BaseSettings, env 61 | 62 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 63 | BASE_DIR = Path(__file__).resolve().parent.parent 64 | 65 | 66 | # Quick-start development settings - unsuitable for production 67 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 68 | 69 | ALLOWED_HOSTS = [] 70 | 71 | ROOT_URLCONF = 'dummy.urls' 72 | 73 | TEMPLATES = [ 74 | { 75 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 76 | 'DIRS': [], 77 | 'APP_DIRS': True, 78 | 'OPTIONS': { 79 | 'context_processors': [ 80 | 'django.template.context_processors.debug', 81 | 'django.template.context_processors.request', 82 | 'django.contrib.auth.context_processors.auth', 83 | 'django.contrib.messages.context_processors.messages', 84 | ], 85 | }, 86 | }, 87 | ] 88 | 89 | WSGI_APPLICATION = 'dummy.wsgi.application' 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 103 | }, 104 | { 105 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 106 | }, 107 | ] 108 | 109 | # Internationalization 110 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 111 | 112 | LANGUAGE_CODE = 'en-us' 113 | 114 | TIME_ZONE = 'UTC' 115 | 116 | USE_I18N = True 117 | 118 | USE_L10N = True 119 | 120 | USE_TZ = True 121 | 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 125 | 126 | STATIC_URL = '/static/' 127 | 128 | # Default primary key field type 129 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 130 | 131 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 132 | 133 | 134 | class Settings(BaseSettings): 135 | 136 | # Allow production to override the secret key, but fall-back to something consistent. 137 | SECRET_KEY = env('django-insecure-kcdhlitokdqc7s7p7)^jm)55%p@frm#l39nzko458#1!6cu*$$') 138 | 139 | # DEBUG defaults to True, but can be overridden by env var `DJANGO_DEBUG` 140 | DEBUG = env.bool(True, prefix='DJANGO_') 141 | 142 | # Simple cases that don't need `self` can even use a lambda 143 | MEDIA_ROOT = env(lambda self: BASE_DIR / 'media') 144 | 145 | # Methods will be transparently invoked by the __getattr__ implementation 146 | def INSTALLED_APPS(self): 147 | return list(filter(None, [ 148 | 'django.contrib.admin', 149 | 'django.contrib.auth', 150 | 'django.contrib.contenttypes', 151 | 'django.contrib.sessions', 152 | 'django.contrib.messages', 153 | 'django.contrib.staticfiles', 154 | # Conditionally include an app 155 | 'debug_toolbar' if self.DEBUG else None, 156 | ])) 157 | 158 | def MIDDLEWARE(self): 159 | return list(filter(None, [ 160 | 'django.middleware.security.SecurityMiddleware', 161 | 'django.contrib.sessions.middleware.SessionMiddleware', 162 | 'django.middleware.common.CommonMiddleware', 163 | 'django.middleware.csrf.CsrfViewMiddleware', 164 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 165 | 'django.contrib.messages.middleware.MessageMiddleware', 166 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 167 | # Conditionally include a middleware 168 | 'debug_toolbar.middleware.DebugToolbarMiddleware' if self.DEBUG else None, 169 | ])) 170 | 171 | # Parse the URL into a database config dict. 172 | DEFAULT_DATABASE = env.dburl('sqlite:///db.sqlite') 173 | 174 | def DATABASES(self) 175 | return { 176 | 'default': self.DEFAULT_DATABASE, 177 | } 178 | 179 | 180 | class ProdSettings(Settings): 181 | 182 | # Override 183 | DEBUG = False 184 | 185 | # Values that *must* be provided in the environment. 186 | STATIC_ROOT = env(env.Required) 187 | 188 | # The `use` method will find the right sub-class of ``BaseSettings`` to use 189 | # Based on the value of the `DJANGO_MODE` env var. 190 | __getattr__, __dir__ = Settings.use() 191 | 192 | 193 | Now when you start Django, it will use all of your global settings, and any 194 | from ``Settings``. 195 | 196 | You can switch to using the ``ProdSettings`` by setting the `DJANGO_MODE` 197 | environment variable: 198 | 199 | .. code-block:: bash 200 | 201 | # Use default Settings 202 | $ ./manage.py shell 203 | 204 | # Use ProdSettings 205 | $ DJANGO_MODE=prod ./manage.py shell 206 | 207 | ``BaseSettings.use()`` picks the ``BaseSettings`` sub-class named 208 | ``{DJANGO_MODE.title()}Settings``. 209 | 210 | .. note:: Since the registry of subclasses is on ``BaseSettings``, you can call 211 | ``.use()`` on any sub-class and it will behave the same. 212 | 213 | Overriding the default 214 | ====================== 215 | 216 | Normally if `DJANGO_MODE` is not set, it will default to looking for ``Settings``. 217 | 218 | You can override the default fallback by passing the value to `use()` 219 | 220 | .. code-block:: python 221 | 222 | __getattr__, __dir__ = Settings.use(default="dev") 223 | 224 | 225 | This way you can have a common base ``Settings`` class, and a separate 226 | ``DevSettings`` with overrides for development. 227 | 228 | Unsetting inherited values 229 | ========================== 230 | 231 | If for whatever reason you want to un-set an inherited setting (perhaps falling back to Django's default) you can use `BaseSettings.Unset` 232 | 233 | .. code-block:: python 234 | 235 | class StagingSettings(Settings): 236 | 237 | LOGGING = Settings.Unset 238 | 239 | 240 | Which settings to move? 241 | ======================= 242 | 243 | Generally, only move settings which are either environment driven, or need 244 | per-mode control. 245 | 246 | .. warning:: 247 | 248 | Because of the precedence rules of Python's module level ``__getattr__`` 249 | function, any settings declared outside a class can *not* be overridden by a 250 | class-based setting. 251 | 252 | .. code-block:: python 253 | 254 | GLOBAL = "global" 255 | 256 | class Settings(BaseSettings): 257 | GLOBAL = "local" # This setting will never be used 258 | 259 | The `env` property 260 | ------------------ 261 | 262 | To help with environment driven settings there is the `env` property decorator. 263 | 264 | The simplest use case is with an immediate value: 265 | 266 | .. code-block:: python 267 | 268 | class Settings(BaseSettings): 269 | 270 | FOO = env('default') 271 | 272 | __getattr__, __dir__ = BaseSettings.use() 273 | 274 | 275 | In this case, if the `FOO` environment variable is set, then ``settings.FOO`` 276 | will yield its value. Otherwise, it will be ``"default"``. 277 | 278 | You can optionally override the environment variable name to look up by passing 279 | a ``key`` argument: 280 | 281 | .. code-block:: python 282 | 283 | class Settings(BaseSettings): 284 | 285 | FOO = env('default', key='BAR') 286 | 287 | Additionally, you can define a prefix for the environment variable: 288 | 289 | .. code-block:: python 290 | 291 | class Settings(BaseSettings): 292 | 293 | FOO = env('default', prefix='MY_') # looks up env var MY_FOO 294 | 295 | If you need a type other than ``str``, you can pass a ``cast`` callable, which 296 | will be passed the value. 297 | 298 | .. code-block:: python 299 | 300 | class Settings(BaseSettings): 301 | 302 | FOO = env('default', cast=int) 303 | 304 | Pre-defined cast types 305 | ~~~~~~~~~~~~~~~~~~~~~~ 306 | 307 | For convenience, there are several built-in pre-defined cast types, accessible 308 | via the ``env`` decorator. 309 | 310 | .. code-block:: python 311 | 312 | env.bool # Treats ("y", "yes", "on", "t", "true", "1") as True, and ("n", "no", "off", "f", "false", "0") as False 313 | env.int # Use the int constructor 314 | env.dburl # Converts URLs to Django DATABASES entries. 315 | env.list # splits on ',', and strips each value 316 | env.tuple # as above, but yields a tuple 317 | 318 | In all cases, if the default value passed is a string, it will be passed to the 319 | cast function. 320 | 321 | As a decorator 322 | ============== 323 | 324 | Additionally, ``env`` will function as a decorator. This allows you to put some 325 | logic into deciding the default value. 326 | 327 | .. code-block:: python 328 | 329 | class Settings(BaseSettings): 330 | DEBUG = True 331 | 332 | @env.int 333 | def FOO(self): 334 | return 1 if self.DEBUG else 2 335 | 336 | 337 | Mandatory environment variable 338 | ============================== 339 | 340 | Should you require a value to *always* be supplied by environment variable, 341 | you can use the `env.Required` property: 342 | 343 | .. code-block:: python 344 | 345 | class Settings(BaseSettings): 346 | 347 | REQUIRED = env(env.Required) 348 | 349 | 350 | Avoiding repeated prefixes 351 | ========================== 352 | 353 | To avoid having to specify a prefix on multiple related variables, ``env`` will 354 | yield a ``partial`` when no default is provided. 355 | 356 | Let's say, for instance, you want all of your environment variables prefixed 357 | with `DJANGO_` 358 | 359 | .. code-block:: python 360 | 361 | # Common prefix for DJANGO related settings 362 | denv = env['DJANGO_'] 363 | 364 | class Settings(BaseSettings): 365 | 366 | DEBUG = denv.bool(True) # Will look for DJANGO_DEBUG in env 367 | 368 | 369 | Now setting ``DJANGO_DEBUG=f`` will disable debug mode. 370 | --------------------------------------------------------------------------------