├── .editorconfig ├── .github └── workflows │ ├── main.yml │ └── python-publish.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── decouple.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── autoconfig │ ├── env │ │ ├── .env │ │ ├── custom-path │ │ │ └── .env │ │ └── project │ │ │ └── empty.txt │ ├── ini │ │ └── project │ │ │ ├── settings.ini │ │ │ └── subdir │ │ │ └── empty.py │ └── no_repository │ │ └── empty.txt ├── secrets │ ├── db_password │ └── db_user ├── test_autoconfig.py ├── test_env.py ├── test_helper_choices.py ├── test_helper_csv.py ├── test_ini.py ├── test_secrets.py └── test_strtobool.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [Makefile] 13 | indent_style = tab 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | ## 2 | # A GitHub Action to run Tox 3 | 4 | name: Tox 5 | 6 | # Controls when the action will run. 7 | on: 8 | # Triggers the workflow on push or pull request events but only for the master branch 9 | pull_request: 10 | branches: 11 | - master 12 | push: 13 | branches: 14 | - master 15 | - tox-action 16 | 17 | # Allows you to run this workflow manually from the Actions tab 18 | workflow_dispatch: 19 | 20 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 21 | jobs: 22 | # This workflow contains a single job called "build" 23 | py310: 24 | runs-on: ubuntu-latest 25 | container: python:3.10-alpine 26 | 27 | # Steps represent a sequence of tasks that will be executed as part of the job 28 | steps: 29 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 30 | - uses: actions/checkout@v2 31 | 32 | # Runs a single command using the runners shell 33 | - name: Install Tox 34 | run: pip install --user --upgrade tox 35 | 36 | - name: Run Tox 37 | env: 38 | TOXENV: py310 39 | run: | 40 | "$HOME/.local/bin/tox" 41 | 42 | py27: 43 | runs-on: ubuntu-latest 44 | container: python:2.7-alpine 45 | 46 | # Steps represent a sequence of tasks that will be executed as part of the job 47 | steps: 48 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 49 | - uses: actions/checkout@v2 50 | 51 | # Runs a single command using the runners shell 52 | - name: Install Tox 53 | run: pip install --user --upgrade tox 54 | 55 | - name: Run Tox 56 | env: 57 | TOXENV: py27 58 | run: | 59 | "$HOME/.local/bin/tox" 60 | 61 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .venv/ 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | .idea 38 | .cache 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.6" 6 | install: pip install tox-travis 7 | script: tox 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 3.7 (2023-01-09) 5 | ---------------- 6 | 7 | - Fix `Csv` cast hanging with `default=None`, now returning an empty list. (#149) 8 | 9 | 3.6 (2022-02-02) 10 | ---------------- 11 | 12 | - Add support for Docker secrets. 13 | - Fix deprecation warning on Python 3.10 14 | 15 | 3.5 (2021-09-30) 16 | ---------------- 17 | 18 | - Fix: fix syntax warnings due to comparison of literals using `is` 19 | - Fix: avoid DeprecationError on ConfigParser.readfp() 20 | - Add Tox Github Action 21 | - Documentation fixups 22 | - Security: bump Pygments version to >=2.7.4 23 | - Fix .env -file quote stripping 24 | - Changelog catchups for 3.2 and 3.3 25 | 26 | 27 | 3.4 (2021-01-05) 28 | ---------------- 29 | 30 | - Add choices helper 31 | - Documentation fixups 32 | 33 | 34 | 3.3 (2019-11-13) 35 | ---------------- 36 | 37 | - Enforce search order in settings files 38 | - Switch to using strtobool (#71) 39 | 40 | 41 | 3.2 (2019-11-13) 42 | ---------------- 43 | 44 | - Fixed typos in documentation 45 | - Add editorconfig file 46 | - Fix handling for configuration values that end in single or double quotes (#78) 47 | - Add support for encoding env files in other encodings (#62) (#64) 48 | - Improve csv documentation (#59) 49 | - Add Changelog #44 50 | - Fixed typo. [Vik] 51 | - Fix the code blocks inline in the documentation, adding two quotes. [Manaia Junior] 52 | - Fixed argument in Csv documentation. [Manaia Junior] 53 | 54 | 55 | 3.1 (2017-08-07) 56 | ---------------- 57 | - Improve README 58 | - Improve tests 59 | - Add support for Csv to be able to return tuple format. Closes #38. [Henrique Bastos] 60 | - Move Csv doc to proper section. [Henrique Bastos] 61 | - Add a post_process argument do Csv. [Henrique Bastos] 62 | - Add support for Csv to be able to return in tuple format. [Manaia Junior] 63 | - Closes #24. [Henrique Bastos] 64 | - Added empty string as False value. [Rafael Sierra] 65 | - Use context manager when opening files. [Jon Banafato] 66 | - Explicitly name summary section. [Henrique Bastos] 67 | - Simplify Repository hierarchy. [Henrique Bastos] 68 | - Fix code quality. [Henrique Bastos] 69 | - Fix case where Option exists on ENV, but not on repository. [Henrique Bastos] #27 70 | - Allow to override initial search path in AutoConfig class. [Rolando Espinoza] 71 | - Update dependencies. [Henrique Bastos] 72 | - Fixes bug while looking for config file in parent dirs. [Flavio Amieiro] 73 | - Add section explaining environment variable override. [Henrique Bastos] 74 | 75 | 76 | 3.0 (2015-09-15) 77 | ---------------- 78 | - Update README for 3.0. [Henrique Bastos] 79 | - Force os.environ to override Ini and Env. [Henrique Bastos] 80 | 81 | 82 | 2.4 (2015-09-15) 83 | ---------------- 84 | - Update requirements. [Henrique Bastos] 85 | - Check if filepath is really a file, not a directory. [Thiago Garcia] 86 | - Fix LIST_OF_INTEGERS CSV example on README. [KS Chan] 87 | - Fix headline marks. [Henrique Bastos] 88 | - Explicitly mention how to comment a line. [Henrique Bastos] 89 | - Typo: A(tt)ention. [Thomas Güttler] 90 | 91 | 92 | 2.3 (2015-03-19) 93 | ---------------- 94 | - Readme fix adding syntax highlight for python console. [Henrique Bastos] 95 | - Add Csv Helper. [Henrique Bastos] 96 | - Update development dependencies’ versions. [Henrique Bastos] 97 | - Update tox to use python 3.4. [Henrique Bastos] 98 | - Fix annoying error when env lines have non relevant whitespace. [Henrique Bastos] 99 | - Test empty key on ini. [Henrique Bastos] 100 | - Fix #3: return empty string when envvar is empty. [Osvaldo Santana Neto] 101 | 102 | 103 | 2.2 (2015-03-19) 104 | ---------------- 105 | - Improve README 106 | - Add notice on fail fast policy. [Henrique Bastos] 107 | - Fix boolean conversion of default value. [Henrique Bastos] 108 | - Update setup.py. [Henrique Bastos] 109 | - Add docutils and pygments to development's requirements.txt. [Henrique Bastos] 110 | - Upgrade development dependencies. [Henrique Bastos] 111 | - Fix more landscape reports. [Henrique Bastos] 112 | - Fixing landscape reported issues. [Henrique Bastos] 113 | - Refactor tests to use fixtures. [Henrique Bastos] 114 | - Fix tests to use Repositories. [Henrique Bastos] 115 | - Adapt AutoConfig to use Repositories with Config. [Henrique Bastos] 116 | - Fix RepositoryEnv logic. [Henrique Bastos] 117 | - Unify Config classes into one. [Henrique Bastos] 118 | - Add comments. [Henrique Bastos] 119 | - Remove unused variables. [Henrique Bastos] 120 | - Add Repository classes. [Henrique Bastos] 121 | - Change ConfigEnv behavior to raise when option or default are undefined. [Henrique Bastos] 122 | 123 | 124 | 2.1 (2015-03-19) 125 | ---------------- 126 | - Test we have access to envvar when we have no file. [Henrique Bastos] 127 | - Deal with access errors. [Henrique Bastos] 128 | - Fix stupid bug on Heroku. [Henrique Bastos] 129 | - Fix inline style on README. [Henrique Bastos] 130 | - Improve explanation about how decouple works. [Henrique Bastos] 131 | - Add build status image. [Henrique Bastos] 132 | - Configure travis-ci. [Henrique Bastos] 133 | - Add tox. [Henrique Bastos] 134 | - Add support for Python3. [Henrique Bastos] 135 | - Implement fallback to os.environ. [Henrique Bastos] 136 | - Remove load method. [Henrique Bastos] 137 | - Isolate logic for reading .env. [Henrique Bastos] 138 | 139 | 140 | 2.0 (2015-03-19) 141 | ---------------- 142 | - Improve README. [Henrique Bastos] 143 | - Move exception to a better context. [Henrique Bastos] 144 | - Replace cwd with current module's path. [Henrique Bastos] 145 | - Add a requirements.txt for developers. [Henrique Bastos] 146 | - Implement AutoConfig. [Henrique Bastos] 147 | - Extract the basic API to a base class. [Henrique Bastos] 148 | - Implement ConfigEnv. [Henrique Bastos] 149 | - Add tests to ConfigIni. [Henrique Bastos] 150 | - Make sure to initialize instance attributes. [Henrique Bastos] 151 | - Ignore PyCharm project dir. [Henrique Bastos] 152 | 153 | 154 | 1.0 (2015-03-19) 155 | ---------------- 156 | - Improve API replacing type with cast. [Henrique Bastos] 157 | - Import code. [Henrique Bastos] 158 | - Add license, readme and setup. [Henrique Bastos] 159 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013 Henrique Bastos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================================== 2 | Python Decouple: Strict separation of settings from code 3 | ======================================================== 4 | 5 | *Decouple* helps you to organize your settings so that you can 6 | change parameters without having to redeploy your app. 7 | 8 | It also makes it easy for you to: 9 | 10 | #. store parameters in *ini* or *.env* files; 11 | #. define comprehensive default values; 12 | #. properly convert values to the correct data type; 13 | #. have **only one** configuration module to rule all your instances. 14 | 15 | It was originally designed for Django, but became an independent generic tool 16 | for separating settings from code. 17 | 18 | .. image:: https://img.shields.io/travis/henriquebastos/python-decouple.svg 19 | :target: https://travis-ci.org/henriquebastos/python-decouple 20 | :alt: Build Status 21 | 22 | .. image:: https://img.shields.io/pypi/v/python-decouple.svg 23 | :target: https://pypi.python.org/pypi/python-decouple/ 24 | :alt: Latest PyPI version 25 | 26 | .. contents:: Summary 27 | 28 | 29 | Why? 30 | ==== 31 | 32 | The settings files in web frameworks store many different kinds of parameters: 33 | 34 | * Locale and i18n; 35 | * Middlewares and Installed Apps; 36 | * Resource handles to the database, Memcached, and other backing services; 37 | * Credentials to external services such as Amazon S3 or Twitter; 38 | * Per-deploy values such as the canonical hostname for the instance. 39 | 40 | The first 2 are *project settings* and the last 3 are *instance settings*. 41 | 42 | You should be able to change *instance settings* without redeploying your app. 43 | 44 | 45 | Why not just use environment variables? 46 | --------------------------------------- 47 | 48 | *Envvars* works, but since ``os.environ`` only returns strings, it's tricky. 49 | 50 | Let's say you have an *envvar* ``DEBUG=False``. If you run: 51 | 52 | .. code-block:: python 53 | 54 | if os.environ['DEBUG']: 55 | print True 56 | else: 57 | print False 58 | 59 | It will print **True**, because ``os.environ['DEBUG']`` returns the **string** ``"False"``. 60 | Since it's a non-empty string, it will be evaluated as True. 61 | 62 | *Decouple* provides a solution that doesn't look like a workaround: ``config('DEBUG', cast=bool)``. 63 | 64 | 65 | Usage 66 | ===== 67 | 68 | Install: 69 | 70 | .. code-block:: console 71 | 72 | pip install python-decouple 73 | 74 | 75 | Then use it on your ``settings.py``. 76 | 77 | #. Import the ``config`` object: 78 | 79 | .. code-block:: python 80 | 81 | from decouple import config 82 | 83 | #. Retrieve the configuration parameters: 84 | 85 | .. code-block:: python 86 | 87 | SECRET_KEY = config('SECRET_KEY') 88 | DEBUG = config('DEBUG', default=False, cast=bool) 89 | EMAIL_HOST = config('EMAIL_HOST', default='localhost') 90 | EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int) 91 | 92 | 93 | Encodings 94 | --------- 95 | 96 | Decouple's default encoding is `UTF-8`. 97 | 98 | But you can specify your preferred encoding. 99 | 100 | Since `config` is lazy and only opens the configuration file when it's first needed, you have the chance to change 101 | its encoding right after import. 102 | 103 | .. code-block:: python 104 | 105 | from decouple import config 106 | config.encoding = 'cp1251' 107 | SECRET_KEY = config('SECRET_KEY') 108 | 109 | If you wish to fall back to your system's default encoding use: 110 | 111 | .. code-block:: python 112 | 113 | import locale 114 | from decouple import config 115 | config.encoding = locale.getpreferredencoding(False) 116 | SECRET_KEY = config('SECRET_KEY') 117 | 118 | 119 | Where is the settings data stored? 120 | ---------------------------------- 121 | 122 | *Decouple* supports both *.ini* and *.env* files. 123 | 124 | 125 | Ini file 126 | ~~~~~~~~ 127 | 128 | Simply create a ``settings.ini`` next to your configuration module in the form: 129 | 130 | .. code-block:: ini 131 | 132 | [settings] 133 | DEBUG=True 134 | TEMPLATE_DEBUG=%(DEBUG)s 135 | SECRET_KEY=ARANDOMSECRETKEY 136 | DATABASE_URL=mysql://myuser:mypassword@myhost/mydatabase 137 | PERCENTILE=90%% 138 | #COMMENTED=42 139 | 140 | *Note*: Since ``ConfigParser`` supports *string interpolation*, to represent the character ``%`` you need to escape it as ``%%``. 141 | 142 | 143 | Env file 144 | ~~~~~~~~ 145 | 146 | Simply create a ``.env`` text file in your repository's root directory in the form: 147 | 148 | .. code-block:: console 149 | 150 | DEBUG=True 151 | TEMPLATE_DEBUG=True 152 | SECRET_KEY=ARANDOMSECRETKEY 153 | DATABASE_URL=mysql://myuser:mypassword@myhost/mydatabase 154 | PERCENTILE=90% 155 | #COMMENTED=42 156 | 157 | 158 | Example: How do I use it with Django? 159 | ------------------------------------- 160 | 161 | Given that I have a ``.env`` file in my repository's root directory, here is a snippet of my ``settings.py``. 162 | 163 | I also recommend using `pathlib `_ 164 | and `dj-database-url `_. 165 | 166 | .. code-block:: python 167 | 168 | # coding: utf-8 169 | from decouple import config 170 | from unipath import Path 171 | from dj_database_url import parse as db_url 172 | 173 | 174 | BASE_DIR = Path(__file__).parent 175 | 176 | DEBUG = config('DEBUG', default=False, cast=bool) 177 | TEMPLATE_DEBUG = DEBUG 178 | 179 | DATABASES = { 180 | 'default': config( 181 | 'DATABASE_URL', 182 | default='sqlite:///' + BASE_DIR.child('db.sqlite3'), 183 | cast=db_url 184 | ) 185 | } 186 | 187 | TIME_ZONE = 'America/Sao_Paulo' 188 | USE_L10N = True 189 | USE_TZ = True 190 | 191 | SECRET_KEY = config('SECRET_KEY') 192 | 193 | EMAIL_HOST = config('EMAIL_HOST', default='localhost') 194 | EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int) 195 | EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') 196 | EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') 197 | EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=False, cast=bool) 198 | 199 | # ... 200 | 201 | 202 | Attention with *undefined* parameters 203 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 204 | 205 | In the above example, all configuration parameters except ``SECRET_KEY = config('SECRET_KEY')`` 206 | have a default value in case it does not exist in the ``.env`` file. 207 | 208 | If ``SECRET_KEY`` is not present in the ``.env``, *decouple* will raise an ``UndefinedValueError``. 209 | 210 | This *fail fast* policy helps you avoid chasing misbehaviours when you eventually forget a parameter. 211 | 212 | 213 | Overriding config files with environment variables 214 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 215 | 216 | Sometimes you may want to change a parameter value without having to edit the ``.ini`` or ``.env`` files. 217 | 218 | Since version 3.0, *decouple* respects the *unix way*. 219 | Therefore environment variables have precedence over config files. 220 | 221 | To override a config parameter you can simply do: 222 | 223 | .. code-block:: console 224 | 225 | DEBUG=True python manage.py 226 | 227 | 228 | How does it work? 229 | ================= 230 | 231 | *Decouple* always searches for *Options* in this order: 232 | 233 | #. Environment variables; 234 | #. Repository: ini or .env file; 235 | #. Default argument passed to config. 236 | 237 | There are 4 classes doing the magic: 238 | 239 | 240 | - ``Config`` 241 | 242 | Coordinates all the configuration retrieval. 243 | 244 | - ``RepositoryIni`` 245 | 246 | Can read values from ``os.environ`` and ini files, in that order. 247 | 248 | **Note:** Since version 3.0 *decouple* respects unix precedence of environment variables *over* config files. 249 | 250 | - ``RepositoryEnv`` 251 | 252 | Can read values from ``os.environ`` and ``.env`` files. 253 | 254 | **Note:** Since version 3.0 *decouple* respects unix precedence of environment variables *over* config files. 255 | 256 | - ``AutoConfig`` 257 | 258 | This is a *lazy* ``Config`` factory that detects which configuration repository you're using. 259 | 260 | It recursively searches up your configuration module path looking for a 261 | ``settings.ini`` or a ``.env`` file. 262 | 263 | Optionally, it accepts ``search_path`` argument to explicitly define 264 | where the search starts. 265 | 266 | The **config** object is an instance of ``AutoConfig`` that instantiates a ``Config`` with the proper ``Repository`` 267 | on the first time it is used. 268 | 269 | 270 | Understanding the CAST argument 271 | ------------------------------- 272 | 273 | By default, all values returned by ``decouple`` are ``strings``, after all they are 274 | read from ``text files`` or the ``envvars``. 275 | 276 | However, your Python code may expect some other value type, for example: 277 | 278 | * Django's ``DEBUG`` expects a boolean ``True`` or ``False``. 279 | * Django's ``EMAIL_PORT`` expects an ``integer``. 280 | * Django's ``ALLOWED_HOSTS`` expects a ``list`` of hostnames. 281 | * Django's ``SECURE_PROXY_SSL_HEADER`` expects a ``tuple`` with two elements, the name of the header to look for and the required value. 282 | 283 | To meet this need, the ``config`` function accepts a ``cast`` argument which 284 | receives any *callable*, that will be used to *transform* the string value 285 | into something else. 286 | 287 | Let's see some examples for the above mentioned cases: 288 | 289 | .. code-block:: python 290 | 291 | >>> os.environ['DEBUG'] = 'False' 292 | >>> config('DEBUG', cast=bool) 293 | False 294 | 295 | >>> os.environ['EMAIL_PORT'] = '42' 296 | >>> config('EMAIL_PORT', cast=int) 297 | 42 298 | 299 | >>> os.environ['ALLOWED_HOSTS'] = '.localhost, .herokuapp.com' 300 | >>> config('ALLOWED_HOSTS', cast=lambda v: [s.strip() for s in v.split(',')]) 301 | ['.localhost', '.herokuapp.com'] 302 | 303 | >>> os.environ['SECURE_PROXY_SSL_HEADER'] = 'HTTP_X_FORWARDED_PROTO, https' 304 | >>> config('SECURE_PROXY_SSL_HEADER', cast=Csv(post_process=tuple)) 305 | ('HTTP_X_FORWARDED_PROTO', 'https') 306 | 307 | As you can see, ``cast`` is very flexible. But the last example got a bit complex. 308 | 309 | 310 | Built in Csv Helper 311 | ~~~~~~~~~~~~~~~~~~~ 312 | 313 | To address the complexity of the last example, *Decouple* comes with an extensible *Csv helper*. 314 | 315 | Let's improve the last example: 316 | 317 | .. code-block:: python 318 | 319 | >>> from decouple import Csv 320 | >>> os.environ['ALLOWED_HOSTS'] = '.localhost, .herokuapp.com' 321 | >>> config('ALLOWED_HOSTS', cast=Csv()) 322 | ['.localhost', '.herokuapp.com'] 323 | 324 | You can also have a `default` value that must be a string to be processed by `Csv`. 325 | 326 | .. code-block:: python 327 | 328 | >>> from decouple import Csv 329 | >>> config('ALLOWED_HOSTS', default='127.0.0.1', cast=Csv()) 330 | ['127.0.0.1'] 331 | 332 | You can also parametrize the *Csv Helper* to return other types of data. 333 | 334 | .. code-block:: python 335 | 336 | >>> os.environ['LIST_OF_INTEGERS'] = '1,2,3,4,5' 337 | >>> config('LIST_OF_INTEGERS', cast=Csv(int)) 338 | [1, 2, 3, 4, 5] 339 | 340 | >>> os.environ['COMPLEX_STRING'] = '%virtual_env%\t *important stuff*\t trailing spaces ' 341 | >>> csv = Csv(cast=lambda s: s.upper(), delimiter='\t', strip=' %*') 342 | >>> csv(os.environ['COMPLEX_STRING']) 343 | ['VIRTUAL_ENV', 'IMPORTANT STUFF', 'TRAILING SPACES'] 344 | 345 | By default *Csv* returns a ``list``, but you can get a ``tuple`` or whatever you want using the ``post_process`` argument: 346 | 347 | .. code-block:: python 348 | 349 | >>> os.environ['SECURE_PROXY_SSL_HEADER'] = 'HTTP_X_FORWARDED_PROTO, https' 350 | >>> config('SECURE_PROXY_SSL_HEADER', cast=Csv(post_process=tuple)) 351 | ('HTTP_X_FORWARDED_PROTO', 'https') 352 | 353 | 354 | Built in Choices helper 355 | ~~~~~~~~~~~~~~~~~~~~~~~ 356 | 357 | Allows for cast and validation based on a list of choices. For example: 358 | 359 | .. code-block:: python 360 | 361 | >>> from decouple import config, Choices 362 | >>> os.environ['CONNECTION_TYPE'] = 'usb' 363 | >>> config('CONNECTION_TYPE', cast=Choices(['eth', 'usb', 'bluetooth'])) 364 | 'usb' 365 | 366 | >>> os.environ['CONNECTION_TYPE'] = 'serial' 367 | >>> config('CONNECTION_TYPE', cast=Choices(['eth', 'usb', 'bluetooth'])) 368 | Traceback (most recent call last): 369 | ... 370 | ValueError: Value not in list: 'serial'; valid values are ['eth', 'usb', 'bluetooth'] 371 | 372 | You can also parametrize *Choices helper* to cast to another type: 373 | 374 | .. code-block:: python 375 | 376 | >>> os.environ['SOME_NUMBER'] = '42' 377 | >>> config('SOME_NUMBER', cast=Choices([7, 14, 42], cast=int)) 378 | 42 379 | 380 | You can also use a Django-like choices tuple: 381 | 382 | .. code-block:: python 383 | 384 | >>> USB = 'usb' 385 | >>> ETH = 'eth' 386 | >>> BLUETOOTH = 'bluetooth' 387 | >>> 388 | >>> CONNECTION_OPTIONS = ( 389 | ... (USB, 'USB'), 390 | ... (ETH, 'Ethernet'), 391 | ... (BLUETOOTH, 'Bluetooth'),) 392 | ... 393 | >>> os.environ['CONNECTION_TYPE'] = BLUETOOTH 394 | >>> config('CONNECTION_TYPE', cast=Choices(choices=CONNECTION_OPTIONS)) 395 | 'bluetooth' 396 | 397 | 398 | Frequently Asked Questions 399 | ========================== 400 | 401 | 402 | 1) How to specify the `.env` path? 403 | ---------------------------------- 404 | 405 | .. code-block:: python 406 | 407 | import os 408 | from decouple import Config, RepositoryEnv 409 | 410 | 411 | config = Config(RepositoryEnv("path/to/.env")) 412 | 413 | 414 | 2) How to use python-decouple with Jupyter? 415 | ------------------------------------------- 416 | 417 | .. code-block:: python 418 | 419 | import os 420 | from decouple import Config, RepositoryEnv 421 | 422 | 423 | config = Config(RepositoryEnv("path/to/.env")) 424 | 425 | 426 | 3) How to specify a file with another name instead of `.env`? 427 | ---------------------------------------------------------------- 428 | 429 | .. code-block:: python 430 | 431 | import os 432 | from decouple import Config, RepositoryEnv 433 | 434 | 435 | config = Config(RepositoryEnv("path/to/somefile-like-env")) 436 | 437 | 438 | 4) How to define the path to my env file on a env var? 439 | -------------------------------------------------------- 440 | 441 | .. code-block:: python 442 | 443 | import os 444 | from decouple import Config, RepositoryEnv 445 | 446 | 447 | DOTENV_FILE = os.environ.get("DOTENV_FILE", ".env") # only place using os.environ 448 | config = Config(RepositoryEnv(DOTENV_FILE)) 449 | 450 | 451 | 5) How can I have multiple *env* files working together? 452 | -------------------------------------------------------- 453 | 454 | .. code-block:: python 455 | 456 | from collections import ChainMap 457 | from decouple import Config, RepositoryEnv 458 | 459 | 460 | config = Config(ChainMap(RepositoryEnv(".private.env"), RepositoryEnv(".env"))) 461 | 462 | 463 | Contribute 464 | ========== 465 | 466 | Your contribution is welcome. 467 | 468 | Setup your development environment: 469 | 470 | .. code-block:: console 471 | 472 | git clone git@github.com:henriquebastos/python-decouple.git 473 | cd python-decouple 474 | python -m venv .venv 475 | source .venv/bin/activate 476 | pip install -r requirements.txt 477 | tox 478 | 479 | *Decouple* supports both Python 2.7 and 3.6. Make sure you have both installed. 480 | 481 | I use `pyenv `_ to 482 | manage multiple Python versions and I described my workspace setup on this article: 483 | `The definitive guide to setup my Python workspace 484 | `_ 485 | 486 | You can submit pull requests and issues for discussion. However I only 487 | consider merging tested code. 488 | 489 | 490 | License 491 | ======= 492 | 493 | The MIT License (MIT) 494 | 495 | Copyright (c) 2017 Henrique Bastos 496 | 497 | Permission is hereby granted, free of charge, to any person obtaining a copy 498 | of this software and associated documentation files (the "Software"), to deal 499 | in the Software without restriction, including without limitation the rights 500 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 501 | copies of the Software, and to permit persons to whom the Software is 502 | furnished to do so, subject to the following conditions: 503 | 504 | The above copyright notice and this permission notice shall be included in 505 | all copies or substantial portions of the Software. 506 | 507 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 508 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 509 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 510 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 511 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 512 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 513 | THE SOFTWARE. 514 | -------------------------------------------------------------------------------- /decouple.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import sys 4 | import string 5 | from shlex import shlex 6 | from io import open 7 | from collections import OrderedDict 8 | 9 | # Useful for very coarse version differentiation. 10 | PYVERSION = sys.version_info 11 | 12 | 13 | if PYVERSION >= (3, 0, 0): 14 | from configparser import ConfigParser, NoOptionError 15 | text_type = str 16 | else: 17 | from ConfigParser import SafeConfigParser as ConfigParser, NoOptionError 18 | text_type = unicode 19 | 20 | if PYVERSION >= (3, 2, 0): 21 | read_config = lambda parser, file: parser.read_file(file) 22 | else: 23 | read_config = lambda parser, file: parser.readfp(file) 24 | 25 | 26 | DEFAULT_ENCODING = 'UTF-8' 27 | 28 | 29 | # Python 3.10 don't have strtobool anymore. So we move it here. 30 | TRUE_VALUES = {"y", "yes", "t", "true", "on", "1"} 31 | FALSE_VALUES = {"n", "no", "f", "false", "off", "0"} 32 | 33 | def strtobool(value): 34 | if isinstance(value, bool): 35 | return value 36 | value = value.lower() 37 | 38 | if value in TRUE_VALUES: 39 | return True 40 | elif value in FALSE_VALUES: 41 | return False 42 | 43 | raise ValueError("Invalid truth value: " + value) 44 | 45 | 46 | class UndefinedValueError(Exception): 47 | pass 48 | 49 | 50 | class Undefined(object): 51 | """ 52 | Class to represent undefined type. 53 | """ 54 | pass 55 | 56 | 57 | # Reference instance to represent undefined values 58 | undefined = Undefined() 59 | 60 | 61 | class Config(object): 62 | """ 63 | Handle .env file format used by Foreman. 64 | """ 65 | 66 | def __init__(self, repository): 67 | self.repository = repository 68 | 69 | def _cast_boolean(self, value): 70 | """ 71 | Helper to convert config values to boolean as ConfigParser do. 72 | """ 73 | value = str(value) 74 | return bool(value) if value == '' else bool(strtobool(value)) 75 | 76 | @staticmethod 77 | def _cast_do_nothing(value): 78 | return value 79 | 80 | def get(self, option, default=undefined, cast=undefined): 81 | """ 82 | Return the value for option or default if defined. 83 | """ 84 | 85 | # We can't avoid __contains__ because value may be empty. 86 | if option in os.environ: 87 | value = os.environ[option] 88 | elif option in self.repository: 89 | value = self.repository[option] 90 | else: 91 | if isinstance(default, Undefined): 92 | raise UndefinedValueError('{} not found. Declare it as envvar or define a default value.'.format(option)) 93 | 94 | value = default 95 | 96 | if isinstance(cast, Undefined): 97 | cast = self._cast_do_nothing 98 | elif cast is bool: 99 | cast = self._cast_boolean 100 | 101 | return cast(value) 102 | 103 | def __call__(self, *args, **kwargs): 104 | """ 105 | Convenient shortcut to get. 106 | """ 107 | return self.get(*args, **kwargs) 108 | 109 | 110 | class RepositoryEmpty(object): 111 | def __init__(self, source='', encoding=DEFAULT_ENCODING): 112 | pass 113 | 114 | def __contains__(self, key): 115 | return False 116 | 117 | def __getitem__(self, key): 118 | return None 119 | 120 | 121 | class RepositoryIni(RepositoryEmpty): 122 | """ 123 | Retrieves option keys from .ini files. 124 | """ 125 | SECTION = 'settings' 126 | 127 | def __init__(self, source, encoding=DEFAULT_ENCODING): 128 | self.parser = ConfigParser() 129 | with open(source, encoding=encoding) as file_: 130 | read_config(self.parser, file_) 131 | 132 | def __contains__(self, key): 133 | return (key in os.environ or 134 | self.parser.has_option(self.SECTION, key)) 135 | 136 | def __getitem__(self, key): 137 | try: 138 | return self.parser.get(self.SECTION, key) 139 | except NoOptionError: 140 | raise KeyError(key) 141 | 142 | 143 | class RepositoryEnv(RepositoryEmpty): 144 | """ 145 | Retrieves option keys from .env files with fall back to os.environ. 146 | """ 147 | def __init__(self, source, encoding=DEFAULT_ENCODING): 148 | self.data = {} 149 | 150 | with open(source, encoding=encoding) as file_: 151 | for line in file_: 152 | line = line.strip() 153 | if not line or line.startswith('#') or '=' not in line: 154 | continue 155 | k, v = line.split('=', 1) 156 | k = k.strip() 157 | v = v.strip() 158 | if len(v) >= 2 and ((v[0] == "'" and v[-1] == "'") or (v[0] == '"' and v[-1] == '"')): 159 | v = v[1:-1] 160 | self.data[k] = v 161 | 162 | def __contains__(self, key): 163 | return key in os.environ or key in self.data 164 | 165 | def __getitem__(self, key): 166 | return self.data[key] 167 | 168 | 169 | class RepositorySecret(RepositoryEmpty): 170 | """ 171 | Retrieves option keys from files, 172 | where title of file is a key, content of file is a value 173 | e.g. Docker swarm secrets 174 | """ 175 | 176 | def __init__(self, source='/run/secrets/'): 177 | self.data = {} 178 | 179 | ls = os.listdir(source) 180 | for file in ls: 181 | with open(os.path.join(source, file), 'r') as f: 182 | self.data[file] = f.read() 183 | 184 | def __contains__(self, key): 185 | return key in os.environ or key in self.data 186 | 187 | def __getitem__(self, key): 188 | return self.data[key] 189 | 190 | 191 | class AutoConfig(object): 192 | """ 193 | Autodetects the config file and type. 194 | 195 | Parameters 196 | ---------- 197 | search_path : str, optional 198 | Initial search path. If empty, the default search path is the 199 | caller's path. 200 | 201 | """ 202 | SUPPORTED = OrderedDict([ 203 | ('settings.ini', RepositoryIni), 204 | ('.env', RepositoryEnv), 205 | ]) 206 | 207 | encoding = DEFAULT_ENCODING 208 | 209 | def __init__(self, search_path=None): 210 | self.search_path = search_path 211 | self.config = None 212 | 213 | def _find_file(self, path): 214 | # look for all files in the current path 215 | for configfile in self.SUPPORTED: 216 | filename = os.path.join(path, configfile) 217 | if os.path.isfile(filename): 218 | return filename 219 | 220 | # search the parent 221 | parent = os.path.dirname(path) 222 | if parent and os.path.normcase(parent) != os.path.normcase(os.path.abspath(os.sep)): 223 | return self._find_file(parent) 224 | 225 | # reached root without finding any files. 226 | return '' 227 | 228 | def _load(self, path): 229 | # Avoid unintended permission errors 230 | try: 231 | filename = self._find_file(os.path.abspath(path)) 232 | except Exception: 233 | filename = '' 234 | Repository = self.SUPPORTED.get(os.path.basename(filename), RepositoryEmpty) 235 | 236 | self.config = Config(Repository(filename, encoding=self.encoding)) 237 | 238 | def _caller_path(self): 239 | # MAGIC! Get the caller's module path. 240 | frame = sys._getframe() 241 | path = os.path.dirname(frame.f_back.f_back.f_code.co_filename) 242 | return path 243 | 244 | def __call__(self, *args, **kwargs): 245 | if not self.config: 246 | self._load(self.search_path or self._caller_path()) 247 | 248 | return self.config(*args, **kwargs) 249 | 250 | 251 | # A pré-instantiated AutoConfig to improve decouple's usability 252 | # now just import config and start using with no configuration. 253 | config = AutoConfig() 254 | 255 | # Helpers 256 | 257 | class Csv(object): 258 | """ 259 | Produces a csv parser that return a list of transformed elements. 260 | """ 261 | 262 | def __init__(self, cast=text_type, delimiter=',', strip=string.whitespace, post_process=list): 263 | """ 264 | Parameters: 265 | cast -- callable that transforms the item just before it's added to the list. 266 | delimiter -- string of delimiters chars passed to shlex. 267 | strip -- string of non-relevant characters to be passed to str.strip after the split. 268 | post_process -- callable to post process all casted values. Default is `list`. 269 | """ 270 | self.cast = cast 271 | self.delimiter = delimiter 272 | self.strip = strip 273 | self.post_process = post_process 274 | 275 | def __call__(self, value): 276 | """The actual transformation""" 277 | if value is None: 278 | return self.post_process() 279 | 280 | transform = lambda s: self.cast(s.strip(self.strip)) 281 | 282 | splitter = shlex(value, posix=True) 283 | splitter.whitespace = self.delimiter 284 | splitter.whitespace_split = True 285 | 286 | return self.post_process(transform(s) for s in splitter) 287 | 288 | 289 | class Choices(object): 290 | """ 291 | Allows for cast and validation based on a list of choices. 292 | """ 293 | 294 | def __init__(self, flat=None, cast=text_type, choices=None): 295 | """ 296 | Parameters: 297 | flat -- a flat list of valid choices. 298 | cast -- callable that transforms value before validation. 299 | choices -- tuple of Django-like choices. 300 | """ 301 | self.flat = flat or [] 302 | self.cast = cast 303 | self.choices = choices or [] 304 | 305 | self._valid_values = [] 306 | self._valid_values.extend(self.flat) 307 | self._valid_values.extend([value for value, _ in self.choices]) 308 | 309 | def __call__(self, value): 310 | transform = self.cast(value) 311 | if transform not in self._valid_values: 312 | raise ValueError(( 313 | 'Value not in list: {!r}; valid values are {!r}' 314 | ).format(value, self._valid_values)) 315 | else: 316 | return transform 317 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mock==2.0.0 2 | pytest==3.2.0 3 | tox==2.7.0 4 | docutils==0.14 5 | Pygments>=2.7.4 6 | twine 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license-file = LICENSE 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from setuptools import setup 3 | import os 4 | 5 | 6 | README = os.path.join(os.path.dirname(__file__), 'README.rst') 7 | 8 | setup(name='python-decouple', 9 | version='3.8', 10 | description='Strict separation of settings from code.', 11 | long_description=open(README).read(), 12 | author="Henrique Bastos", author_email="henrique@bastos.net", 13 | license="MIT", 14 | py_modules=['decouple'], 15 | zip_safe=False, 16 | platforms='any', 17 | include_package_data=True, 18 | classifiers=[ 19 | 'Development Status :: 5 - Production/Stable', 20 | 'Framework :: Django', 21 | 'Framework :: Flask', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Natural Language :: English', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 3', 28 | 'Topic :: Software Development :: Libraries', 29 | ], 30 | url='http://github.com/henriquebastos/python-decouple/',) 31 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | -------------------------------------------------------------------------------- /tests/autoconfig/env/.env: -------------------------------------------------------------------------------- 1 | KEY=ENV 2 | -------------------------------------------------------------------------------- /tests/autoconfig/env/custom-path/.env: -------------------------------------------------------------------------------- 1 | KEY=CUSTOMPATH 2 | -------------------------------------------------------------------------------- /tests/autoconfig/env/project/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HBNetwork/python-decouple/0573e6f96637f08fb4cb85e0552f0622d36827d4/tests/autoconfig/env/project/empty.txt -------------------------------------------------------------------------------- /tests/autoconfig/ini/project/settings.ini: -------------------------------------------------------------------------------- 1 | [settings] 2 | KEY=INI 3 | -------------------------------------------------------------------------------- /tests/autoconfig/ini/project/subdir/empty.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HBNetwork/python-decouple/0573e6f96637f08fb4cb85e0552f0622d36827d4/tests/autoconfig/ini/project/subdir/empty.py -------------------------------------------------------------------------------- /tests/autoconfig/no_repository/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HBNetwork/python-decouple/0573e6f96637f08fb4cb85e0552f0622d36827d4/tests/autoconfig/no_repository/empty.txt -------------------------------------------------------------------------------- /tests/secrets/db_password: -------------------------------------------------------------------------------- 1 | world -------------------------------------------------------------------------------- /tests/secrets/db_user: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /tests/test_autoconfig.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import pytest 4 | from mock import patch, mock_open 5 | from decouple import AutoConfig, UndefinedValueError, RepositoryEmpty, DEFAULT_ENCODING 6 | 7 | 8 | def test_autoconfig_env(): 9 | config = AutoConfig() 10 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'env', 'project') 11 | with patch.object(config, '_caller_path', return_value=path): 12 | assert 'ENV' == config('KEY') 13 | 14 | 15 | def test_autoconfig_ini(): 16 | config = AutoConfig() 17 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'ini', 'project') 18 | with patch.object(config, '_caller_path', return_value=path): 19 | assert 'INI' == config('KEY') 20 | 21 | 22 | def test_autoconfig_ini_in_subdir(): 23 | """ 24 | When `AutoConfig._find_file()` gets a relative path from 25 | `AutoConfig._caller_path()`, it will not properly search back to parent 26 | dirs. 27 | 28 | This is a regression test to make sure that when 29 | `AutoConfig._caller_path()` finds something like `./config.py` it will look 30 | for settings.ini in parent directories. 31 | """ 32 | config = AutoConfig() 33 | subdir = os.path.join(os.path.dirname(__file__), 'autoconfig', 'ini', 34 | 'project', 'subdir') 35 | os.chdir(subdir) 36 | path = os.path.join(os.path.curdir, 'empty.py') 37 | with patch.object(config, '_caller_path', return_value=path): 38 | assert 'INI' == config('KEY') 39 | 40 | 41 | def test_autoconfig_none(): 42 | os.environ['KeyFallback'] = 'On' 43 | config = AutoConfig() 44 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'none') 45 | with patch('os.path.isfile', return_value=False): 46 | assert True is config('KeyFallback', cast=bool) 47 | del os.environ['KeyFallback'] 48 | 49 | 50 | def test_autoconfig_exception(): 51 | os.environ['KeyFallback'] = 'On' 52 | config = AutoConfig() 53 | with patch('os.path.isfile', side_effect=Exception('PermissionDenied')): 54 | assert True is config('KeyFallback', cast=bool) 55 | del os.environ['KeyFallback'] 56 | 57 | 58 | def test_autoconfig_is_not_a_file(): 59 | os.environ['KeyFallback'] = 'On' 60 | config = AutoConfig() 61 | with patch('os.path.isfile', return_value=False): 62 | assert True is config('KeyFallback', cast=bool) 63 | del os.environ['KeyFallback'] 64 | 65 | 66 | def test_autoconfig_search_path(): 67 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'env', 'custom-path') 68 | config = AutoConfig(path) 69 | assert 'CUSTOMPATH' == config('KEY') 70 | 71 | 72 | def test_autoconfig_empty_repository(): 73 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'env', 'custom-path') 74 | config = AutoConfig(path) 75 | 76 | with pytest.raises(UndefinedValueError): 77 | config('KeyNotInEnvAndNotInRepository') 78 | 79 | assert isinstance(config.config.repository, RepositoryEmpty) 80 | 81 | def test_autoconfig_ini_default_encoding(): 82 | config = AutoConfig() 83 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'ini', 'project') 84 | filename = os.path.join(os.path.dirname(__file__), 'autoconfig', 'ini', 'project', 'settings.ini') 85 | with patch.object(config, '_caller_path', return_value=path): 86 | with patch('decouple.open', mock_open(read_data='')) as mopen: 87 | assert config.encoding == DEFAULT_ENCODING 88 | assert 'ENV' == config('KEY', default='ENV') 89 | mopen.assert_called_once_with(filename, encoding=DEFAULT_ENCODING) 90 | 91 | def test_autoconfig_env_default_encoding(): 92 | config = AutoConfig() 93 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'env', 'project') 94 | filename = os.path.join(os.path.dirname(__file__), 'autoconfig', 'env', '.env') 95 | with patch.object(config, '_caller_path', return_value=path): 96 | with patch('decouple.open', mock_open(read_data='')) as mopen: 97 | assert config.encoding == DEFAULT_ENCODING 98 | assert 'ENV' == config('KEY', default='ENV') 99 | mopen.assert_called_once_with(filename, encoding=DEFAULT_ENCODING) 100 | 101 | 102 | def test_autoconfig_no_repository(): 103 | path = os.path.join(os.path.dirname(__file__), 'autoconfig', 'ini', 'no_repository') 104 | config = AutoConfig(path) 105 | 106 | with pytest.raises(UndefinedValueError): 107 | config('KeyNotInEnvAndNotInRepository') 108 | 109 | assert isinstance(config.config.repository, RepositoryEmpty) 110 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import sys 4 | from mock import patch 5 | import pytest 6 | from decouple import Config, RepositoryEnv, UndefinedValueError 7 | 8 | 9 | # Useful for very coarse version differentiation. 10 | PY3 = sys.version_info[0] == 3 11 | 12 | if PY3: 13 | from io import StringIO 14 | else: 15 | from io import BytesIO as StringIO 16 | 17 | 18 | ENVFILE = ''' 19 | KeyTrue=True 20 | KeyOne=1 21 | KeyYes=yes 22 | KeyOn=on 23 | KeyY=y 24 | 25 | KeyFalse=False 26 | KeyZero=0 27 | KeyNo=no 28 | KeyN=n 29 | KeyOff=off 30 | KeyEmpty= 31 | 32 | #CommentedKey=None 33 | PercentNotEscaped=%% 34 | NoInterpolation=%(KeyOff)s 35 | IgnoreSpace = text 36 | RespectSingleQuoteSpace = ' text' 37 | RespectDoubleQuoteSpace = " text" 38 | KeyOverrideByEnv=NotThis 39 | 40 | KeyWithSingleQuoteEnd=text' 41 | KeyWithSingleQuoteMid=te'xt 42 | KeyWithSingleQuoteBegin='text 43 | KeyWithDoubleQuoteEnd=text" 44 | KeyWithDoubleQuoteMid=te"xt 45 | KeyWithDoubleQuoteBegin="text 46 | KeyIsSingleQuote=' 47 | KeyIsDoubleQuote=" 48 | KeyHasTwoSingleQuote="'Y'" 49 | KeyHasTwoDoubleQuote='"Y"' 50 | KeyHasMixedQuotesAsData1="Y' 51 | KeyHasMixedQuotesAsData2='Y" 52 | ''' 53 | 54 | @pytest.fixture(scope='module') 55 | def config(): 56 | with patch('decouple.open', return_value=StringIO(ENVFILE), create=True): 57 | return Config(RepositoryEnv('.env')) 58 | 59 | 60 | def test_env_comment(config): 61 | with pytest.raises(UndefinedValueError): 62 | config('CommentedKey') 63 | 64 | 65 | def test_env_percent_not_escaped(config): 66 | assert '%%' == config('PercentNotEscaped') 67 | 68 | 69 | def test_env_no_interpolation(config): 70 | assert '%(KeyOff)s' == config('NoInterpolation') 71 | 72 | 73 | def test_env_bool_true(config): 74 | assert True is config('KeyTrue', cast=bool) 75 | assert True is config('KeyOne', cast=bool) 76 | assert True is config('KeyYes', cast=bool) 77 | assert True is config('KeyOn', cast=bool) 78 | assert True is config('KeyY', cast=bool) 79 | assert True is config('Key1int', default=1, cast=bool) 80 | 81 | def test_env_bool_false(config): 82 | assert False is config('KeyFalse', cast=bool) 83 | assert False is config('KeyZero', cast=bool) 84 | assert False is config('KeyNo', cast=bool) 85 | assert False is config('KeyOff', cast=bool) 86 | assert False is config('KeyN', cast=bool) 87 | assert False is config('KeyEmpty', cast=bool) 88 | assert False is config('Key0int', default=0, cast=bool) 89 | 90 | 91 | def test_env_os_environ(config): 92 | os.environ['KeyOverrideByEnv'] = 'This' 93 | assert 'This' == config('KeyOverrideByEnv') 94 | del os.environ['KeyOverrideByEnv'] 95 | 96 | 97 | def test_env_undefined_but_present_in_os_environ(config): 98 | os.environ['KeyOnlyEnviron'] = '' 99 | assert '' == config('KeyOnlyEnviron') 100 | del os.environ['KeyOnlyEnviron'] 101 | 102 | 103 | def test_env_undefined(config): 104 | with pytest.raises(UndefinedValueError): 105 | config('UndefinedKey') 106 | 107 | 108 | def test_env_default_none(config): 109 | assert None is config('UndefinedKey', default=None) 110 | 111 | 112 | def test_env_empty(config): 113 | assert '' == config('KeyEmpty', default=None) 114 | assert '' == config('KeyEmpty') 115 | 116 | 117 | def test_env_support_space(config): 118 | assert 'text' == config('IgnoreSpace') 119 | assert ' text' == config('RespectSingleQuoteSpace') 120 | assert ' text' == config('RespectDoubleQuoteSpace') 121 | 122 | 123 | def test_env_empty_string_means_false(config): 124 | assert False is config('KeyEmpty', cast=bool) 125 | 126 | def test_env_with_quote(config): 127 | assert "text'" == config('KeyWithSingleQuoteEnd') 128 | assert 'text"' == config('KeyWithDoubleQuoteEnd') 129 | assert "te'xt" == config('KeyWithSingleQuoteMid') 130 | assert "'text" == config('KeyWithSingleQuoteBegin') 131 | assert 'te"xt' == config('KeyWithDoubleQuoteMid') 132 | assert '"text' == config('KeyWithDoubleQuoteBegin') 133 | assert '"' == config('KeyIsDoubleQuote') 134 | assert "'" == config('KeyIsSingleQuote') 135 | assert "'Y'" == config('KeyHasTwoSingleQuote') 136 | assert '"Y"' == config('KeyHasTwoDoubleQuote') 137 | assert '''"Y\'''' == config('KeyHasMixedQuotesAsData1') 138 | assert '''\'Y"''' == config('KeyHasMixedQuotesAsData2') 139 | 140 | def test_env_repo_keyerror(config): 141 | with pytest.raises(KeyError): 142 | config.repository['UndefinedKey'] 143 | -------------------------------------------------------------------------------- /tests/test_helper_choices.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import pytest 3 | from decouple import Choices 4 | 5 | 6 | FRUIT_APPLE = 'apple' 7 | FRUIT_BANANA = 'banana' 8 | FRUIT_COCONUT = 'coconut' 9 | 10 | ALLOWED_FRUITS = ( 11 | (FRUIT_APPLE, 'Apple'), 12 | (FRUIT_BANANA, 'Banana'), 13 | (FRUIT_COCONUT, 'Coconut'), 14 | ) 15 | 16 | ZERO = 0 17 | THREE = 3 18 | SEVEN = 7 19 | 20 | ALLOWED_NUMBERS = ( 21 | (ZERO, 'Zero'), 22 | (THREE, 'Three'), 23 | (SEVEN, 'Seven'), 24 | ) 25 | 26 | 27 | def test_default_cast_with_flat_list(): 28 | """Default cast with a flat list.""" 29 | choices = Choices(['a', 'b', 'c']) 30 | assert 'a' == choices('a') 31 | assert 'b' == choices('b') 32 | assert 'c' == choices('c') 33 | 34 | with pytest.raises(ValueError): 35 | choices('d') 36 | 37 | 38 | def test_cast_to_int_with_flat_list(): 39 | """Cast to int with a flat list.""" 40 | choices = Choices([3, 5, 7], cast=int) 41 | assert 3 == choices('3') 42 | assert 5 == choices('5') 43 | assert 7 == choices('7') 44 | 45 | with pytest.raises(ValueError): 46 | choices(1) 47 | 48 | 49 | def test_default_with_django_like_choices(): 50 | """Default cast with a Django-like choices tuple.""" 51 | choices = Choices(choices=ALLOWED_FRUITS) 52 | assert 'apple' == choices('apple') 53 | assert 'banana' == choices('banana') 54 | assert 'coconut' == choices('coconut') 55 | 56 | with pytest.raises(ValueError): 57 | choices('strawberry') 58 | 59 | 60 | def test_cast_to_int_with_django_like_choices(): 61 | """Cast to int with a Django-like choices tuple.""" 62 | choices = Choices(cast=int, choices=ALLOWED_NUMBERS) 63 | assert 0 == choices('0') 64 | assert 3 == choices('3') 65 | assert 7 == choices('7') 66 | 67 | with pytest.raises(ValueError): 68 | choices(1) 69 | 70 | 71 | def test_default_cast_with_booth_flat_list_and_django_like_choices(): 72 | """Default cast with booth flat list and Django-like choices tuple.""" 73 | choices = Choices(['a', 'b', 'c'], choices=ALLOWED_FRUITS) 74 | assert 'a' == choices('a') 75 | assert 'b' == choices('b') 76 | assert 'c' == choices('c') 77 | assert 'apple' == choices('apple') 78 | assert 'banana' == choices('banana') 79 | assert 'coconut' == choices('coconut') 80 | 81 | with pytest.raises(ValueError): 82 | choices('d') 83 | 84 | with pytest.raises(ValueError): 85 | choices('watermelon') 86 | 87 | 88 | def test_cast_to_int_with_booth_flat_list_and_django_like_choices(): 89 | """Cast to int with booth flat list and Django-like choices tuple.""" 90 | choices = Choices([7, 14, 42], cast=int, choices=ALLOWED_NUMBERS) 91 | assert 7 == choices('7') 92 | assert 14 == choices('14') 93 | assert 42 == choices('42') 94 | 95 | assert 0 == choices('0') 96 | assert 3 == choices('3') 97 | assert 7 == choices('7') 98 | 99 | with pytest.raises(ValueError): 100 | choices('not my fault') 101 | 102 | with pytest.raises(ValueError): 103 | choices('1') 104 | -------------------------------------------------------------------------------- /tests/test_helper_csv.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from decouple import Csv 3 | 4 | 5 | def test_csv(): 6 | csv = Csv() 7 | assert ['127.0.0.1', '.localhost', '.herokuapp.com'] == \ 8 | csv('127.0.0.1, .localhost, .herokuapp.com') 9 | 10 | csv = Csv(int) 11 | assert [1, 2, 3, 4, 5] == csv('1,2,3,4,5') 12 | 13 | csv = Csv(post_process=tuple) 14 | assert ('HTTP_X_FORWARDED_PROTO', 'https') == \ 15 | csv('HTTP_X_FORWARDED_PROTO, https') 16 | 17 | csv = Csv(cast=lambda s: s.upper(), delimiter='\t', strip=' %*') 18 | assert ['VIRTUAL_ENV', 'IMPORTANT STUFF', 'TRAILING SPACES'] == \ 19 | csv('%virtual_env%\t *important stuff*\t trailing spaces ') 20 | 21 | 22 | def test_csv_quoted_parse(): 23 | csv = Csv() 24 | 25 | assert ['foo', 'bar, baz', 'qux'] == csv(""" foo ,'bar, baz', 'qux'""") 26 | 27 | assert ['foo', 'bar, baz', 'qux'] == csv(''' foo ,"bar, baz", "qux"''') 28 | 29 | assert ['foo', "'bar, baz'", "'qux"] == csv(''' foo ,"'bar, baz'", "'qux"''') 30 | 31 | assert ['foo', '"bar, baz"', '"qux'] == csv(""" foo ,'"bar, baz"', '"qux'""") 32 | 33 | 34 | def test_csv_none(): 35 | csv = Csv() 36 | assert [] == csv(None) 37 | -------------------------------------------------------------------------------- /tests/test_ini.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import sys 4 | from mock import patch, mock_open 5 | import pytest 6 | from decouple import Config, RepositoryIni, UndefinedValueError 7 | 8 | # Useful for very coarse version differentiation. 9 | PY3 = sys.version_info[0] == 3 10 | 11 | if PY3: 12 | from io import StringIO 13 | else: 14 | from io import BytesIO as StringIO 15 | 16 | 17 | 18 | INIFILE = ''' 19 | [settings] 20 | KeyTrue=True 21 | KeyOne=1 22 | KeyYes=yes 23 | KeyY=y 24 | KeyOn=on 25 | 26 | KeyFalse=False 27 | KeyZero=0 28 | KeyNo=no 29 | KeyN=n 30 | KeyOff=off 31 | KeyEmpty= 32 | 33 | #CommentedKey=None 34 | PercentIsEscaped=%% 35 | Interpolation=%(KeyOff)s 36 | IgnoreSpace = text 37 | KeyOverrideByEnv=NotThis 38 | ''' 39 | 40 | @pytest.fixture(scope='module') 41 | def config(): 42 | with patch('decouple.open', return_value=StringIO(INIFILE), create=True): 43 | return Config(RepositoryIni('settings.ini')) 44 | 45 | 46 | def test_ini_comment(config): 47 | with pytest.raises(UndefinedValueError): 48 | config('CommentedKey') 49 | 50 | 51 | def test_ini_percent_escape(config): 52 | assert '%' == config('PercentIsEscaped') 53 | 54 | 55 | def test_ini_interpolation(config): 56 | assert 'off' == config('Interpolation') 57 | 58 | 59 | def test_ini_bool_true(config): 60 | assert True is config('KeyTrue', cast=bool) 61 | assert True is config('KeyOne', cast=bool) 62 | assert True is config('KeyYes', cast=bool) 63 | assert True is config('KeyY', cast=bool) 64 | assert True is config('KeyOn', cast=bool) 65 | assert True is config('Key1int', default=1, cast=bool) 66 | 67 | 68 | def test_ini_bool_false(config): 69 | assert False is config('KeyFalse', cast=bool) 70 | assert False is config('KeyZero', cast=bool) 71 | assert False is config('KeyNo', cast=bool) 72 | assert False is config('KeyOff', cast=bool) 73 | assert False is config('KeyN', cast=bool) 74 | assert False is config('KeyEmpty', cast=bool) 75 | assert False is config('Key0int', default=0, cast=bool) 76 | 77 | 78 | def test_init_undefined(config): 79 | with pytest.raises(UndefinedValueError): 80 | config('UndefinedKey') 81 | 82 | 83 | def test_ini_default_none(config): 84 | assert None is config('UndefinedKey', default=None) 85 | 86 | 87 | def test_ini_default_bool(config): 88 | assert False is config('UndefinedKey', default=False, cast=bool) 89 | assert True is config('UndefinedKey', default=True, cast=bool) 90 | 91 | 92 | def test_ini_default(config): 93 | assert False is config('UndefinedKey', default=False) 94 | assert True is config('UndefinedKey', default=True) 95 | 96 | 97 | def test_ini_default_invalid_bool(config): 98 | with pytest.raises(ValueError): 99 | config('UndefinedKey', default='NotBool', cast=bool) 100 | 101 | 102 | def test_ini_empty(config): 103 | assert '' == config('KeyEmpty', default=None) 104 | 105 | 106 | def test_ini_support_space(config): 107 | assert 'text' == config('IgnoreSpace') 108 | 109 | 110 | def test_ini_os_environ(config): 111 | os.environ['KeyOverrideByEnv'] = 'This' 112 | assert 'This' == config('KeyOverrideByEnv') 113 | del os.environ['KeyOverrideByEnv'] 114 | 115 | 116 | def test_ini_undefined_but_present_in_os_environ(config): 117 | os.environ['KeyOnlyEnviron'] = '' 118 | assert '' == config('KeyOnlyEnviron') 119 | del os.environ['KeyOnlyEnviron'] 120 | 121 | 122 | def test_ini_empty_string_means_false(config): 123 | assert False is config('KeyEmpty', cast=bool) 124 | 125 | 126 | def test_ini_repo_keyerror(config): 127 | with pytest.raises(KeyError): 128 | config.repository['UndefinedKey'] 129 | -------------------------------------------------------------------------------- /tests/test_secrets.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import os 3 | import pytest 4 | 5 | from decouple import Config, RepositorySecret 6 | 7 | 8 | def test_secrets(): 9 | path = os.path.join(os.path.dirname(__file__), 'secrets') 10 | config = Config(RepositorySecret(path)) 11 | 12 | assert 'hello' == config('db_user') 13 | assert 'world' == config('db_password') 14 | 15 | 16 | def test_no_secret_but_present_in_os_environ(): 17 | path = os.path.join(os.path.dirname(__file__), 'secrets') 18 | config = Config(RepositorySecret(path)) 19 | 20 | os.environ['KeyOnlyEnviron'] = 'SOMETHING' 21 | assert 'SOMETHING' == config('KeyOnlyEnviron') 22 | del os.environ['KeyOnlyEnviron'] 23 | 24 | 25 | def test_secret_overriden_by_environ(): 26 | path = os.path.join(os.path.dirname(__file__), 'secrets') 27 | config = Config(RepositorySecret(path)) 28 | 29 | os.environ['db_user'] = 'hi' 30 | assert 'hi' == config('db_user') 31 | del os.environ['db_user'] 32 | 33 | def test_secret_repo_keyerror(): 34 | path = os.path.join(os.path.dirname(__file__), 'secrets') 35 | repo = RepositorySecret(path) 36 | 37 | with pytest.raises(KeyError): 38 | repo['UndefinedKey'] 39 | -------------------------------------------------------------------------------- /tests/test_strtobool.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from decouple import strtobool 3 | 4 | 5 | @pytest.mark.parametrize("value", ("Y", "YES", "T", "TRUE", "ON", "1")) 6 | def test_true_values(value): 7 | assert strtobool(value) 8 | 9 | 10 | @pytest.mark.parametrize("value", ("N", "NO", "F", "FALSE", "OFF", "0")) 11 | def test_false_values(value): 12 | assert strtobool(value) is False 13 | 14 | 15 | def test_invalid(): 16 | with pytest.raises(ValueError, match="Invalid truth value"): 17 | strtobool("MAYBE") 18 | 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py2, py3 3 | 4 | [testenv] 5 | deps = 6 | mock 7 | pytest 8 | commands=py.test 9 | --------------------------------------------------------------------------------