├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── change-pr-target.yml │ ├── ci.yml │ ├── codeql.yml │ ├── cs.yml │ └── docs.yml ├── .gitignore ├── .readthedocs.yml ├── AUTHORS.rst ├── BACKERS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── SECURITY.rst ├── docs ├── Makefile ├── _static │ └── .gitkeep ├── api.rst ├── backers.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── deprecations.rst ├── docutils.conf ├── faq.rst ├── index.rst ├── install.rst ├── license.rst ├── quickstart.rst ├── tips.rst └── types.rst ├── environ ├── __init__.py ├── compat.py ├── environ.py └── fileaware_mapping.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── asserts.py ├── conftest.py ├── fixtures.py ├── test_cache.py ├── test_channels.py ├── test_db.py ├── test_email.py ├── test_env.py ├── test_env.txt ├── test_fileaware.py ├── test_path.py ├── test_schema.py ├── test_search.py └── test_utils.py └── tox.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: joke2k 4 | patreon: # Replace with a single Patreon username 5 | open_collective: joke2k 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | 5 | - package-ecosystem: pip 6 | # setup.py stored in repository root. 7 | directory: '/' 8 | # Raise pull requests for version updates 9 | # to pip against the `develop` branch 10 | target-branch: develop 11 | schedule: 12 | # Check for updates managed by pip once a week 13 | interval: weekly 14 | # Specify labels for npm pull requests 15 | labels: 16 | - pip 17 | - dependencies 18 | assignees: 19 | - sergeyklay 20 | 21 | - package-ecosystem: github-actions 22 | # Workflow files stored in the 23 | # default location of `.github/workflows` 24 | directory: '/' 25 | # Raise pull requests for version updates 26 | # to pip against the `develop` branch 27 | target-branch: develop 28 | schedule: 29 | # Check for updates for GitHub actions once a week 30 | interval: weekly 31 | # Specify labels for npm pull requests 32 | labels: 33 | - github_actions 34 | - dependencies 35 | assignees: 36 | - sergeyklay 37 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | # These should always correspond to pull requests, so ignore them for 7 | # the push trigger and let them be triggered by the pull_request 8 | # trigger, avoiding running the workflow twice. This is a minor 9 | # optimization so there's no need to ensure this is comprehensive. 10 | - 'dependabot/**' 11 | tags: 12 | - 'v[0-9]+.[0-9]+.[0-9]+' 13 | 14 | # The branches below must be a subset of the branches above 15 | pull_request: 16 | branches: 17 | - develop 18 | - main 19 | 20 | jobs: 21 | build: 22 | name: Build and test package distribution 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4.2.2 28 | 29 | - name: Set up Python 3.12 30 | uses: actions/setup-python@v5.3.0 31 | with: 32 | python-version: '3.12' 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install tox tox-gh-actions setuptools 38 | 39 | - name: Check MANIFEST.in for completeness 40 | run: tox -e manifest 41 | 42 | - name: Build and test package 43 | run: tox -e build 44 | 45 | - name: Archive build artifacts 46 | if: success() 47 | uses: actions/upload-artifact@v4 48 | with: 49 | # To ensure that jobs don't overwrite existing artifacts, 50 | # use a different name per job. 51 | name: build-${{ matrix.os }} 52 | path: .tox/build/tmp/build 53 | # Artifacts are retained for 90 days by default. 54 | # In fact, we don't need such long period. 55 | retention-days: 60 56 | 57 | install-dev: 58 | name: Verify dev environment 59 | runs-on: ${{ matrix.os }} 60 | 61 | strategy: 62 | matrix: 63 | os: [ ubuntu-latest, macos-latest, windows-latest ] 64 | 65 | steps: 66 | - name: Checkout code 67 | uses: actions/checkout@v4.2.2 68 | 69 | - name: Set up Python 3.12 70 | uses: actions/setup-python@v5.3.0 71 | with: 72 | python-version: '3.12' 73 | 74 | - name: Install in dev mode 75 | run: python -m pip install -e . 76 | 77 | - name: Import package 78 | run: python -c 'import environ; print(environ.__version__)' 79 | -------------------------------------------------------------------------------- /.github/workflows/change-pr-target.yml: -------------------------------------------------------------------------------- 1 | name: Make sure new PRs are sent to develop 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | check-branch: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: Vankka/pr-target-branch-action@v3 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | with: 15 | target: main 16 | exclude: develop # Don't prevent going from develop -> main 17 | change-to: develop 18 | comment: | 19 | Your PR was set to target `main`, PRs should be target `develop` 20 | The base branch of this PR has been automatically changed to `develop`, please check that there are no merge conflicts. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | # These should always correspond to pull requests, so ignore them for 7 | # the push trigger and let them be triggered by the pull_request 8 | # trigger, avoiding running the workflow twice. This is a minor 9 | # optimization so there's no need to ensure this is comprehensive. 10 | - 'dependabot/**' 11 | 12 | # The branches below must be a subset of the branches above 13 | pull_request: 14 | branches: 15 | - develop 16 | - main 17 | 18 | schedule: 19 | - cron: '0 11 * * *' 20 | # | | | | | 21 | # | | | | |____ day of the week (0 - 6 or SUN-SAT) 22 | # | | | |____ month (1 - 12 or JAN-DEC) 23 | # | | |____ day of the month (1 - 31) 24 | # | |____ hour (0 - 23) 25 | # |____ minute (0 - 59) 26 | 27 | env: 28 | PYTHONUNBUFFERED: '1' 29 | 30 | defaults: 31 | run: 32 | shell: bash 33 | 34 | jobs: 35 | test: 36 | name: Python ${{ matrix.python }} on ${{ matrix.os }} 37 | runs-on: ${{ matrix.os }} 38 | 39 | # The maximum number of minutes to let a workflow run 40 | # before GitHub automatically cancels it. Default: 360 41 | timeout-minutes: 30 42 | 43 | strategy: 44 | # When set to true, GitHub cancels 45 | # all in-progress jobs if any matrix job fails. 46 | fail-fast: false 47 | 48 | matrix: 49 | python: 50 | - '3.9' 51 | - '3.10' 52 | - '3.11' 53 | - '3.12' 54 | - '3.13' 55 | - 'pypy-3.10' 56 | os: [ ubuntu-latest, macos-latest, windows-latest ] 57 | 58 | steps: 59 | - name: Checkout code 60 | uses: actions/checkout@v4.2.2 61 | with: 62 | fetch-depth: 5 63 | 64 | - name: Set up Python ${{ matrix.python }} 65 | uses: actions/setup-python@v5.3.0 66 | with: 67 | python-version: ${{ matrix.python }} 68 | 69 | - name: Install dependencies 70 | run: | 71 | python -m pip install --upgrade pip 72 | python -m pip install tox tox-gh-actions setuptools 73 | 74 | - name: Setuptools self-test 75 | run: | 76 | python setup.py --fullname 77 | python setup.py --long-description 78 | python setup.py --classifiers 79 | 80 | - name: Run unit tests with coverage 81 | run: tox 82 | 83 | - name: Combine coverage reports 84 | run: tox -e coverage-report 85 | 86 | - name: Upload coverage report 87 | if: success() 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | COVERALLS_SERVICE_NAME: github 91 | run: | 92 | python -m pip install coveralls 93 | # Do not fail job if coveralls.io is down 94 | coveralls || true 95 | 96 | - name: Success Reporting 97 | if: success() 98 | run: git log --format=fuller -5 99 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - main 8 | 9 | # The branches below must be a subset of the branches above 10 | pull_request: 11 | branches: 12 | - develop 13 | - main 14 | 15 | schedule: 16 | - cron: '40 22 * * 5' 17 | # | | | | | 18 | # | | | | |____ day of the week (0 - 6 or SUN-SAT) 19 | # | | | |____ month (1 - 12 or JAN-DEC) 20 | # | | |____ day of the month (1 - 31) 21 | # | |____ hour (0 - 23) 22 | # |____ minute (0 - 59) 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | # The maximum number of minutes to let a workflow run 34 | # before GitHub automatically cancels it. Default: 360 35 | timeout-minutes: 30 36 | 37 | strategy: 38 | # When set to true, GitHub cancels 39 | # all in-progress jobs if any matrix job fails. 40 | fail-fast: false 41 | 42 | matrix: 43 | language: 44 | - python 45 | 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v4.2.2 49 | 50 | # Initializes the CodeQL tools for scanning. 51 | - name: Initialize CodeQL 52 | uses: github/codeql-action/init@v3 53 | with: 54 | languages: ${{ matrix.language }} 55 | 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v3 61 | -------------------------------------------------------------------------------- /.github/workflows/cs.yml: -------------------------------------------------------------------------------- 1 | name: CS 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | # These should always correspond to pull requests, so ignore them for 7 | # the push trigger and let them be triggered by the pull_request 8 | # trigger, avoiding running the workflow twice. This is a minor 9 | # optimization so there's no need to ensure this is comprehensive. 10 | - 'dependabot/**' 11 | 12 | pull_request: 13 | branches: 14 | - develop 15 | - main 16 | 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | name: Code linting 21 | 22 | # The maximum number of minutes to let a workflow run 23 | # before GitHub automatically cancels it. Default: 360 24 | timeout-minutes: 30 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v4.2.2 29 | 30 | - name: Set up Python 3.12 31 | uses: actions/setup-python@v5.3.0 32 | with: 33 | python-version: '3.12' 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install tox tox-gh-actions setuptools 39 | 40 | - name: Lint with tox 41 | run: tox -e lint 42 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | # These should always correspond to pull requests, so ignore them for 7 | # the push trigger and let them be triggered by the pull_request 8 | # trigger, avoiding running the workflow twice. This is a minor 9 | # optimization so there's no need to ensure this is comprehensive. 10 | - 'dependabot/**' 11 | 12 | # The branches below must be a subset of the branches above 13 | pull_request: 14 | branches: 15 | - develop 16 | - main 17 | 18 | jobs: 19 | docs: 20 | runs-on: ubuntu-latest 21 | name: Build and test package documentation 22 | 23 | # The maximum number of minutes to let a workflow run 24 | # before GitHub automatically cancels it. Default: 360 25 | timeout-minutes: 30 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4.2.2 30 | 31 | - name: Set up Python 3.12 32 | uses: actions/setup-python@v5.3.0 33 | with: 34 | python-version: '3.12' 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install tox tox-gh-actions setuptools 40 | 41 | - name: Check external links in the package documentation 42 | run: tox -e linkcheck 43 | 44 | - name: Build and test package documentation 45 | run: tox -e docs 46 | 47 | - name: Archive docs artifacts 48 | if: always() 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: docs 52 | path: docs 53 | # Artifacts are retained for 90 days by default. 54 | # In fact, we don't need such long period. 55 | retention-days: 60 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | # Please do not use this ignore file to define platform specific files. 10 | # 11 | # For these purposes create a global .gitignore file, which is a list of rules 12 | # for ignoring files in every Git repository on your computer. 13 | # 14 | # https://help.github.com/articles/ignoring-files/#create-a-global-gitignore 15 | 16 | # Directories to ignore (do not add trailing '/'s, they skip symlinks). 17 | /.pytest_cache 18 | /.tox 19 | /build 20 | /dist 21 | /*.egg-info 22 | /htmlcov 23 | /docs/_build 24 | 25 | # Python cache. 26 | *.py[cod] 27 | __pycache__ 28 | 29 | # Ignore codecoverage stuff. 30 | .coverage* 31 | coverage.xml 32 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | # Read the Docs configuration file 10 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 11 | 12 | --- 13 | version: 2 14 | 15 | build: 16 | os: ubuntu-20.04 17 | tools: 18 | # Keep version in sync with tox.ini (testenv:docs) and 19 | # docs.yml (GitHub Action Workflow). 20 | python: '3.12' 21 | 22 | python: 23 | install: 24 | - method: pip 25 | path: . 26 | extra_requirements: 27 | - docs 28 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Credits 2 | ======= 3 | 4 | ``django-environ`` was initially created by `Daniele Faraglia `_ 5 | and currently maintained by `Serghei Iakovlev `_. 6 | 7 | A full list of contributors can be found in `GitHub `__. 8 | 9 | Acknowledgments 10 | =============== 11 | 12 | The existence of ``django-environ`` would have been impossible without these 13 | projects: 14 | 15 | - `rconradharris/envparse `_ 16 | - `jazzband/dj-database-url `_ 17 | - `migonzalvar/dj-email-url `_ 18 | - `ghickman/django-cache-url `_ 19 | - `dstufft/dj-search-url `_ 20 | - `julianwachholz/dj-config-url `_ 21 | - `nickstenning/honcho `_ 22 | - `rconradharris/envparse `_ 23 | -------------------------------------------------------------------------------- /BACKERS.rst: -------------------------------------------------------------------------------- 1 | Backers and supporters 2 | ====================== 3 | 4 | You can join them in supporting django-environ development by visiting our page 5 | on `Open Collective `_ and becoming 6 | a sponsor or a backer! 7 | 8 | Sponsors 9 | -------- 10 | 11 | Support this project by becoming a sponsor. Your logo will show up here with a 12 | link to your website. `Became sponsor `_. 13 | 14 | |ocsponsor0| |ocsponsor1| 15 | 16 | Backers 17 | ------- 18 | 19 | Thank you to all our backers! 20 | 21 | |ocbackerimage| 22 | 23 | .. |ocsponsor0| image:: https://opencollective.com/django-environ/sponsor/0/avatar.svg 24 | :target: https://opencollective.com/triplebyte 25 | :alt: Sponsor 26 | .. |ocsponsor1| image:: https://images.opencollective.com/static/images/become_sponsor.svg 27 | :target: https://opencollective.com/django-environ/contribute/sponsors-3474/checkout 28 | :alt: Become a Sponsor 29 | .. |ocbackerimage| image:: https://opencollective.com/django-environ/backers.svg?width=890 30 | :target: https://opencollective.com/django-environ 31 | :alt: Backers on Open Collective 32 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | All notable changes to this project will be documented in this file. 5 | The format is inspired by `Keep a Changelog `_ 6 | and this project adheres to `Semantic Versioning `_. 7 | 8 | `v0.12.0`_ - 8-November-2024 9 | ----------------------------- 10 | Fixed 11 | +++++ 12 | - Include prefix in the ``ImproperlyConfigured`` error message 13 | `#513 `_. 14 | 15 | Added 16 | +++++ 17 | - Add support for Python 3.12 and 3.13 18 | `#538 `_. 19 | - Add support for Django 5.1 20 | `#535 `_. 21 | - Add support for Django CockroachDB driver 22 | `#509 `_. 23 | - Add support for Django Channels 24 | `#266 `_. 25 | 26 | Changed 27 | +++++++ 28 | - Disabled inline comments handling by default due to potential side effects. 29 | While the feature itself is useful, the project's philosophy dictates that 30 | it should not be enabled by default for all users 31 | `#499 `_. 32 | 33 | Removed 34 | +++++++ 35 | - Removed support of Python 3.6, 3.7 and 3.8 36 | `#538 `_. 37 | - Removed support of Django 1.x. 38 | `#538 `_. 39 | 40 | 41 | `v0.11.2`_ - 1-September-2023 42 | ----------------------------- 43 | Fixed 44 | +++++ 45 | - Revert "Add variable expansion." feature 46 | due to `#490 `_. 47 | 48 | 49 | `v0.11.1`_ - 30-August-2023 50 | --------------------------- 51 | Fixed 52 | +++++ 53 | - Revert "Add interpolate argument to avoid resolving proxied values." feature 54 | due to `#485 `_. 55 | 56 | 57 | `v0.11.0`_ - 30-August-2023 58 | --------------------------- 59 | Added 60 | +++++ 61 | - Added support for Django 4.2 62 | `#456 `_. 63 | - Added support for secure Elasticsearch connections 64 | `#463 `_. 65 | - Added variable expansion 66 | `#468 `_. 67 | - Added capability to handle comments after ``#``, after quoted values, 68 | like ``KEY= 'part1 # part2' # comment`` 69 | `#475 `_. 70 | - Added support for ``interpolate`` parameter 71 | `#415 `_. 72 | 73 | Changed 74 | +++++++ 75 | - Used ``mssql-django`` as engine for SQL Server 76 | `#446 `_. 77 | - Changed handling bool values, stripping whitespace around value 78 | `#475 `_. 79 | - Use ``importlib.util.find_spec`` to ``replace pkgutil.find_loader`` 80 | `#482 `_. 81 | 82 | 83 | Removed 84 | +++++++ 85 | - Removed support of Python 3.5. 86 | 87 | 88 | `v0.10.0`_ - 2-March-2023 89 | ------------------------- 90 | Added 91 | +++++ 92 | - Use the core redis library by default if running Django >= 4.0 93 | `#356 `_. 94 | - Value of dict can now contain an equal sign 95 | `#241 `_. 96 | - Added support for Python 3.11. 97 | - Added ``CONN_HEALTH_CHECKS`` to database base options 98 | `#413 `_. 99 | - Added ``encoding`` parameter to ``read_env`` with default value 'utf8' 100 | `#442 `_. 101 | - Added support for Django 4.1 102 | `#416 `_. 103 | 104 | Deprecated 105 | ++++++++++ 106 | - Support of Python < 3.6 is deprecated and will be removed 107 | in next major version. 108 | 109 | Changed 110 | +++++++ 111 | - Used UTF-8 as a encoding when open ``.env`` file. 112 | - Provided access to ``DB_SCHEMES`` through ``cls`` rather than 113 | ``Env`` in ``db_url_config`` 114 | `#414 `_. 115 | - Correct CI workflow to use supported Python versions/OS matrix 116 | `#441 `_. 117 | - Reworked trigger CI workflows strategy 118 | `#440 `_. 119 | 120 | Fixed 121 | +++++ 122 | - Fixed logic of ``Env.get_value()`` to skip parsing only when 123 | ``default=None``, not for all default values that coerce to ``False`` 124 | `#404 `_. 125 | - Deleted duplicated include in docs/quickstart.rst 126 | `#439 `_. 127 | 128 | Removed 129 | +++++++ 130 | - Removed deprecated ``Env.unicode()``. 131 | - Removed ``environ.register_schemes`` calls and do not modify global 132 | ``urllib.parse.urlparse``'s ``uses_*`` variables as this no longer needed 133 | `#246 `_. 134 | 135 | 136 | `v0.9.0`_ - 15-June-2022 137 | ------------------------ 138 | Added 139 | +++++ 140 | - Added support for Postgresql cluster URI 141 | `#355 `_. 142 | - Added support for Django 4.0 143 | `#371 `_. 144 | - Added support for prefixed variables 145 | `#362 `_. 146 | - Amended documentation. 147 | 148 | Deprecated 149 | ++++++++++ 150 | - ``Env.unicode()`` is deprecated and will be removed in the next 151 | major release. Use ``Env.str()`` instead. 152 | 153 | Changed 154 | +++++++ 155 | - Attach cause to ``ImproperlyConfigured`` exception 156 | `#360 `_. 157 | 158 | Fixed 159 | +++++ 160 | - Fixed ``_cast_urlstr`` unquoting 161 | `#357 `_. 162 | - Fixed documentation regarding unsafe characters in URLs 163 | `#220 `_. 164 | - Fixed ``environ.Path.__eq__()`` to compare paths correctly 165 | `#86 `_, 166 | `#197 `_. 167 | 168 | 169 | `v0.8.1`_ - 20-October-2021 170 | --------------------------- 171 | Fixed 172 | +++++ 173 | - Fixed "Invalid line" spam logs on blank lines in env file 174 | `#340 `_. 175 | - Fixed ``memcache``/``pymemcache`` URL parsing for correct identification of 176 | connection type `#337 `_. 177 | 178 | 179 | `v0.8.0`_ - 17-October-2021 180 | --------------------------- 181 | Added 182 | +++++ 183 | - Log invalid lines when parse ``.env`` file 184 | `#283 `_. 185 | - Added docker-style file variable support 186 | `#189 `_. 187 | - Added option to override existing variables with ``read_env`` 188 | `#103 `_, 189 | `#249 `_. 190 | - Added support for empty var with None default value 191 | `#209 `_. 192 | - Added ``pymemcache`` cache backend for Django 3.2+ 193 | `#335 `_. 194 | 195 | Fixed 196 | +++++ 197 | - Keep newline/tab escapes in quoted strings 198 | `#296 `_. 199 | - Handle escaped dollar sign in values 200 | `#271 `_. 201 | - Fixed incorrect parsing of ``DATABASES_URL`` for Google Cloud MySQL 202 | `#294 `_. 203 | 204 | 205 | `v0.7.0`_ - 11-September-2021 206 | ------------------------------ 207 | Added 208 | +++++ 209 | - Added support for negative float strings 210 | `#160 `_. 211 | - Added Elasticsearch5 to search scheme 212 | `#297 `_. 213 | - Added Elasticsearch7 to search scheme 214 | `#314 `_. 215 | - Added the ability to use ``bytes`` or ``str`` as a default value for ``Env.bytes()``. 216 | 217 | Fixed 218 | +++++ 219 | - Fixed links in the documentation. 220 | - Use default option in ``Env.bytes()`` 221 | `#206 `_. 222 | - Safely evaluate a string containing an invalid Python literal 223 | `#200 `_. 224 | 225 | Changed 226 | +++++++ 227 | - Added 'Funding' and 'Say Thanks!' project urls on pypi. 228 | - Stop raising ``UserWarning`` if ``.env`` file isn't found. Log a message with 229 | ``INFO`` log level instead `#243 `_. 230 | 231 | 232 | `v0.6.0`_ - 4-September-2021 233 | ---------------------------- 234 | Added 235 | +++++ 236 | - Python 3.9, 3.10 and pypy 3.7 are now supported. 237 | - Django 3.1 and 3.2 are now supported. 238 | - Added missed classifiers to ``setup.py``. 239 | - Accept Python 3.6 path-like objects for ``read_env`` 240 | `#106 `_, 241 | `#286 `_. 242 | 243 | Fixed 244 | +++++ 245 | - Fixed various code linting errors. 246 | - Fixed typos in the documentation. 247 | - Added missed files to the package contents. 248 | - Fixed ``db_url_config`` to work the same for all postgres-like schemes 249 | `#264 `_, 250 | `#268 `_. 251 | 252 | Changed 253 | +++++++ 254 | - Refactor tests to use pytest and follow DRY. 255 | - Moved CI to GitHub Actions. 256 | - Restructuring of project documentation. 257 | - Build and test package documentation as a part of CI pipeline. 258 | - Build and test package distribution as a part of CI pipeline. 259 | - Check ``MANIFEST.in`` in a source package for completeness as a part of CI 260 | pipeline. 261 | - Added ``pytest`` and ``coverage[toml]`` to setuptools' ``extras_require``. 262 | 263 | 264 | `v0.5.0`_ - 30-August-2021 265 | -------------------------- 266 | Added 267 | +++++ 268 | - Support for Django 2.1 & 2.2. 269 | - Added tox.ini targets. 270 | - Added secure redis backend URLs via ``rediss://``. 271 | - Added ``cast=str`` to ``str()`` method. 272 | 273 | Fixed 274 | +++++ 275 | - Fixed misspelling in the documentation. 276 | 277 | Changed 278 | +++++++ 279 | - Validate empty cache url and invalid cache schema. 280 | - Set ``long_description_content_type`` in setup. 281 | - Improved Django 1.11 database configuration support. 282 | 283 | 284 | `v0.4.5`_ - 25-June-2018 285 | ------------------------ 286 | Added 287 | +++++ 288 | - Support for Django 2.0. 289 | - Support for smart casting. 290 | - Support PostgreSQL unix domain socket paths. 291 | - Tip: Multiple env files. 292 | 293 | Changed 294 | +++++++ 295 | - Fix parsing option values ``None``, ``True`` and ``False``. 296 | - Order of importance of engine configuration in ``db_url_config``. 297 | 298 | Removed 299 | +++++++ 300 | - Remove ``django`` and ``six`` dependencies. 301 | 302 | 303 | `v0.4.4`_ - 21-August-2017 304 | -------------------------- 305 | 306 | Added 307 | +++++ 308 | - Support for ``django-redis`` multiple locations (master/slave, shards). 309 | - Support for Elasticsearch2. 310 | - Support for Mysql-connector. 311 | - Support for ``pyodbc``. 312 | - Added ``__contains__`` feature to Environ class. 313 | 314 | Fixed 315 | +++++ 316 | - Fix Path subtracting. 317 | 318 | `v0.4.3`_ - 21-August-2017 319 | -------------------------- 320 | Changed 321 | +++++++ 322 | - Rollback the default Environ to ``os.environ``. 323 | 324 | 325 | `v0.4.2`_ - 13-April-2017 326 | ------------------------- 327 | Added 328 | +++++ 329 | - Confirm support for Django 1.11. 330 | - Support for Redshift database URL. 331 | 332 | Changed 333 | +++++++ 334 | - Fixed uwsgi settings reload problem 335 | `#55 `_. 336 | - Update support for ``django-redis`` urls 337 | `#109 `_. 338 | 339 | 340 | `v0.4.1`_ - 13-November-2016 341 | ---------------------------- 342 | Added 343 | +++++ 344 | - Add support for Django 1.10. 345 | 346 | Changed 347 | +++++++ 348 | - Fixed for unsafe characters into URLs. 349 | - Clarifying warning on missing or unreadable file. 350 | Thanks to `@nickcatal `_. 351 | - Fixed support for Oracle urls. 352 | - Fixed support for ``django-redis``. 353 | 354 | 355 | `v0.4`_ - 23-September-2015 356 | --------------------------- 357 | Added 358 | +++++ 359 | - New email schemes - ``smtp+ssl`` and ``smtp+tls`` (``smtps`` would be deprecated). 360 | - Added tuple support. Thanks to `@anonymouzz `_. 361 | - Added LDAP url support for database. Thanks to 362 | `django-ldapdb/django-ldapdb `_. 363 | 364 | Changed 365 | +++++++ 366 | - Fixed non-ascii values (broken in Python 2.x). 367 | - ``redis_cache`` replaced by ``django_redis``. 368 | - Fixed psql/pgsql url. 369 | 370 | 371 | `v0.3.1`_ - 19 Sep 2015 372 | ----------------------- 373 | Added 374 | +++++ 375 | - Added ``email`` as alias for ``email_url``. 376 | - Django 1.7 is now supported. 377 | - Added LDAP scheme support for ``db_url_config``. 378 | 379 | Fixed 380 | +++++ 381 | - Fixed typos in the documentation. 382 | - Fixed ``environ.Path.__add__`` to correctly handle plus operator. 383 | - Fixed ``environ.Path.__contains__`` to correctly work on Windows. 384 | 385 | 386 | `v0.3`_ - 03-June-2014 387 | ---------------------- 388 | Added 389 | +++++ 390 | - Added cache url support. 391 | - Added email url support. 392 | - Added search url support. 393 | 394 | Changed 395 | +++++++ 396 | - Rewriting README.rst. 397 | 398 | 399 | v0.2.1 - 19-April-2013 400 | ---------------------- 401 | Changed 402 | +++++++ 403 | - ``Env.__call__`` now uses ``Env.get_value`` instance method. 404 | 405 | 406 | v0.2 - 16-April-2013 407 | -------------------- 408 | Added 409 | +++++ 410 | - Added advanced float parsing (comma and dot symbols to separate thousands and decimals). 411 | 412 | Fixed 413 | +++++ 414 | - Fixed typos in the documentation. 415 | 416 | 417 | v0.1 - 2-April-2013 418 | ------------------- 419 | Added 420 | +++++ 421 | - Initial release. 422 | 423 | 424 | .. _v0.12.0: https://github.com/joke2k/django-environ/compare/v0.11.2...v0.12.0 425 | .. _v0.11.2: https://github.com/joke2k/django-environ/compare/v0.11.1...v0.11.2 426 | .. _v0.11.1: https://github.com/joke2k/django-environ/compare/v0.11.0...v0.11.1 427 | .. _v0.11.0: https://github.com/joke2k/django-environ/compare/v0.10.0...v0.11.0 428 | .. _v0.10.0: https://github.com/joke2k/django-environ/compare/v0.9.0...v0.10.0 429 | .. _v0.9.0: https://github.com/joke2k/django-environ/compare/v0.8.1...v0.9.0 430 | .. _v0.8.1: https://github.com/joke2k/django-environ/compare/v0.8.0...v0.8.1 431 | .. _v0.8.0: https://github.com/joke2k/django-environ/compare/v0.7.0...v0.8.0 432 | .. _v0.7.0: https://github.com/joke2k/django-environ/compare/v0.6.0...v0.7.0 433 | .. _v0.6.0: https://github.com/joke2k/django-environ/compare/v0.5.0...v0.6.0 434 | .. _v0.5.0: https://github.com/joke2k/django-environ/compare/v0.4.5...v0.5.0 435 | .. _v0.4.5: https://github.com/joke2k/django-environ/compare/v0.4.4...v0.4.5 436 | .. _v0.4.4: https://github.com/joke2k/django-environ/compare/v0.4.3...v0.4.4 437 | .. _v0.4.3: https://github.com/joke2k/django-environ/compare/v0.4.2...v0.4.3 438 | .. _v0.4.2: https://github.com/joke2k/django-environ/compare/v0.4.1...v0.4.2 439 | .. _v0.4.1: https://github.com/joke2k/django-environ/compare/v0.4...v0.4.1 440 | .. _v0.4: https://github.com/joke2k/django-environ/compare/v0.3.1...v0.4 441 | .. _v0.3.1: https://github.com/joke2k/django-environ/compare/v0.3...v0.3.1 442 | .. _v0.3: https://github.com/joke2k/django-environ/compare/v0.2.1...v0.3 443 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | If you would like to contribute to ``django-environ``, please take a look at the 5 | `current issues `_. If there is 6 | a bug or feature that you want but it isn't listed, make an issue and work on it. 7 | 8 | Bug reports 9 | ----------- 10 | 11 | *Before raising an issue, please ensure that you are using the latest version 12 | of django-environ.* 13 | 14 | Please provide the following information with your issue to enable us to 15 | respond as quickly as possible. 16 | 17 | * The relevant versions of the packages you are using. 18 | * The steps to recreate your issue. 19 | * The full stacktrace if there is an exception. 20 | * An executable code example where possible 21 | 22 | Guidelines for bug reports: 23 | 24 | * **Use the GitHub issue search** — check if the issue has already been 25 | reported. 26 | * **Check if the issue has been fixed** — try to reproduce it using the latest 27 | ``main`` or ``develop`` branch in the repository. 28 | * Isolate the problem — create a reduced test case and a live example. 29 | 30 | A good bug report shouldn't leave others needing to chase you up for more 31 | information. Please try to be as detailed as possible in your report. What is 32 | your environment? What steps will reproduce the issue? What OS experience the 33 | problem? What would you expect to be the outcome? All these details will help 34 | people to fix any potential bugs. 35 | 36 | Feature requests 37 | ---------------- 38 | 39 | Feature requests are welcome. But take a moment to find out whether your idea 40 | fits with the scope and aims of the project. It's up to *you* to make a strong 41 | case to convince the project's developers of the merits of this feature. Please 42 | provide as much detail and context as possible. 43 | 44 | Pull requests 45 | ------------- 46 | 47 | Good pull requests - patches, improvements, new features - are a fantastic 48 | help. They should remain focused in scope and avoid containing unrelated 49 | commits. 50 | 51 | Follow this process if you'd like your work considered for inclusion in the 52 | project: 53 | 54 | 1. Check for open issues or open a fresh issue to start a discussion around a 55 | feature idea or a bug. 56 | 2. Fork `the repository `_ 57 | on GitHub to start making your changes to the ``develop`` branch 58 | (or branch off of it). 59 | 3. Write a test which shows that the bug was fixed or that the feature works as 60 | expected. 61 | 4. Send a pull request and bug the maintainer until it gets merged and published. 62 | 63 | If you are intending to implement a fairly large feature we'd appreciate if you 64 | open an issue with GitHub detailing your use case and intended solution to 65 | discuss how it might impact other work that is in flight. 66 | 67 | We also appreciate it if you take the time to update and write tests for any 68 | changes you submit. 69 | 70 | **By submitting a patch, you agree to allow the project owner to license your 71 | work under the same license as that used by the project.** 72 | 73 | Resources 74 | --------- 75 | 76 | * `How to Contribute to Open Source `_ 77 | * `Using Pull Requests `_ 78 | * `Writing good commit messages `_ 79 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021-2024, Serghei Iakovlev 2 | Copyright (c) 2013-2021, Daniele Faraglia 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | # This file consists of commands, one per line, instructing setuptools to add 10 | # or remove some set of files from the sdist. 11 | 12 | # Include all files matching any of the listed patterns. 13 | include *.rst LICENSE.txt *.yml 14 | graft .github 15 | 16 | # The contents of the directory tree tests will first be added to the sdist. 17 | # Many OS distributions prefers provide an ability run the tests 18 | # during the package installation. 19 | recursive-include tests *.py 20 | recursive-include tests *.txt 21 | include tox.ini 22 | 23 | # All files in the sdist with a .pyc, .pyo, or .pyd extension will be removed 24 | # from the sdist. 25 | global-exclude *.py[cod] 26 | 27 | # Documentation 28 | include docs/docutils.conf docs/Makefile 29 | recursive-include docs *.png 30 | recursive-include docs *.svg 31 | recursive-include docs *.py 32 | recursive-include docs *.rst 33 | recursive-include docs *.gitkeep 34 | prune docs/_build 35 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. raw:: html 2 | 3 |

django-environ

4 |

5 | 6 | Latest version released on PyPi 7 | 8 | 9 | Coverage Status 10 | 11 | 12 | CI Status 13 | 14 | 15 | Sponsors on Open Collective 16 | 17 | 18 | Backers on Open Collective 19 | 20 | 21 | Say Thanks! 22 | 23 | 24 | Package license 25 | 26 |

27 | 28 | .. -teaser-begin- 29 | 30 | ``django-environ`` is the Python package that allows you to use 31 | `Twelve-factor methodology `_ to configure your 32 | Django application with environment variables. 33 | 34 | .. -teaser-end- 35 | 36 | For that, it gives you an easy way to configure Django application using 37 | environment variables obtained from an environment file and provided by the OS: 38 | 39 | .. -code-begin- 40 | 41 | .. code-block:: python 42 | 43 | import environ 44 | import os 45 | 46 | env = environ.Env( 47 | # set casting, default value 48 | DEBUG=(bool, False) 49 | ) 50 | 51 | # Set the project base directory 52 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 53 | 54 | # Take environment variables from .env file 55 | environ.Env.read_env(os.path.join(BASE_DIR, '.env')) 56 | 57 | # False if not in os.environ because of casting above 58 | DEBUG = env('DEBUG') 59 | 60 | # Raises Django's ImproperlyConfigured 61 | # exception if SECRET_KEY not in os.environ 62 | SECRET_KEY = env('SECRET_KEY') 63 | 64 | # Parse database connection url strings 65 | # like psql://user:pass@127.0.0.1:8458/db 66 | DATABASES = { 67 | # read os.environ['DATABASE_URL'] and raises 68 | # ImproperlyConfigured exception if not found 69 | # 70 | # The db() method is an alias for db_url(). 71 | 'default': env.db(), 72 | 73 | # read os.environ['SQLITE_URL'] 74 | 'extra': env.db_url( 75 | 'SQLITE_URL', 76 | default='sqlite:////tmp/my-tmp-sqlite.db' 77 | ) 78 | } 79 | 80 | CACHES = { 81 | # Read os.environ['CACHE_URL'] and raises 82 | # ImproperlyConfigured exception if not found. 83 | # 84 | # The cache() method is an alias for cache_url(). 85 | 'default': env.cache(), 86 | 87 | # read os.environ['REDIS_URL'] 88 | 'redis': env.cache_url('REDIS_URL') 89 | } 90 | 91 | .. -overview- 92 | 93 | The idea of this package is to unify a lot of packages that make the same stuff: 94 | Take a string from ``os.environ``, parse and cast it to some of useful python 95 | typed variables. To do that and to use the `12factor `_ 96 | approach, some connection strings are expressed as url, so this package can parse 97 | it and return a ``urllib.parse.ParseResult``. These strings from ``os.environ`` 98 | are loaded from a ``.env`` file and filled in ``os.environ`` with ``setdefault`` 99 | method, to avoid to overwrite the real environ. 100 | A similar approach is used in 101 | `Two Scoops of Django `_ 102 | book and explained in `12factor-django `_ 103 | article. 104 | 105 | 106 | Using ``django-environ`` you can stop to make a lot of unversioned 107 | ``settings_*.py`` to configure your app. 108 | See `cookiecutter-django `_ 109 | for a concrete example on using with a django project. 110 | 111 | **Feature Support** 112 | 113 | - Fast and easy multi environment for deploy 114 | - Fill ``os.environ`` with .env file variables 115 | - Variables casting 116 | - Url variables exploded to django specific package settings 117 | - Optional support for Docker-style file based config variables (use 118 | ``environ.FileAwareEnv`` instead of ``environ.Env``) 119 | 120 | .. -project-information- 121 | 122 | Project Information 123 | =================== 124 | 125 | ``django-environ`` is released under the `MIT / X11 License `__, 126 | its documentation lives at `Read the Docs `_, 127 | the code on `GitHub `_, 128 | and the latest release on `PyPI `_. 129 | 130 | It’s rigorously tested on Python 3.9+, and officially supports 131 | Django 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0, and 5.1. 132 | 133 | If you'd like to contribute to ``django-environ`` you're most welcome! 134 | 135 | .. -support- 136 | 137 | Support 138 | ======= 139 | 140 | Should you have any question, any remark, or if you find a bug, or if there is 141 | something you can't do with the ``django-environ``, please 142 | `open an issue `_. 143 | -------------------------------------------------------------------------------- /SECURITY.rst: -------------------------------------------------------------------------------- 1 | Security Policy 2 | =============== 3 | 4 | 5 | Reporting a Vulnerability 6 | ------------------------- 7 | 8 | If you discover a security vulnerability within ``django-environ``, please 9 | send an e-mail to Serghei Iakovlev via oss@serghei.pl. All security 10 | vulnerabilities will be promptly addressed. 11 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | # Makefile for Sphinx documentation 10 | 11 | # You can set these variables from the command line. 12 | SPHINXOPTS = 13 | SPHINXBUILD = sphinx-build 14 | PAPER = 15 | BUILDDIR = _build 16 | 17 | # User-friendly check for sphinx-build 18 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 19 | $(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/) 20 | endif 21 | 22 | # Internal variables. 23 | PAPEROPT_a4 = -D latex_paper_size=a4 24 | PAPEROPT_letter = -D latex_paper_size=letter 25 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 26 | # the i18n builder cannot share the environment and doctrees with the others 27 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 28 | 29 | .PHONY: help 30 | help: 31 | @echo "Please use \`make ' where is one of" 32 | @echo " html to make standalone HTML files" 33 | @echo " dirhtml to make HTML files named index.html in directories" 34 | @echo " singlehtml to make a single large HTML file" 35 | @echo " pickle to make pickle files" 36 | @echo " json to make JSON files" 37 | @echo " htmlhelp to make HTML files and a HTML help project" 38 | @echo " qthelp to make HTML files and a qthelp project" 39 | @echo " devhelp to make HTML files and a Devhelp project" 40 | @echo " epub to make an epub" 41 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 42 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 43 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 44 | @echo " text to make text files" 45 | @echo " man to make manual pages" 46 | @echo " texinfo to make Texinfo files" 47 | @echo " info to make Texinfo files and run them through makeinfo" 48 | @echo " gettext to make PO message catalogs" 49 | @echo " changes to make an overview of all changed/added/deprecated items" 50 | @echo " xml to make Docutils-native XML files" 51 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 52 | @echo " linkcheck to check all external links for integrity" 53 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 54 | 55 | .PHONY: clean 56 | clean: 57 | rm -rf $(BUILDDIR)/* 58 | 59 | .PHONY: html 60 | html: 61 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 62 | @echo 63 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 64 | 65 | .PHONY: dirhtml 66 | dirhtml: 67 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 68 | @echo 69 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 70 | 71 | .PHONY: singlehtml 72 | singlehtml: 73 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 74 | @echo 75 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 76 | 77 | .PHONY: pickle 78 | pickle: 79 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 80 | @echo 81 | @echo "Build finished; now you can process the pickle files." 82 | 83 | .PHONY: json 84 | json: 85 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 86 | @echo 87 | @echo "Build finished; now you can process the JSON files." 88 | 89 | .PHONY: htmlhelp 90 | htmlhelp: 91 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 92 | @echo 93 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 94 | ".hhp project file in $(BUILDDIR)/htmlhelp." 95 | 96 | .PHONY: qthelp 97 | qthelp: 98 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 99 | @echo 100 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 101 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 102 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-environ.qhcp" 103 | @echo "To view the help file:" 104 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-environ.qhc" 105 | 106 | .PHONY: devhelp 107 | devhelp: 108 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 109 | @echo 110 | @echo "Build finished." 111 | @echo "To view the help file:" 112 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-environ" 113 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-environ" 114 | @echo "# devhelp" 115 | 116 | .PHONY: epub 117 | epub: 118 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 119 | @echo 120 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 121 | 122 | .PHONY: latex 123 | latex: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo 126 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 127 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 128 | "(use \`make latexpdf' here to do that automatically)." 129 | 130 | .PHONY: latexpdf 131 | latexpdf: 132 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 133 | @echo "Running LaTeX files through pdflatex..." 134 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 135 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 136 | 137 | .PHONY: 138 | latexpdfja: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through platex and dvipdfmx..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: text 145 | text: 146 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 147 | @echo 148 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 149 | 150 | .PHONY: man 151 | man: 152 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 153 | @echo 154 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 155 | 156 | .PHONY: texinfo 157 | texinfo: 158 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 159 | @echo 160 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 161 | @echo "Run \`make' in that directory to run these through makeinfo" \ 162 | "(use \`make info' here to do that automatically)." 163 | 164 | .PHONY: info 165 | info: 166 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 167 | @echo "Running Texinfo files through makeinfo..." 168 | make -C $(BUILDDIR)/texinfo info 169 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 170 | 171 | .PHONY: gettext 172 | gettext: 173 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 174 | @echo 175 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 176 | 177 | .PHONY: changes 178 | changes: 179 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 180 | @echo 181 | @echo "The overview file is in $(BUILDDIR)/changes." 182 | 183 | .PHONY: linkcheck 184 | linkcheck: 185 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 186 | @echo 187 | @echo "Link check complete; look for any errors in the above output " \ 188 | "or in $(BUILDDIR)/linkcheck/output.txt." 189 | 190 | .PHONY: doctest 191 | doctest: 192 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 193 | @echo "Testing of doctests in the sources finished, look at the " \ 194 | "results in $(BUILDDIR)/doctest/output.txt." 195 | 196 | .PHONY: xml 197 | xml: 198 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 199 | @echo 200 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 201 | 202 | .PHONY: pseudoxml 203 | pseudoxml: 204 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 205 | @echo 206 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 207 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joke2k/django-environ/176e812d8624f6937ba3dd276a0032929d87eb69/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | .. currentmodule:: environ 6 | 7 | 8 | The ``__init__`` module 9 | ======================= 10 | 11 | .. automodule:: environ 12 | :members: 13 | :special-members: 14 | :no-undoc-members: 15 | 16 | 17 | The ``compat`` module 18 | ====================== 19 | 20 | .. automodule:: environ.compat 21 | :members: 22 | :no-undoc-members: 23 | 24 | 25 | The ``environ`` module 26 | ====================== 27 | 28 | .. autoclass:: environ.Env 29 | :members: 30 | :no-undoc-members: 31 | 32 | .. autoclass:: environ.FileAwareEnv 33 | :members: 34 | :no-undoc-members: 35 | 36 | .. autoclass:: environ.Path 37 | :members: 38 | :no-undoc-members: 39 | 40 | 41 | The ``fileaware_mapping`` module 42 | ================================ 43 | 44 | .. autoclass:: environ.fileaware_mapping.FileAwareMapping 45 | :members: 46 | :no-undoc-members: 47 | -------------------------------------------------------------------------------- /docs/backers.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../BACKERS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | # 10 | # -- Utils --------------------------------------------------------- 11 | # 12 | 13 | import codecs 14 | import os 15 | import re 16 | import sys 17 | from datetime import date 18 | 19 | PROJECT_DIR = os.path.abspath('..') 20 | sys.path.insert(0, PROJECT_DIR) 21 | 22 | 23 | def read_file(filepath): 24 | """Read content from a UTF-8 encoded text file.""" 25 | with codecs.open(filepath, 'rb', 'utf-8') as file_handle: 26 | return file_handle.read() 27 | 28 | 29 | def find_version(meta_file): 30 | """Extract ``__version__`` from meta_file.""" 31 | contents = read_file(os.path.join(PROJECT_DIR, meta_file)) 32 | meta_match = re.search( 33 | r"^__version__\s+=\s+['\"]([^'\"]*)['\"]", 34 | contents, 35 | re.M 36 | ) 37 | 38 | if meta_match: 39 | return meta_match.group(1) 40 | raise RuntimeError( 41 | "Unable to find __version__ string in package meta file") 42 | 43 | 44 | # 45 | # -- Project information ----------------------------------------------------- 46 | # 47 | 48 | # General information about the project. 49 | project = "django-environ" 50 | copyright = f'2013-{date.today().year}, Daniele Faraglia and other contributors' 51 | author = u"Daniele Faraglia \\and Serghei Iakovlev" 52 | 53 | # 54 | # -- General configuration --------------------------------------------------- 55 | # 56 | 57 | extensions = [ 58 | "sphinx.ext.autodoc", 59 | "sphinx.ext.doctest", 60 | "sphinx.ext.intersphinx", 61 | "sphinx.ext.todo", 62 | "sphinx.ext.viewcode", 63 | "notfound.extension", 64 | ] 65 | 66 | # Add any paths that contain templates here, relative to this directory. 67 | templates_path = ["_templates"] 68 | 69 | # The suffix of source filenames. 70 | source_suffix = ".rst" 71 | 72 | # Allow non-local URIs, so we can have images in CHANGELOG etc. 73 | suppress_warnings = [ 74 | "image.nonlocal_uri", 75 | ] 76 | 77 | # The master toctree document. 78 | master_doc = "index" 79 | 80 | # The version info 81 | # The short X.Y version. 82 | release = find_version(os.path.join("environ", "__init__.py")) 83 | version = release.rsplit(u".", 1)[0] 84 | # The full version, including alpha/beta/rc tags. 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | exclude_patterns = ["_build"] 89 | 90 | # The reST default role (used for this markup: `text`) to use for all 91 | # documents. 92 | default_role = "any" 93 | 94 | # If true, '()' will be appended to :func: etc. cross-reference text. 95 | add_function_parentheses = True 96 | 97 | # 98 | # -- Options for autodoc --------------------------------------------------- 99 | # 100 | 101 | # This value selects if automatically documented members are sorted alphabetical 102 | # (value 'alphabetical'), by member type (value 'groupwise') or by source order 103 | # (value 'bysource'). The default is alphabetical. 104 | # 105 | # Note that for source order, the module must be a Python module with the 106 | # source code available. 107 | autodoc_member_order = 'bysource' 108 | 109 | # 110 | # -- Options for linkcheck --------------------------------------------------- 111 | # 112 | 113 | linkcheck_ignore = [ 114 | # We run into GitHub's rate limits. 115 | r"https://github.com/.*/(issues|pull)/\d+", 116 | # Do not check links to compare tags. 117 | r"https://github.com/joke2k/django-environ/compare/.*", 118 | ] 119 | 120 | # 121 | # -- Options for nitpick ----------------------------------------------------- 122 | # 123 | 124 | # In nitpick mode (-n), still ignore any of the following "broken" references 125 | # to non-types. 126 | nitpick_ignore = [ 127 | ('py:func', 'str.rfind'), 128 | ('py:func', 'str.find'), 129 | ] 130 | 131 | # 132 | # -- Options for extlinks ---------------------------------------------------- 133 | # 134 | 135 | extlinks = { 136 | "pypi": ("https://pypi.org/project/%s/", ""), 137 | } 138 | 139 | # 140 | # -- Options for intersphinx ------------------------------------------------- 141 | # 142 | 143 | intersphinx_mapping = { 144 | "python": ("https://docs.python.org/3", None), 145 | "sphinx": ("https://www.sphinx-doc.org/en/master", None), 146 | } 147 | 148 | # 149 | # -- Options for TODOs ------------------------------------------------------- 150 | # 151 | 152 | todo_include_todos = True 153 | 154 | # -- Options for HTML output ------------------------------------------------- 155 | 156 | # html_favicon = None 157 | 158 | html_theme = "furo" 159 | html_title = project 160 | 161 | html_theme_options = {} 162 | 163 | # Add any paths that contain custom static files (such as style sheets) here, 164 | # relative to this directory. They are copied after the builtin static files, 165 | # so a file named "default.css" will overwrite the builtin "default.css". 166 | html_static_path = ["_static"] 167 | 168 | # If false, no module index is generated. 169 | html_domain_indices = True 170 | 171 | # If false, no index is generated. 172 | html_use_index = True 173 | 174 | # If true, the index is split into individual pages for each letter. 175 | html_split_index = False 176 | 177 | # If true, links to the reST sources are added to the pages. 178 | html_show_sourcelink = False 179 | 180 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 181 | html_show_sphinx = True 182 | 183 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 184 | html_show_copyright = True 185 | 186 | # If true, an OpenSearch description file will be output, and all pages will 187 | # contain a tag referring to it. The value of this option must be the 188 | # base URL from which the finished HTML is served. 189 | # html_use_openserver = '' 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = "django-environ-doc" 193 | 194 | # 195 | # -- Options for manual page output ------------------------------------------ 196 | # 197 | 198 | # One entry per manual page. List of tuples 199 | # (source start file, name, description, authors, manual section). 200 | man_pages = [ 201 | ("index", project, "django-environ Documentation", [author], 1) 202 | ] 203 | 204 | # 205 | # -- Options for Texinfo output ---------------------------------------------- 206 | # 207 | 208 | # Grouping the document tree into Texinfo files. List of tuples 209 | # (source start file, target name, title, author, 210 | # dir menu entry, description, category) 211 | texinfo_documents = [ 212 | ( 213 | "index", 214 | project, 215 | "django-environ Documentation", 216 | author, 217 | project, 218 | "Configure Django made easy.", 219 | "Miscellaneous", 220 | ) 221 | ] 222 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/deprecations.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Deprecations 3 | ============ 4 | 5 | Features deprecated in 0.10.0 6 | ============================= 7 | 8 | Python 9 | ------ 10 | 11 | * Support of Python < 3.6 is deprecated and will be removed 12 | in next major version. 13 | 14 | 15 | Features deprecated in 0.9.0 16 | ============================ 17 | 18 | Methods 19 | ------- 20 | 21 | * The ``environ.Env.unicode`` method is deprecated as it was used 22 | for Python 2.x only. Use :meth:`.environ.Env.str` instead. 23 | -------------------------------------------------------------------------------- /docs/docutils.conf: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | [parsers] 10 | 11 | [restructuredtext parser] 12 | smart_quotes=yes 13 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | === 2 | FAQ 3 | === 4 | 5 | 6 | #. **Can django-environ determine the location of .env file automatically?** 7 | 8 | django-environ will try to get and read ``.env`` file from the project 9 | root if you haven't specified the path for it when call :meth:`.environ.Env.read_env`. 10 | However, this is not the recommended way. When it is possible always specify 11 | the path tho ``.env`` file. Alternatively, you can use a trick with a 12 | environment variable pointing to the actual location of ``.env`` file. 13 | For details see ":ref:`multiple-env-files-label`". 14 | 15 | #. **What (where) is the root part of the project, is it part of the project where are settings?** 16 | 17 | Where your ``manage.py`` file is (that is your project root directory). 18 | 19 | #. **What kind of file should .env be?** 20 | 21 | ``.env`` is a plain text file. 22 | 23 | #. **Should name of the file be simply .env (or something.env)?** 24 | 25 | Just ``.env``. However, this is not a strict rule, but just a common 26 | practice. Formally, you can use any filename. 27 | 28 | #. **Is .env file going to be imported in settings file?** 29 | 30 | No need to import, django-environ automatically picks variables 31 | from there. 32 | 33 | #. **Should I commit my .env file?** 34 | 35 | Credentials should only be accessible on the machines that need access to them. 36 | Never commit sensitive information to a repository that is not needed by every 37 | development machine and server. 38 | 39 | #. **Why is it not overriding existing environment variables?** 40 | 41 | By default, django-environ won't overwrite existing environment variables as 42 | it assumes the deployment environment has more knowledge about configuration 43 | than the application does. To overwrite existing environment variables you can 44 | pass ``overwrite=True`` to :meth:`.environ.Env.read_env`. For more see 45 | ":ref:`overwriting-existing-env`" 46 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Welcome to django-environ documentation 3 | ======================================= 4 | 5 | Release v\ |release| (`What's new? `). 6 | 7 | .. include:: ../README.rst 8 | :start-after: -teaser-begin- 9 | :end-before: -teaser-end- 10 | 11 | Overview 12 | ======== 13 | 14 | .. include:: ../README.rst 15 | :start-after: -overview- 16 | :end-before: -project-information- 17 | 18 | ---- 19 | 20 | Full Table of Contents 21 | ====================== 22 | 23 | The User Guide 24 | -------------- 25 | 26 | This part of the documentation, which is mostly prose, begins with some 27 | background information about django-environ, then focuses on step-by-step 28 | instructions for getting the most out of django-environ. 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | install 34 | quickstart 35 | 36 | 37 | The Community Guide 38 | ------------------- 39 | 40 | This part of the documentation, which is mostly prose, details the 41 | django-environ ecosystem and community. 42 | 43 | .. toctree:: 44 | :maxdepth: 2 45 | 46 | faq 47 | types 48 | tips 49 | 50 | .. toctree:: 51 | :maxdepth: 1 52 | 53 | deprecations 54 | changelog 55 | 56 | 57 | The API Documentation / Guide 58 | ----------------------------- 59 | 60 | If you are looking for information on a specific function, class, or method, 61 | this part of the documentation is for you. 62 | 63 | .. toctree:: 64 | :maxdepth: 2 65 | 66 | api 67 | 68 | 69 | The Contributor Guide 70 | --------------------- 71 | 72 | If you want to contribute to the project, this part of the documentation is for 73 | you. 74 | 75 | .. toctree:: 76 | :maxdepth: 3 77 | 78 | contributing 79 | backers 80 | license 81 | 82 | .. include:: ../README.rst 83 | :start-after: -support- 84 | 85 | .. include:: ../README.rst 86 | :start-after: -project-information- 87 | :end-before: -support- 88 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | 6 | Requirements 7 | ============ 8 | 9 | * `Django `_ >= 2.2 10 | * `Python `_ >= 3.9 11 | 12 | Installing django-environ 13 | ========================= 14 | 15 | django-environ is a Python-only package `hosted_on_pypi`_. 16 | The recommended installation method is `pip`_-installing into a 17 | :mod:`virtualenv `: 18 | 19 | .. code-block:: console 20 | 21 | $ python -m pip install django-environ 22 | 23 | .. note:: 24 | 25 | After installing django-environ, no need to add it to ``INSTALLED_APPS``. 26 | 27 | 28 | .. _hosted_on_pypi: https://pypi.org/project/django-environ/ 29 | .. _pip: https://pip.pypa.io/en/stable/ 30 | 31 | 32 | Unstable version 33 | ================ 34 | 35 | The master of all the material is the Git repository at https://github.com/joke2k/django-environ. 36 | So, you can also install the latest unreleased development version directly from the 37 | ``develop`` branch on GitHub. It is a work-in-progress of a future stable release so the 38 | experience might be not as smooth: 39 | 40 | .. code-block:: console 41 | 42 | $ pip install -e git://github.com/joke2k/django-environ.git#egg=django-environ 43 | # OR 44 | $ pip install --upgrade https://github.com/joke2k/django-environ.git/archive/develop.tar.gz 45 | 46 | This command will download the latest version of django-environ and install 47 | it to your system. 48 | 49 | .. note:: 50 | 51 | The ``develop`` branch will always contain the latest unstable version, so the experience 52 | might be not as smooth. If you wish to check older versions or formal, tagged release, 53 | please switch to the relevant `tag `_. 54 | 55 | More information about ``pip`` and PyPI can be found here: 56 | 57 | * `Install pip `_ 58 | * `Python Packaging User Guide `_ 59 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | License and Credits 3 | =================== 4 | 5 | django-environ is open source software licensed under the 6 | `MIT / X11 License `_. 7 | The full license text can be also found in the `source code repository `_. 8 | 9 | .. include:: ../AUTHORS.rst 10 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Quick Start 3 | =========== 4 | 5 | Usage 6 | ===== 7 | 8 | Create a ``.env`` file in project root directory. The file format can be understood 9 | from the example below: 10 | 11 | .. code-block:: shell 12 | 13 | DEBUG=on 14 | SECRET_KEY=your-secret-key 15 | DATABASE_URL=psql://user:un-githubbedpassword@127.0.0.1:8458/database 16 | SQLITE_URL=sqlite:///my-local-sqlite.db 17 | CACHE_URL=memcache://127.0.0.1:11211,127.0.0.1:11212,127.0.0.1:11213 18 | REDIS_URL=rediscache://127.0.0.1:6379/1?client_class=django_redis.client.DefaultClient&password=ungithubbed-secret 19 | 20 | And use it with ``settings.py`` as follows: 21 | 22 | .. include:: ../README.rst 23 | :start-after: -code-begin- 24 | :end-before: -overview- 25 | 26 | The ``.env`` file should be specific to the environment and not checked into 27 | version control, it is best practice documenting the ``.env`` file with an example. 28 | For example, you can also add ``.env.dist`` with a template of your variables to 29 | the project repo. This file should describe the mandatory variables for the 30 | Django application, and it can be committed to version control. This provides a 31 | useful reference and speeds up the on-boarding process for new team members, since 32 | the time to dig through the codebase to find out what has to be set up is reduced. 33 | 34 | A good ``.env.dist`` could look like this: 35 | 36 | .. code-block:: shell 37 | 38 | # SECURITY WARNING: don't run with the debug turned on in production! 39 | DEBUG=True 40 | 41 | # Should robots.txt allow everything to be crawled? 42 | ALLOW_ROBOTS=False 43 | 44 | # SECURITY WARNING: keep the secret key used in production secret! 45 | SECRET_KEY=secret 46 | 47 | # A list of all the people who get code error notifications. 48 | ADMINS="John Doe , Mary " 49 | 50 | # A list of all the people who should get broken link notifications. 51 | MANAGERS="Blake , Alice Judge " 52 | 53 | # By default, Django will send system email from root@localhost. 54 | # However, some mail providers reject all email from this address. 55 | SERVER_EMAIL=webmaster@example.com 56 | -------------------------------------------------------------------------------- /docs/tips.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | Tips 3 | ==== 4 | 5 | Handling Inline Comments in .env Files 6 | ====================================== 7 | 8 | ``django-environ`` provides an optional feature to parse inline comments in ``.env`` 9 | files. This is controlled by the ``parse_comments`` parameter in the ``read_env`` 10 | method. 11 | 12 | Modes 13 | ----- 14 | 15 | - **Enabled (``parse_comments=True``)**: Inline comments starting with ``#`` will be ignored. 16 | - **Disabled (``parse_comments=False``)**: The entire line, including comments, will be read as the value. 17 | - **Default**: The behavior is the same as when ``parse_comments=False``. 18 | 19 | Side Effects 20 | ------------ 21 | 22 | While this feature can be useful for adding context to your ``.env`` files, 23 | it can introduce unexpected behavior. For example, if your value includes 24 | a ``#`` symbol, it will be truncated when ``parse_comments=True``. 25 | 26 | Why Disabled by Default? 27 | ------------------------ 28 | 29 | In line with the project's philosophy of being explicit and avoiding unexpected behavior, 30 | this feature is disabled by default. If you understand the implications and find the feature 31 | useful, you can enable it explicitly. 32 | 33 | Example 34 | ------- 35 | 36 | Here is an example demonstrating the different modes of handling inline comments. 37 | 38 | **.env file contents**: 39 | 40 | .. code-block:: shell 41 | 42 | # .env file contents 43 | BOOL_TRUE_WITH_COMMENT=True # This is a comment 44 | STR_WITH_HASH=foo#bar # This is also a comment 45 | 46 | **Python code**: 47 | 48 | .. code-block:: python 49 | 50 | import environ 51 | 52 | # Using parse_comments=True 53 | env = environ.Env() 54 | env.read_env(parse_comments=True) 55 | print(env('BOOL_TRUE_WITH_COMMENT')) # Output: True 56 | print(env('STR_WITH_HASH')) # Output: foo 57 | 58 | # Using parse_comments=False 59 | env = environ.Env() 60 | env.read_env(parse_comments=False) 61 | print(env('BOOL_TRUE_WITH_COMMENT')) # Output: True # This is a comment 62 | print(env('STR_WITH_HASH')) # Output: foo#bar # This is also a comment 63 | 64 | # Using default behavior 65 | env = environ.Env() 66 | env.read_env() 67 | print(env('BOOL_TRUE_WITH_COMMENT')) # Output: True # This is a comment 68 | print(env('STR_WITH_HASH')) # Output: foo#bar # This is also a comment 69 | 70 | 71 | Docker-style file based variables 72 | ================================= 73 | 74 | Docker (swarm) and Kubernetes are two widely used platforms that store their 75 | secrets in tmpfs inside containers as individual files, providing a secure way 76 | to be able to share configuration data between containers. 77 | 78 | Use :class:`.environ.FileAwareEnv` rather than :class:`.environ.Env` to first look for 79 | environment variables with ``_FILE`` appended. If found, their contents will be 80 | read from the file system and used instead. 81 | 82 | For example, given an app with the following in its settings module: 83 | 84 | .. code-block:: python 85 | 86 | import environ 87 | 88 | env = environ.FileAwareEnv() 89 | SECRET_KEY = env("SECRET_KEY") 90 | 91 | the example ``docker-compose.yml`` for would contain: 92 | 93 | .. code-block:: yaml 94 | 95 | secrets: 96 | secret_key: 97 | external: true 98 | 99 | services: 100 | app: 101 | secrets: 102 | - secret_key 103 | environment: 104 | - SECRET_KEY_FILE=/run/secrets/secret_key 105 | 106 | 107 | Using unsafe characters in URLs 108 | =============================== 109 | 110 | In order to use unsafe characters you have to encode with :py:func:`urllib.parse.quote` 111 | before you set into ``.env`` file. Encode only the value (i.e. the password) not the whole url. 112 | 113 | .. code-block:: shell 114 | 115 | DATABASE_URL=mysql://user:%23password@127.0.0.1:3306/dbname 116 | 117 | See https://perishablepress.com/stop-using-unsafe-characters-in-urls/ for reference. 118 | 119 | 120 | Smart Casting 121 | ============= 122 | 123 | django-environ has a "Smart-casting" enabled by default, if you don't provide a ``cast`` type, it will be detected from ``default`` type. 124 | This could raise side effects (see `#192 `_). 125 | To disable it use ``env.smart_cast = False``. 126 | 127 | .. note:: 128 | 129 | The next major release will disable it by default. 130 | 131 | 132 | Multiple redis cache locations 133 | ============================== 134 | 135 | For redis cache, multiple master/slave or shard locations can be configured as follows: 136 | 137 | .. code-block:: shell 138 | 139 | CACHE_URL='rediscache://master:6379,slave1:6379,slave2:6379/1' 140 | 141 | 142 | Email settings 143 | ============== 144 | 145 | In order to set email configuration for Django you can use this code: 146 | 147 | .. code-block:: python 148 | 149 | # The email() method is an alias for email_url(). 150 | EMAIL_CONFIG = env.email( 151 | 'EMAIL_URL', 152 | default='smtp://user:password@localhost:25' 153 | ) 154 | 155 | vars().update(EMAIL_CONFIG) 156 | 157 | 158 | SQLite urls 159 | =========== 160 | 161 | SQLite connects to file based databases. The same URL format is used, omitting the hostname, 162 | and using the "file" portion as the filename of the database. 163 | This has the effect of four slashes being present for an absolute 164 | 165 | file path: ``sqlite:////full/path/to/your/database/file.sqlite``. 166 | 167 | 168 | Nested lists 169 | ============ 170 | 171 | Some settings such as Django's ``ADMINS`` make use of nested lists. 172 | You can use something like this to handle similar cases. 173 | 174 | .. code-block:: python 175 | 176 | # DJANGO_ADMINS=Blake:blake@cyb.org,Alice:alice@cyb.org 177 | ADMINS = [x.split(':') for x in env.list('DJANGO_ADMINS')] 178 | 179 | # or use more specific function 180 | 181 | from email.utils import getaddresses 182 | 183 | # DJANGO_ADMINS=Alice Judge ,blake@cyb.org 184 | ADMINS = getaddresses([env('DJANGO_ADMINS')]) 185 | 186 | # another option is to use parseaddr from email.utils 187 | 188 | # DJANGO_ADMINS="Blake , Alice Judge " 189 | from email.utils import parseaddr 190 | 191 | ADMINS = tuple(parseaddr(email) for email in env.list('DJANGO_ADMINS')) 192 | 193 | 194 | .. _complex_dict_format: 195 | 196 | Complex dict format 197 | =================== 198 | 199 | Sometimes we need to get a bit more complex dict type than usual. For example, 200 | consider Djangosaml2's ``SAML_ATTRIBUTE_MAPPING``: 201 | 202 | .. code-block:: python 203 | 204 | SAML_ATTRIBUTE_MAPPING = { 205 | 'uid': ('username', ), 206 | 'mail': ('email', ), 207 | 'cn': ('first_name', ), 208 | 'sn': ('last_name', ), 209 | } 210 | 211 | A dict of this format can be obtained as shown below: 212 | 213 | **.env file**: 214 | 215 | .. code-block:: shell 216 | 217 | # .env file contents 218 | SAML_ATTRIBUTE_MAPPING="uid=username;mail=email;cn=first_name;sn=last_name;" 219 | 220 | **settings.py file**: 221 | 222 | .. code-block:: python 223 | 224 | # settings.py file contents 225 | import environ 226 | 227 | 228 | env = environ.Env() 229 | 230 | # {'uid': ('username',), 'mail': ('email',), 'cn': ('first_name',), 'sn': ('last_name',)} 231 | SAML_ATTRIBUTE_MAPPING = env.dict( 232 | 'SAML_ATTRIBUTE_MAPPING', 233 | cast={'value': tuple}, 234 | default={} 235 | ) 236 | 237 | 238 | Multiline value 239 | =============== 240 | 241 | To get multiline value pass ``multiline=True`` to ```str()```. 242 | 243 | .. note:: 244 | 245 | You shouldn't escape newline/tab characters yourself if you want to preserve 246 | the formatting. 247 | 248 | The following example demonstrates the above: 249 | 250 | **.env file**: 251 | 252 | .. code-block:: shell 253 | 254 | # .env file contents 255 | UNQUOTED_CERT=---BEGIN---\r\n---END--- 256 | QUOTED_CERT="---BEGIN---\r\n---END---" 257 | ESCAPED_CERT=---BEGIN---\\n---END--- 258 | 259 | **settings.py file**: 260 | 261 | .. code-block:: python 262 | 263 | # settings.py file contents 264 | import environ 265 | 266 | 267 | env = environ.Env() 268 | 269 | print(env.str('UNQUOTED_CERT', multiline=True)) 270 | # ---BEGIN--- 271 | # ---END--- 272 | 273 | print(env.str('UNQUOTED_CERT', multiline=False)) 274 | # ---BEGIN---\r\n---END--- 275 | 276 | print(env.str('QUOTED_CERT', multiline=True)) 277 | # ---BEGIN--- 278 | # ---END--- 279 | 280 | print(env.str('QUOTED_CERT', multiline=False)) 281 | # ---BEGIN---\r\n---END--- 282 | 283 | print(env.str('ESCAPED_CERT', multiline=True)) 284 | # ---BEGIN---\ 285 | # ---END--- 286 | 287 | print(env.str('ESCAPED_CERT', multiline=False)) 288 | # ---BEGIN---\\n---END--- 289 | 290 | Proxy value 291 | =========== 292 | 293 | Values that being with a ``$`` may be interpolated. Pass ``interpolate=True`` to 294 | ``environ.Env()`` to enable this feature: 295 | 296 | .. code-block:: python 297 | 298 | import environ 299 | 300 | env = environ.Env(interpolate=True) 301 | 302 | # BAR=FOO 303 | # PROXY=$BAR 304 | >>> print(env.str('PROXY')) 305 | FOO 306 | 307 | 308 | Escape Proxy 309 | ============ 310 | 311 | If you're having trouble with values starting with dollar sign ($) without the intention of proxying the value to 312 | another, You should enable the ``escape_proxy`` and prepend a backslash to it. 313 | 314 | .. code-block:: python 315 | 316 | import environ 317 | 318 | env = environ.Env() 319 | env.escape_proxy = True 320 | 321 | # ESCAPED_VAR=\$baz 322 | env.str('ESCAPED_VAR') # $baz 323 | 324 | 325 | Reading env files 326 | ================= 327 | 328 | .. _multiple-env-files-label: 329 | 330 | Multiple env files 331 | ------------------ 332 | 333 | There is an ability point to the .env file location using an environment 334 | variable. This feature may be convenient in a production systems with a 335 | different .env file location. 336 | 337 | The following example demonstrates the above: 338 | 339 | .. code-block:: shell 340 | 341 | # /etc/environment file contents 342 | DEBUG=False 343 | 344 | .. code-block:: shell 345 | 346 | # .env file contents 347 | DEBUG=True 348 | 349 | .. code-block:: python 350 | 351 | env = environ.Env() 352 | env.read_env(env.str('ENV_PATH', '.env')) 353 | 354 | 355 | Now ``ENV_PATH=/etc/environment ./manage.py runserver`` uses ``/etc/environment`` 356 | while ``./manage.py runserver`` uses ``.env``. 357 | 358 | 359 | Using Path objects when reading env 360 | ----------------------------------- 361 | 362 | It is possible to use of :py:class:`pathlib.Path` objects when reading environment 363 | file from the filesystem: 364 | 365 | .. code-block:: python 366 | 367 | import os 368 | import pathlib 369 | 370 | import environ 371 | 372 | 373 | # Build paths inside the project like this: BASE_DIR('subdir'). 374 | BASE_DIR = environ.Path(__file__) - 3 375 | 376 | env = environ.Env() 377 | 378 | # The four lines below do the same: 379 | env.read_env(BASE_DIR('.env')) 380 | env.read_env(os.path.join(BASE_DIR, '.env')) 381 | env.read_env(pathlib.Path(str(BASE_DIR)).joinpath('.env')) 382 | env.read_env(pathlib.Path(str(BASE_DIR)) / '.env') 383 | 384 | 385 | .. _overwriting-existing-env: 386 | 387 | Overwriting existing environment values from env files 388 | ------------------------------------------------------ 389 | 390 | If you want variables set within your env files to take higher precedence than 391 | an existing set environment variable, use the ``overwrite=True`` argument of 392 | :meth:`.environ.Env.read_env`. For example: 393 | 394 | .. code-block:: python 395 | 396 | env = environ.Env() 397 | env.read_env(BASE_DIR('.env'), overwrite=True) 398 | 399 | 400 | Handling prefixes 401 | ================= 402 | 403 | Sometimes it is desirable to be able to prefix all environment variables. For 404 | example, if you are using Django, you may want to prefix all environment 405 | variables with ``DJANGO_``. This can be done by setting the ``prefix`` 406 | to desired prefix. For example: 407 | 408 | **.env file**: 409 | 410 | .. code-block:: shell 411 | 412 | # .env file contents 413 | DJANGO_TEST="foo" 414 | 415 | **settings.py file**: 416 | 417 | .. code-block:: python 418 | 419 | # settings.py file contents 420 | import environ 421 | 422 | 423 | env = environ.Env() 424 | env.prefix = 'DJANGO_' 425 | 426 | env.str('TEST') # foo 427 | -------------------------------------------------------------------------------- /docs/types.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Supported types 3 | =============== 4 | 5 | The following are all type-casting methods of :py:class:`.environ.Env`. 6 | 7 | * :py:meth:`~.environ.Env.str` 8 | * :py:meth:`~.environ.Env.bool` 9 | * :py:meth:`~.environ.Env.int` 10 | * :py:meth:`~.environ.Env.float` 11 | * :py:meth:`~.environ.Env.json` 12 | * :py:meth:`~.environ.Env.url` 13 | * :py:meth:`~.environ.Env.list`: (accepts values like ``(FOO=a,b,c)``) 14 | * :py:meth:`~.environ.Env.tuple`: (accepts values like ``(FOO=(a,b,c))``) 15 | * :py:meth:`~.environ.Env.path`: (accepts values like ``(environ.Path)``) 16 | * :py:meth:`~.environ.Env.dict`: (see below, ":ref:`environ-env-dict`" section) 17 | * :py:meth:`~.environ.Env.db_url` (see below, ":ref:`environ-env-db-url`" section) 18 | * :py:meth:`~.environ.Env.cache_url` (see below, ":ref:`environ-env-cache-url`" section) 19 | * :py:meth:`~.environ.Env.search_url` (see below, ":ref:`environ-env-search-url`" section) 20 | * :py:meth:`~.environ.Env.email_url` (see below, ":ref:`environ-env-email-url`" section) 21 | 22 | 23 | .. _environ-env-dict: 24 | 25 | ``environ.Env.dict`` 26 | ====================== 27 | 28 | :py:class:`.environ.Env` may parse complex variables like with the complex type-casting. 29 | For example: 30 | 31 | .. code-block:: python 32 | 33 | import environ 34 | 35 | 36 | env = environ.Env() 37 | 38 | # {'key': 'val', 'foo': 'bar'} 39 | env.parse_value('key=val,foo=bar', dict) 40 | 41 | # {'key': 'val', 'foo': 1.1, 'baz': True} 42 | env.parse_value( 43 | 'key=val;foo=1.1;baz=True', 44 | dict(value=str, cast=dict(foo=float,baz=bool)) 45 | ) 46 | 47 | For more detailed example see ":ref:`complex_dict_format`". 48 | 49 | 50 | .. _environ-env-db-url: 51 | 52 | ``environ.Env.db_url`` 53 | ====================== 54 | 55 | :py:meth:`~.environ.Env.db_url` supports the following URL schemas: 56 | 57 | .. glossary:: 58 | 59 | Amazon Redshift 60 | **Database Backend:** ``django_redshift_backend`` 61 | 62 | **URL schema:** ``redshift://`` 63 | 64 | LDAP 65 | **Database Backend:** ``ldapdb.backends.ldap`` 66 | 67 | **URL schema:** ``ldap://host:port/dn?attrs?scope?filter?exts`` 68 | 69 | MSSQL 70 | **Database Backend:** ``sql_server.pyodbc`` 71 | 72 | **URL schema:** ``mssql://user:password@host:port/dbname`` 73 | 74 | With MySQL you can use the following schemas: ``mysql``, ``mysql2``. 75 | 76 | MySQL (GIS) 77 | **Database Backend:** ``django.contrib.gis.db.backends.mysql`` 78 | 79 | **URL schema:** ``mysqlgis://user:password@host:port/dbname`` 80 | 81 | MySQL 82 | **Database Backend:** ``django.db.backends.mysql`` 83 | 84 | **URL schema:** ``mysql://user:password@host:port/dbname`` 85 | 86 | MySQL Connector Python from Oracle 87 | **Database Backend:** ``mysql.connector.django`` 88 | 89 | **URL schema:** ``mysql-connector://`` 90 | 91 | Oracle 92 | **Database Backend:** ``django.db.backends.oracle`` 93 | 94 | **URL schema:** ``oracle://user:password@host:port/dbname`` 95 | 96 | PostgreSQL 97 | **Database Backend:** ``django.db.backends.postgresql`` 98 | 99 | **URL schema:** ``postgres://user:password@host:port/dbname`` 100 | 101 | With PostgreSQL you can use the following schemas: ``postgres``, ``postgresql``, ``psql``, ``pgsql``, ``postgis``. 102 | You can also use UNIX domain sockets path instead of hostname. For example: ``postgres://path/dbname``. 103 | The ``django.db.backends.postgresql_psycopg2`` will be used if the Django version is less than ``2.0``. 104 | 105 | PostGIS 106 | **Database Backend:** ``django.contrib.gis.db.backends.postgis`` 107 | 108 | **URL schema:** ``postgis://user:password@host:port/dbname`` 109 | 110 | PyODBC 111 | **Database Backend:** ``sql_server.pyodbc`` 112 | 113 | **URL schema:** ``pyodbc://`` 114 | 115 | SQLite 116 | **Database Backend:** ``django.db.backends.sqlite3`` 117 | 118 | **URL schema:** ``sqlite:////absolute/path/to/db/file`` 119 | 120 | SQLite connects to file based databases. URL schemas ``sqlite://`` or 121 | ``sqlite://:memory:`` means the database is in the memory (not a file on disk). 122 | 123 | SpatiaLite 124 | **Database Backend:** ``django.contrib.gis.db.backends.spatialite`` 125 | 126 | **URL schema:** ``spatialite:///PATH`` 127 | 128 | SQLite connects to file based databases. URL schemas ``sqlite://`` or 129 | ``sqlite://:memory:`` means the database is in the memory (not a file on disk). 130 | 131 | 132 | .. _environ-env-cache-url: 133 | 134 | ``environ.Env.cache_url`` 135 | ========================= 136 | 137 | :py:meth:`~.environ.Env.cache_url` supports the following URL schemas: 138 | 139 | * Database: ``dbcache://`` 140 | * Dummy: ``dummycache://`` 141 | * File: ``filecache://`` 142 | * Memory: ``locmemcache://`` 143 | * Memcached: 144 | 145 | * ``memcache://`` (uses ``python-memcached`` backend, deprecated in Django 3.2) 146 | * ``pymemcache://`` (uses ``pymemcache`` backend if Django >=3.2 and package is installed, otherwise will use ``pylibmc`` backend to keep config backwards compatibility) 147 | * ``pylibmc://`` 148 | 149 | * Redis: ``rediscache://``, ``redis://``, or ``rediss://`` 150 | 151 | 152 | .. _environ-env-search-url: 153 | 154 | ``environ.Env.search_url`` 155 | ========================== 156 | 157 | :py:meth:`~.environ.Env.search_url` supports the following URL schemas: 158 | 159 | * Elasticsearch: ``elasticsearch://`` (http) or ``elasticsearchs://`` (https) 160 | * Elasticsearch2: ``elasticsearch2://`` (http) or ``elasticsearch2s://`` (https) 161 | * Elasticsearch5: ``elasticsearch5://`` (http) or ``elasticsearch5s://`` (https) 162 | * Elasticsearch7: ``elasticsearch7://`` (http) or ``elasticsearch7s://`` (https) 163 | * Solr: ``solr://`` 164 | * Whoosh: ``whoosh://`` 165 | * Xapian: ``xapian://`` 166 | * Simple cache: ``simple://`` 167 | 168 | 169 | .. _environ-env-email-url: 170 | 171 | ``environ.Env.email_url`` 172 | ========================== 173 | 174 | :py:meth:`~.environ.Env.email_url` supports the following URL schemas: 175 | 176 | * SMTP: ``smtp://`` 177 | * SMTP+SSL: ``smtp+ssl://`` 178 | * SMTP+TLS: ``smtp+tls://`` 179 | * Console mail: ``consolemail://`` 180 | * File mail: ``filemail://`` 181 | * LocMem mail: ``memorymail://`` 182 | * Dummy mail: ``dummymail://`` 183 | -------------------------------------------------------------------------------- /environ/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | """The top-level module for django-environ package. 10 | 11 | This module tracks the version of the package as well as the base 12 | package info used by various functions within django-environ. 13 | 14 | Refer to the `documentation `_ 15 | for details on the use of this package. 16 | """ # noqa: E501 17 | 18 | from .environ import * 19 | 20 | 21 | __copyright__ = 'Copyright (C) 2013-2023 Daniele Faraglia' 22 | """The copyright notice of the package.""" 23 | 24 | __version__ = '0.12.0' 25 | """The version of the package.""" 26 | 27 | __license__ = 'MIT' 28 | """The license of the package.""" 29 | 30 | __author__ = 'Daniele Faraglia' 31 | """The author of the package.""" 32 | 33 | __author_email__ = 'daniele.faraglia@gmail.com' 34 | """The email of the author of the package.""" 35 | 36 | __maintainer__ = 'Serghei Iakovlev' 37 | """The maintainer of the package.""" 38 | 39 | __maintainer_email__ = 'oss@serghei.pl' 40 | """The email of the maintainer of the package.""" 41 | 42 | __url__ = 'https://django-environ.readthedocs.org' 43 | """The URL of the package.""" 44 | 45 | # pylint: disable=line-too-long 46 | __description__ = 'A package that allows you to utilize 12factor inspired environment variables to configure your Django application.' # noqa: E501 47 | """The description of the package.""" 48 | -------------------------------------------------------------------------------- /environ/compat.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | """This module handles import compatibility issues.""" 10 | 11 | from importlib.util import find_spec 12 | 13 | if find_spec('simplejson'): 14 | import simplejson as json 15 | else: 16 | import json 17 | 18 | if find_spec('django'): 19 | from django import VERSION as DJANGO_VERSION 20 | from django.core.exceptions import ImproperlyConfigured 21 | else: 22 | DJANGO_VERSION = None 23 | 24 | class ImproperlyConfigured(Exception): 25 | """Django is somehow improperly configured""" 26 | 27 | 28 | def choose_rediscache_driver(): 29 | """Backward compatibility for RedisCache driver.""" 30 | 31 | # django-redis library takes precedence 32 | if find_spec('django_redis'): 33 | return 'django_redis.cache.RedisCache' 34 | 35 | # use built-in support if Django 4+ 36 | if DJANGO_VERSION is not None and DJANGO_VERSION >= (4, 0): 37 | return 'django.core.cache.backends.redis.RedisCache' 38 | 39 | # back compatibility with redis_cache package 40 | return 'redis_cache.RedisCache' 41 | 42 | 43 | def choose_postgres_driver(): 44 | """Backward compatibility for postgresql driver.""" 45 | old_django = DJANGO_VERSION is not None and DJANGO_VERSION < (2, 0) 46 | if old_django: 47 | return 'django.db.backends.postgresql_psycopg2' 48 | return 'django.db.backends.postgresql' 49 | 50 | 51 | def choose_pymemcache_driver(): 52 | """Backward compatibility for pymemcache.""" 53 | old_django = DJANGO_VERSION is not None and DJANGO_VERSION < (3, 2) 54 | if old_django or not find_spec('pymemcache'): 55 | # The original backend choice for the 'pymemcache' scheme is 56 | # unfortunately 'pylibmc'. 57 | return 'django.core.cache.backends.memcached.PyLibMCCache' 58 | return 'django.core.cache.backends.memcached.PyMemcacheCache' 59 | 60 | 61 | REDIS_DRIVER = choose_rediscache_driver() 62 | """The name of the RedisCache driver.""" 63 | 64 | DJANGO_POSTGRES = choose_postgres_driver() 65 | """The name of the PostgreSQL driver.""" 66 | 67 | PYMEMCACHE_DRIVER = choose_pymemcache_driver() 68 | """The name of the Pymemcache driver.""" 69 | -------------------------------------------------------------------------------- /environ/environ.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | """ 10 | Django-environ allows you to utilize 12factor inspired environment 11 | variables to configure your Django application. 12 | """ 13 | 14 | import ast 15 | import itertools 16 | import logging 17 | import os 18 | import re 19 | import sys 20 | import warnings 21 | from urllib.parse import ( 22 | parse_qs, 23 | ParseResult, 24 | quote, 25 | unquote, 26 | unquote_plus, 27 | urlparse, 28 | urlunparse, 29 | ) 30 | 31 | from .compat import ( 32 | DJANGO_POSTGRES, 33 | ImproperlyConfigured, 34 | json, 35 | PYMEMCACHE_DRIVER, 36 | REDIS_DRIVER, 37 | ) 38 | from .fileaware_mapping import FileAwareMapping 39 | 40 | Openable = (str, os.PathLike) 41 | logger = logging.getLogger(__name__) 42 | 43 | 44 | def _cast(value): 45 | # Safely evaluate an expression node or a string containing a Python 46 | # literal or container display. 47 | # https://docs.python.org/3/library/ast.html#ast.literal_eval 48 | try: 49 | return ast.literal_eval(value) 50 | except (ValueError, SyntaxError): 51 | return value 52 | 53 | 54 | def _cast_int(v): 55 | """Return int if possible.""" 56 | return int(v) if hasattr(v, 'isdigit') and v.isdigit() else v 57 | 58 | 59 | def _cast_urlstr(v): 60 | return unquote(v) if isinstance(v, str) else v 61 | 62 | 63 | def _urlparse_quote(url): 64 | return urlparse(quote(url, safe=':/?&=@')) 65 | 66 | 67 | class NoValue: 68 | """Represent of no value object.""" 69 | 70 | def __repr__(self): 71 | return f'<{self.__class__.__name__}>' 72 | 73 | 74 | class Env: 75 | """Provide scheme-based lookups of environment variables so that each 76 | caller doesn't have to pass in ``cast`` and ``default`` parameters. 77 | 78 | Usage::: 79 | 80 | import environ 81 | import os 82 | 83 | env = environ.Env( 84 | # set casting, default value 85 | MAIL_ENABLED=(bool, False), 86 | SMTP_LOGIN=(str, 'DEFAULT') 87 | ) 88 | 89 | # Set the project base directory 90 | BASE_DIR = os.path.dirname( 91 | os.path.dirname(os.path.abspath(__file__)) 92 | ) 93 | 94 | # Take environment variables from .env file 95 | environ.Env.read_env(os.path.join(BASE_DIR, '.env')) 96 | 97 | # False if not in os.environ due to casting above 98 | MAIL_ENABLED = env('MAIL_ENABLED') 99 | 100 | # 'DEFAULT' if not in os.environ due to casting above 101 | SMTP_LOGIN = env('SMTP_LOGIN') 102 | """ 103 | 104 | ENVIRON = os.environ 105 | NOTSET = NoValue() 106 | BOOLEAN_TRUE_STRINGS = ('true', 'on', 'ok', 'y', 'yes', '1') 107 | URL_CLASS = ParseResult 108 | 109 | POSTGRES_FAMILY = ['postgres', 'postgresql', 'psql', 'pgsql', 'postgis'] 110 | 111 | DEFAULT_DATABASE_ENV = 'DATABASE_URL' 112 | DB_SCHEMES = { 113 | 'postgres': DJANGO_POSTGRES, 114 | 'postgresql': DJANGO_POSTGRES, 115 | 'psql': DJANGO_POSTGRES, 116 | 'pgsql': DJANGO_POSTGRES, 117 | 'postgis': 'django.contrib.gis.db.backends.postgis', 118 | 'cockroachdb': 'django_cockroachdb', 119 | 'mysql': 'django.db.backends.mysql', 120 | 'mysql2': 'django.db.backends.mysql', 121 | 'mysql-connector': 'mysql.connector.django', 122 | 'mysqlgis': 'django.contrib.gis.db.backends.mysql', 123 | 'mssql': 'mssql', 124 | 'oracle': 'django.db.backends.oracle', 125 | 'pyodbc': 'sql_server.pyodbc', 126 | 'redshift': 'django_redshift_backend', 127 | 'spatialite': 'django.contrib.gis.db.backends.spatialite', 128 | 'sqlite': 'django.db.backends.sqlite3', 129 | 'ldap': 'ldapdb.backends.ldap', 130 | } 131 | _DB_BASE_OPTIONS = [ 132 | 'CONN_MAX_AGE', 133 | 'ATOMIC_REQUESTS', 134 | 'AUTOCOMMIT', 135 | 'DISABLE_SERVER_SIDE_CURSORS', 136 | 'CONN_HEALTH_CHECKS', 137 | ] 138 | 139 | DEFAULT_CACHE_ENV = 'CACHE_URL' 140 | CACHE_SCHEMES = { 141 | 'dbcache': 'django.core.cache.backends.db.DatabaseCache', 142 | 'dummycache': 'django.core.cache.backends.dummy.DummyCache', 143 | 'filecache': 'django.core.cache.backends.filebased.FileBasedCache', 144 | 'locmemcache': 'django.core.cache.backends.locmem.LocMemCache', 145 | 'memcache': 'django.core.cache.backends.memcached.MemcachedCache', 146 | 'pymemcache': PYMEMCACHE_DRIVER, 147 | 'pylibmc': 'django.core.cache.backends.memcached.PyLibMCCache', 148 | 'rediscache': REDIS_DRIVER, 149 | 'redis': REDIS_DRIVER, 150 | 'rediss': REDIS_DRIVER, 151 | } 152 | _CACHE_BASE_OPTIONS = [ 153 | 'TIMEOUT', 154 | 'KEY_PREFIX', 155 | 'VERSION', 156 | 'KEY_FUNCTION', 157 | 'BINARY', 158 | ] 159 | 160 | DEFAULT_EMAIL_ENV = 'EMAIL_URL' 161 | EMAIL_SCHEMES = { 162 | 'smtp': 'django.core.mail.backends.smtp.EmailBackend', 163 | 'smtps': 'django.core.mail.backends.smtp.EmailBackend', 164 | 'smtp+tls': 'django.core.mail.backends.smtp.EmailBackend', 165 | 'smtp+ssl': 'django.core.mail.backends.smtp.EmailBackend', 166 | 'consolemail': 'django.core.mail.backends.console.EmailBackend', 167 | 'filemail': 'django.core.mail.backends.filebased.EmailBackend', 168 | 'memorymail': 'django.core.mail.backends.locmem.EmailBackend', 169 | 'dummymail': 'django.core.mail.backends.dummy.EmailBackend' 170 | } 171 | _EMAIL_BASE_OPTIONS = ['EMAIL_USE_TLS', 'EMAIL_USE_SSL'] 172 | 173 | DEFAULT_SEARCH_ENV = 'SEARCH_URL' 174 | SEARCH_SCHEMES = { 175 | "elasticsearch": "haystack.backends.elasticsearch_backend." 176 | "ElasticsearchSearchEngine", 177 | "elasticsearch2": "haystack.backends.elasticsearch2_backend." 178 | "Elasticsearch2SearchEngine", 179 | "elasticsearch5": "haystack.backends.elasticsearch5_backend." 180 | "Elasticsearch5SearchEngine", 181 | "elasticsearch7": "haystack.backends.elasticsearch7_backend." 182 | "Elasticsearch7SearchEngine", 183 | "solr": "haystack.backends.solr_backend.SolrEngine", 184 | "whoosh": "haystack.backends.whoosh_backend.WhooshEngine", 185 | "xapian": "haystack.backends.xapian_backend.XapianEngine", 186 | "simple": "haystack.backends.simple_backend.SimpleEngine", 187 | } 188 | ELASTICSEARCH_FAMILY = [scheme + s for scheme in SEARCH_SCHEMES 189 | if scheme.startswith("elasticsearch") 190 | for s in ('', 's')] 191 | CLOUDSQL = 'cloudsql' 192 | 193 | DEFAULT_CHANNELS_ENV = "CHANNELS_URL" 194 | CHANNELS_SCHEMES = { 195 | "inmemory": "channels.layers.InMemoryChannelLayer", 196 | "redis": "channels_redis.core.RedisChannelLayer", 197 | "redis+pubsub": "channels_redis.pubsub.RedisPubSubChannelLayer" 198 | } 199 | 200 | def __init__(self, **scheme): 201 | self.smart_cast = True 202 | self.escape_proxy = False 203 | self.prefix = "" 204 | self.scheme = scheme 205 | 206 | def __call__(self, var, cast=None, default=NOTSET, parse_default=False): 207 | return self.get_value( 208 | var, 209 | cast=cast, 210 | default=default, 211 | parse_default=parse_default 212 | ) 213 | 214 | def __contains__(self, var): 215 | return var in self.ENVIRON 216 | 217 | def str(self, var, default=NOTSET, multiline=False): 218 | """ 219 | :rtype: str 220 | """ 221 | value = self.get_value(var, cast=str, default=default) 222 | if multiline: 223 | return re.sub(r'(\\r)?\\n', r'\n', value) 224 | return value 225 | 226 | def bytes(self, var, default=NOTSET, encoding='utf8'): 227 | """ 228 | :rtype: bytes 229 | """ 230 | value = self.get_value(var, cast=str, default=default) 231 | if hasattr(value, 'encode'): 232 | return value.encode(encoding) 233 | return value 234 | 235 | def bool(self, var, default=NOTSET): 236 | """ 237 | :rtype: bool 238 | """ 239 | return self.get_value(var, cast=bool, default=default) 240 | 241 | def int(self, var, default=NOTSET): 242 | """ 243 | :rtype: int 244 | """ 245 | return self.get_value(var, cast=int, default=default) 246 | 247 | def float(self, var, default=NOTSET): 248 | """ 249 | :rtype: float 250 | """ 251 | return self.get_value(var, cast=float, default=default) 252 | 253 | def json(self, var, default=NOTSET): 254 | """ 255 | :returns: Json parsed 256 | """ 257 | return self.get_value(var, cast=json.loads, default=default) 258 | 259 | def list(self, var, cast=None, default=NOTSET): 260 | """ 261 | :rtype: list 262 | """ 263 | return self.get_value( 264 | var, 265 | cast=list if not cast else [cast], 266 | default=default 267 | ) 268 | 269 | def tuple(self, var, cast=None, default=NOTSET): 270 | """ 271 | :rtype: tuple 272 | """ 273 | return self.get_value( 274 | var, 275 | cast=tuple if not cast else (cast,), 276 | default=default 277 | ) 278 | 279 | def dict(self, var, cast=dict, default=NOTSET): 280 | """ 281 | :rtype: dict 282 | """ 283 | return self.get_value(var, cast=cast, default=default) 284 | 285 | def url(self, var, default=NOTSET): 286 | """ 287 | :rtype: urllib.parse.ParseResult 288 | """ 289 | return self.get_value( 290 | var, 291 | cast=urlparse, 292 | default=default, 293 | parse_default=True 294 | ) 295 | 296 | def db_url(self, var=DEFAULT_DATABASE_ENV, default=NOTSET, engine=None): 297 | """Returns a config dictionary, defaulting to DATABASE_URL. 298 | 299 | The db method is an alias for db_url. 300 | 301 | :rtype: dict 302 | """ 303 | return self.db_url_config( 304 | self.get_value(var, default=default), 305 | engine=engine 306 | ) 307 | 308 | db = db_url 309 | 310 | def cache_url(self, var=DEFAULT_CACHE_ENV, default=NOTSET, backend=None): 311 | """Returns a config dictionary, defaulting to CACHE_URL. 312 | 313 | The cache method is an alias for cache_url. 314 | 315 | :rtype: dict 316 | """ 317 | return self.cache_url_config( 318 | self.url(var, default=default), 319 | backend=backend 320 | ) 321 | 322 | cache = cache_url 323 | 324 | def email_url(self, var=DEFAULT_EMAIL_ENV, default=NOTSET, backend=None): 325 | """Returns a config dictionary, defaulting to EMAIL_URL. 326 | 327 | The email method is an alias for email_url. 328 | 329 | :rtype: dict 330 | """ 331 | return self.email_url_config( 332 | self.url(var, default=default), 333 | backend=backend 334 | ) 335 | 336 | email = email_url 337 | 338 | def search_url(self, var=DEFAULT_SEARCH_ENV, default=NOTSET, engine=None): 339 | """Returns a config dictionary, defaulting to SEARCH_URL. 340 | 341 | :rtype: dict 342 | """ 343 | return self.search_url_config( 344 | self.url(var, default=default), 345 | engine=engine 346 | ) 347 | 348 | def channels_url(self, var=DEFAULT_CHANNELS_ENV, default=NOTSET, 349 | backend=None): 350 | """Returns a config dictionary, defaulting to CHANNELS_URL. 351 | 352 | :rtype: dict 353 | """ 354 | return self.channels_url_config( 355 | self.url(var, default=default), 356 | backend=backend 357 | ) 358 | 359 | channels = channels_url 360 | 361 | def path(self, var, default=NOTSET, **kwargs): 362 | """ 363 | :rtype: Path 364 | """ 365 | return Path(self.get_value(var, default=default), **kwargs) 366 | 367 | def get_value(self, var, cast=None, default=NOTSET, parse_default=False): 368 | """Return value for given environment variable. 369 | 370 | :param str var: 371 | Name of variable. 372 | :param collections.abc.Callable or None cast: 373 | Type to cast return value as. 374 | :param default: 375 | If var not present in environ, return this instead. 376 | :param bool parse_default: 377 | Force to parse default. 378 | :returns: Value from environment or default (if set). 379 | :rtype: typing.IO[typing.Any] 380 | """ 381 | 382 | logger.debug( 383 | "get '%s' casted as '%s' with default '%s'", 384 | var, cast, default) 385 | 386 | var_name = f'{self.prefix}{var}' 387 | if var_name in self.scheme: 388 | var_info = self.scheme[var_name] 389 | 390 | try: 391 | has_default = len(var_info) == 2 392 | except TypeError: 393 | has_default = False 394 | 395 | if has_default: 396 | if not cast: 397 | cast = var_info[0] 398 | 399 | if default is self.NOTSET: 400 | try: 401 | default = var_info[1] 402 | except IndexError: 403 | pass 404 | else: 405 | if not cast: 406 | cast = var_info 407 | 408 | try: 409 | value = self.ENVIRON[var_name] 410 | except KeyError as exc: 411 | if default is self.NOTSET: 412 | error_msg = f'Set the {var_name} environment variable' 413 | raise ImproperlyConfigured(error_msg) from exc 414 | 415 | value = default 416 | 417 | # Resolve any proxied values 418 | prefix = b'$' if isinstance(value, bytes) else '$' 419 | escape = rb'\$' if isinstance(value, bytes) else r'\$' 420 | if hasattr(value, 'startswith') and value.startswith(prefix): 421 | value = value.lstrip(prefix) 422 | value = self.get_value(value, cast=cast, default=default) 423 | 424 | if self.escape_proxy and hasattr(value, 'replace'): 425 | value = value.replace(escape, prefix) 426 | 427 | # Smart casting 428 | if self.smart_cast: 429 | if cast is None and default is not None and \ 430 | not isinstance(default, NoValue): 431 | cast = type(default) 432 | 433 | value = None if default is None and value == '' else value 434 | 435 | if value != default or (parse_default and value is not None): 436 | value = self.parse_value(value, cast) 437 | 438 | return value 439 | 440 | @classmethod 441 | def parse_value(cls, value, cast): 442 | """Parse and cast provided value 443 | 444 | :param value: Stringed value. 445 | :param cast: Type to cast return value as. 446 | 447 | :returns: Casted value 448 | """ 449 | if cast is None: 450 | return value 451 | if cast is bool: 452 | try: 453 | value = int(value) != 0 454 | except ValueError: 455 | value = value.lower().strip() in cls.BOOLEAN_TRUE_STRINGS 456 | elif isinstance(cast, list): 457 | value = list(map(cast[0], [x for x in value.split(',') if x])) 458 | elif isinstance(cast, tuple): 459 | val = value.strip('(').strip(')').split(',') 460 | value = tuple(map(cast[0], [x for x in val if x])) 461 | elif isinstance(cast, dict): 462 | key_cast = cast.get('key', str) 463 | value_cast = cast.get('value', str) 464 | value_cast_by_key = cast.get('cast', {}) 465 | value = dict(map( 466 | lambda kv: ( 467 | key_cast(kv[0]), 468 | cls.parse_value( 469 | kv[1], 470 | value_cast_by_key.get(kv[0], value_cast) 471 | ) 472 | ), 473 | [val.split('=') for val in value.split(';') if val] 474 | )) 475 | elif cast is dict: 476 | value = dict([v.split('=', 1) for v in value.split(',') if v]) 477 | elif cast is list: 478 | value = [x for x in value.split(',') if x] 479 | elif cast is tuple: 480 | val = value.strip('(').strip(')').split(',') 481 | # pylint: disable=consider-using-generator 482 | value = tuple([x for x in val if x]) 483 | elif cast is float: 484 | # clean string 485 | float_str = re.sub(r'[^\d,.-]', '', value) 486 | # split for avoid thousand separator and different 487 | # locale comma/dot symbol 488 | parts = re.split(r'[,.]', float_str) 489 | if len(parts) == 1: 490 | float_str = parts[0] 491 | else: 492 | float_str = f"{''.join(parts[0:-1])}.{parts[-1]}" 493 | value = float(float_str) 494 | else: 495 | value = cast(value) 496 | return value 497 | 498 | @classmethod 499 | # pylint: disable=too-many-statements 500 | def db_url_config(cls, url, engine=None): 501 | # pylint: enable-msg=too-many-statements 502 | """Parse an arbitrary database URL. 503 | 504 | Supports the following URL schemas: 505 | 506 | * PostgreSQL: ``postgres[ql]?://`` or ``p[g]?sql://`` 507 | * PostGIS: ``postgis://`` 508 | * MySQL: ``mysql://`` or ``mysql2://`` 509 | * MySQL (GIS): ``mysqlgis://`` 510 | * MySQL Connector Python from Oracle: ``mysql-connector://`` 511 | * SQLite: ``sqlite://`` 512 | * SQLite with SpatiaLite for GeoDjango: ``spatialite://`` 513 | * Oracle: ``oracle://`` 514 | * Microsoft SQL Server: ``mssql://`` 515 | * PyODBC: ``pyodbc://`` 516 | * Amazon Redshift: ``redshift://`` 517 | * LDAP: ``ldap://`` 518 | 519 | :param urllib.parse.ParseResult or str url: 520 | Database URL to parse. 521 | :param str or None engine: 522 | If None, the database engine is evaluates from the ``url``. 523 | :return: Parsed database URL. 524 | :rtype: dict 525 | """ 526 | if not isinstance(url, cls.URL_CLASS): 527 | if url == 'sqlite://:memory:': 528 | # this is a special case, because if we pass this URL into 529 | # urlparse, urlparse will choke trying to interpret "memory" 530 | # as a port number 531 | return { 532 | 'ENGINE': cls.DB_SCHEMES['sqlite'], 533 | 'NAME': ':memory:' 534 | } 535 | # note: no other settings are required for sqlite 536 | try: 537 | url = urlparse(url) 538 | # handle Invalid IPv6 URL 539 | except ValueError: 540 | url = _urlparse_quote(url) 541 | 542 | config = {} 543 | 544 | # handle unexpected URL schemes with special characters 545 | if not url.path: 546 | url = _urlparse_quote(urlunparse(url)) 547 | # Remove query strings. 548 | path = url.path[1:] 549 | path = unquote_plus(path.split('?', 2)[0]) 550 | 551 | if url.scheme == 'sqlite': 552 | if path == '': 553 | # if we are using sqlite and we have no path, then assume we 554 | # want an in-memory database (this is the behaviour of 555 | # sqlalchemy) 556 | path = ':memory:' 557 | if url.netloc: 558 | warnings.warn( 559 | f'SQLite URL contains host component {url.netloc!r}, ' 560 | 'it will be ignored', 561 | stacklevel=3 562 | ) 563 | if url.scheme == 'ldap': 564 | path = f'{url.scheme}://{url.hostname}' 565 | if url.port: 566 | path += f':{url.port}' 567 | 568 | user_host = url.netloc.rsplit('@', 1) 569 | if url.scheme in cls.POSTGRES_FAMILY and ',' in user_host[-1]: 570 | # Parsing postgres cluster dsn 571 | hinfo = list( 572 | itertools.zip_longest( 573 | *( 574 | host.rsplit(':', 1) 575 | for host in user_host[-1].split(',') 576 | ) 577 | ) 578 | ) 579 | hostname = ','.join(hinfo[0]) 580 | port = ','.join(filter(None, hinfo[1])) if len(hinfo) == 2 else '' 581 | else: 582 | hostname = url.hostname 583 | port = url.port 584 | 585 | # Update with environment configuration. 586 | config.update({ 587 | 'NAME': path or '', 588 | 'USER': _cast_urlstr(url.username) or '', 589 | 'PASSWORD': _cast_urlstr(url.password) or '', 590 | 'HOST': hostname or '', 591 | 'PORT': _cast_int(port) or '', 592 | }) 593 | 594 | if ( 595 | url.scheme in cls.POSTGRES_FAMILY and path.startswith('/') 596 | or cls.CLOUDSQL in path and path.startswith('/') 597 | ): 598 | config['HOST'], config['NAME'] = path.rsplit('/', 1) 599 | 600 | if url.scheme == 'oracle' and path == '': 601 | config['NAME'] = config['HOST'] 602 | config['HOST'] = '' 603 | 604 | if url.scheme == 'oracle': 605 | # Django oracle/base.py strips port and fails on non-string value 606 | if not config['PORT']: 607 | del config['PORT'] 608 | else: 609 | config['PORT'] = str(config['PORT']) 610 | 611 | if url.query: 612 | config_options = {} 613 | for k, v in parse_qs(url.query).items(): 614 | if k.upper() in cls._DB_BASE_OPTIONS: 615 | config.update({k.upper(): _cast(v[0])}) 616 | else: 617 | config_options.update({k: _cast_int(v[0])}) 618 | config['OPTIONS'] = config_options 619 | 620 | if engine: 621 | config['ENGINE'] = engine 622 | else: 623 | config['ENGINE'] = url.scheme 624 | 625 | if config['ENGINE'] in cls.DB_SCHEMES: 626 | config['ENGINE'] = cls.DB_SCHEMES[config['ENGINE']] 627 | 628 | if not config.get('ENGINE', False): 629 | warnings.warn(f'Engine not recognized from url: {config}') 630 | return {} 631 | 632 | return config 633 | 634 | @classmethod 635 | def cache_url_config(cls, url, backend=None): 636 | """Parse an arbitrary cache URL. 637 | 638 | :param urllib.parse.ParseResult or str url: 639 | Cache URL to parse. 640 | :param str or None backend: 641 | If None, the backend is evaluates from the ``url``. 642 | :return: Parsed cache URL. 643 | :rtype: dict 644 | """ 645 | if not isinstance(url, cls.URL_CLASS): 646 | if not url: 647 | return {} 648 | url = urlparse(url) 649 | 650 | if url.scheme not in cls.CACHE_SCHEMES: 651 | raise ImproperlyConfigured(f'Invalid cache schema {url.scheme}') 652 | 653 | location = url.netloc.split(',') 654 | if len(location) == 1: 655 | location = location[0] 656 | 657 | config = { 658 | 'BACKEND': cls.CACHE_SCHEMES[url.scheme], 659 | 'LOCATION': location, 660 | } 661 | 662 | # Add the drive to LOCATION 663 | if url.scheme == 'filecache': 664 | config.update({ 665 | 'LOCATION': url.netloc + url.path, 666 | }) 667 | 668 | # urlparse('pymemcache://127.0.0.1:11211') 669 | # => netloc='127.0.0.1:11211', path='' 670 | # 671 | # urlparse('pymemcache://memcached:11211/?key_prefix=ci') 672 | # => netloc='memcached:11211', path='/' 673 | # 674 | # urlparse('memcache:///tmp/memcached.sock') 675 | # => netloc='', path='/tmp/memcached.sock' 676 | if not url.netloc and url.scheme in ['memcache', 'pymemcache']: 677 | config.update({ 678 | 'LOCATION': 'unix:' + url.path, 679 | }) 680 | elif url.scheme.startswith('redis'): 681 | if url.hostname: 682 | scheme = url.scheme.replace('cache', '') 683 | else: 684 | scheme = 'unix' 685 | locations = [scheme + '://' + loc + url.path 686 | for loc in url.netloc.split(',')] 687 | if len(locations) == 1: 688 | config['LOCATION'] = locations[0] 689 | else: 690 | config['LOCATION'] = locations 691 | 692 | if url.query: 693 | config_options = {} 694 | for k, v in parse_qs(url.query).items(): 695 | opt = {k.upper(): _cast(v[0])} 696 | if k.upper() in cls._CACHE_BASE_OPTIONS: 697 | config.update(opt) 698 | else: 699 | config_options.update(opt) 700 | config['OPTIONS'] = config_options 701 | 702 | if backend: 703 | config['BACKEND'] = backend 704 | 705 | return config 706 | 707 | @classmethod 708 | def email_url_config(cls, url, backend=None): 709 | """Parse an arbitrary email URL. 710 | 711 | :param urllib.parse.ParseResult or str url: 712 | Email URL to parse. 713 | :param str or None backend: 714 | If None, the backend is evaluates from the ``url``. 715 | :return: Parsed email URL. 716 | :rtype: dict 717 | """ 718 | 719 | config = {} 720 | 721 | url = urlparse(url) if not isinstance(url, cls.URL_CLASS) else url 722 | 723 | # Remove query strings 724 | path = url.path[1:] 725 | path = unquote_plus(path.split('?', 2)[0]) 726 | 727 | # Update with environment configuration 728 | config.update({ 729 | 'EMAIL_FILE_PATH': path, 730 | 'EMAIL_HOST_USER': _cast_urlstr(url.username), 731 | 'EMAIL_HOST_PASSWORD': _cast_urlstr(url.password), 732 | 'EMAIL_HOST': url.hostname, 733 | 'EMAIL_PORT': _cast_int(url.port), 734 | }) 735 | 736 | if backend: 737 | config['EMAIL_BACKEND'] = backend 738 | elif url.scheme not in cls.EMAIL_SCHEMES: 739 | raise ImproperlyConfigured(f'Invalid email schema {url.scheme}') 740 | elif url.scheme in cls.EMAIL_SCHEMES: 741 | config['EMAIL_BACKEND'] = cls.EMAIL_SCHEMES[url.scheme] 742 | 743 | if url.scheme in ('smtps', 'smtp+tls'): 744 | config['EMAIL_USE_TLS'] = True 745 | elif url.scheme == 'smtp+ssl': 746 | config['EMAIL_USE_SSL'] = True 747 | 748 | if url.query: 749 | config_options = {} 750 | for k, v in parse_qs(url.query).items(): 751 | opt = {k.upper(): _cast_int(v[0])} 752 | if k.upper() in cls._EMAIL_BASE_OPTIONS: 753 | config.update(opt) 754 | else: 755 | config_options.update(opt) 756 | config['OPTIONS'] = config_options 757 | 758 | return config 759 | 760 | @classmethod 761 | def channels_url_config(cls, url, backend=None): 762 | """Parse an arbitrary channels URL. 763 | 764 | :param urllib.parse.ParseResult or str url: 765 | Email URL to parse. 766 | :param str or None backend: 767 | If None, the backend is evaluates from the ``url``. 768 | :return: Parsed channels URL. 769 | :rtype: dict 770 | """ 771 | config = {} 772 | url = urlparse(url) if not isinstance(url, cls.URL_CLASS) else url 773 | 774 | if backend: 775 | config["BACKEND"] = backend 776 | elif url.scheme not in cls.CHANNELS_SCHEMES: 777 | raise ImproperlyConfigured(f"Invalid channels schema {url.scheme}") 778 | else: 779 | config["BACKEND"] = cls.CHANNELS_SCHEMES[url.scheme] 780 | if url.scheme in ("redis", "redis+pubsub"): 781 | config["CONFIG"] = { 782 | "hosts": [url._replace(scheme="redis").geturl()] 783 | } 784 | 785 | return config 786 | 787 | @classmethod 788 | def _parse_common_search_params(cls, url): 789 | cfg = {} 790 | prs = {} 791 | 792 | if not url.query or str(url.query) == '': 793 | return cfg, prs 794 | 795 | prs = parse_qs(url.query) 796 | if 'EXCLUDED_INDEXES' in prs: 797 | cfg['EXCLUDED_INDEXES'] = prs['EXCLUDED_INDEXES'][0].split(',') 798 | if 'INCLUDE_SPELLING' in prs: 799 | val = prs['INCLUDE_SPELLING'][0] 800 | cfg['INCLUDE_SPELLING'] = cls.parse_value(val, bool) 801 | if 'BATCH_SIZE' in prs: 802 | cfg['BATCH_SIZE'] = cls.parse_value(prs['BATCH_SIZE'][0], int) 803 | return cfg, prs 804 | 805 | @classmethod 806 | def _parse_elasticsearch_search_params(cls, url, path, secure, params): 807 | cfg = {} 808 | split = path.rsplit('/', 1) 809 | 810 | if len(split) > 1: 811 | path = '/'.join(split[:-1]) 812 | index = split[-1] 813 | else: 814 | path = "" 815 | index = split[0] 816 | 817 | cfg['URL'] = urlunparse( 818 | ('https' if secure else 'http', url[1], path, '', '', '') 819 | ) 820 | if 'TIMEOUT' in params: 821 | cfg['TIMEOUT'] = cls.parse_value(params['TIMEOUT'][0], int) 822 | if 'KWARGS' in params: 823 | cfg['KWARGS'] = params['KWARGS'][0] 824 | cfg['INDEX_NAME'] = index 825 | return cfg 826 | 827 | @classmethod 828 | def _parse_solr_search_params(cls, url, path, params): 829 | cfg = {} 830 | cfg['URL'] = urlunparse(('http',) + url[1:2] + (path,) + ('', '', '')) 831 | if 'TIMEOUT' in params: 832 | cfg['TIMEOUT'] = cls.parse_value(params['TIMEOUT'][0], int) 833 | if 'KWARGS' in params: 834 | cfg['KWARGS'] = params['KWARGS'][0] 835 | return cfg 836 | 837 | @classmethod 838 | def _parse_whoosh_search_params(cls, params): 839 | cfg = {} 840 | if 'STORAGE' in params: 841 | cfg['STORAGE'] = params['STORAGE'][0] 842 | if 'POST_LIMIT' in params: 843 | cfg['POST_LIMIT'] = cls.parse_value(params['POST_LIMIT'][0], int) 844 | return cfg 845 | 846 | @classmethod 847 | def _parse_xapian_search_params(cls, params): 848 | cfg = {} 849 | if 'FLAGS' in params: 850 | cfg['FLAGS'] = params['FLAGS'][0] 851 | return cfg 852 | 853 | @classmethod 854 | def search_url_config(cls, url, engine=None): 855 | """Parse an arbitrary search URL. 856 | 857 | :param urllib.parse.ParseResult or str url: 858 | Search URL to parse. 859 | :param str or None engine: 860 | If None, the engine is evaluating from the ``url``. 861 | :return: Parsed search URL. 862 | :rtype: dict 863 | """ 864 | config = {} 865 | url = urlparse(url) if not isinstance(url, cls.URL_CLASS) else url 866 | 867 | # Remove query strings. 868 | path = unquote_plus(url.path[1:].split('?', 2)[0]) 869 | 870 | scheme = url.scheme 871 | secure = False 872 | # elasticsearch supports secure schemes, similar to http -> https 873 | if scheme in cls.ELASTICSEARCH_FAMILY and scheme.endswith('s'): 874 | scheme = scheme[:-1] 875 | secure = True 876 | if scheme not in cls.SEARCH_SCHEMES: 877 | raise ImproperlyConfigured(f'Invalid search schema {url.scheme}') 878 | config['ENGINE'] = cls.SEARCH_SCHEMES[scheme] 879 | 880 | # check commons params 881 | cfg, params = cls._parse_common_search_params(url) 882 | config.update(cfg) 883 | 884 | if url.scheme == 'simple': 885 | return config 886 | 887 | # remove trailing slash 888 | if path.endswith('/'): 889 | path = path[:-1] 890 | 891 | if url.scheme == 'solr': 892 | config.update(cls._parse_solr_search_params(url, path, params)) 893 | return config 894 | 895 | if url.scheme in cls.ELASTICSEARCH_FAMILY: 896 | config.update(cls._parse_elasticsearch_search_params( 897 | url, path, secure, params)) 898 | return config 899 | 900 | config['PATH'] = '/' + path 901 | 902 | if url.scheme == 'whoosh': 903 | config.update(cls._parse_whoosh_search_params(params)) 904 | elif url.scheme == 'xapian': 905 | config.update(cls._parse_xapian_search_params(params)) 906 | 907 | if engine: 908 | config['ENGINE'] = engine 909 | 910 | return config 911 | 912 | @classmethod 913 | def read_env(cls, env_file=None, overwrite=False, parse_comments=False, 914 | encoding='utf8', **overrides): 915 | r"""Read a .env file into os.environ. 916 | 917 | If not given a path to a dotenv path, does filthy magic stack 918 | backtracking to find the dotenv in the same directory as the file that 919 | called ``read_env``. 920 | 921 | Existing environment variables take precedent and are NOT overwritten 922 | by the file content. ``overwrite=True`` will force an overwrite of 923 | existing environment variables. 924 | 925 | Refs: 926 | 927 | * https://wellfire.co/learn/easier-12-factor-django 928 | 929 | :param env_file: The path to the ``.env`` file your application should 930 | use. If a path is not provided, `read_env` will attempt to import 931 | the Django settings module from the Django project root. 932 | :param overwrite: ``overwrite=True`` will force an overwrite of 933 | existing environment variables. 934 | :param parse_comments: Determines whether to recognize and ignore 935 | inline comments in the .env file. Default is False. 936 | :param encoding: The encoding to use when reading the environment file. 937 | :param \**overrides: Any additional keyword arguments provided directly 938 | to read_env will be added to the environment. If the key matches an 939 | existing environment variable, the value will be overridden. 940 | """ 941 | if env_file is None: 942 | # pylint: disable=protected-access 943 | frame = sys._getframe() 944 | env_file = os.path.join( 945 | os.path.dirname(frame.f_back.f_code.co_filename), 946 | '.env' 947 | ) 948 | if not os.path.exists(env_file): 949 | logger.info( 950 | "%s doesn't exist - if you're not configuring your " 951 | "environment separately, create one.", env_file) 952 | return 953 | 954 | try: 955 | if isinstance(env_file, Openable): 956 | # Python 3.5 support (wrap path with str). 957 | with open(str(env_file), encoding=encoding) as f: 958 | content = f.read() 959 | else: 960 | with env_file as f: 961 | content = f.read() 962 | except OSError: 963 | logger.info( 964 | "%s not found - if you're not configuring your " 965 | "environment separately, check this.", env_file) 966 | return 967 | 968 | logger.debug('Read environment variables from: %s', env_file) 969 | 970 | def _keep_escaped_format_characters(match): 971 | """Keep escaped newline/tabs in quoted strings""" 972 | escaped_char = match.group(1) 973 | if escaped_char in 'rnt': 974 | return '\\' + escaped_char 975 | return escaped_char 976 | 977 | for line in content.splitlines(): 978 | m1 = re.match(r'\A(?:export )?([A-Za-z_0-9]+)=(.*)\Z', line) 979 | if m1: 980 | 981 | # Example: 982 | # 983 | # line: KEY_499=abc#def 984 | # key: KEY_499 985 | # val: abc#def 986 | key, val = m1.group(1), m1.group(2) 987 | 988 | if not parse_comments: 989 | # Default behavior 990 | # 991 | # Look for value in single quotes 992 | m2 = re.match(r"\A'(.*)'\Z", val) 993 | if m2: 994 | val = m2.group(1) 995 | else: 996 | # Ignore post-# comments (outside quotes). 997 | # Something like ['val' # comment] becomes ['val']. 998 | m2 = re.match(r"\A\s*'(? 1: 1132 | base_path = os.path.join(base_path, '') 1133 | return item.__root__.startswith(base_path) 1134 | 1135 | def __repr__(self): 1136 | return f'' 1137 | 1138 | def __str__(self): 1139 | return self.__root__ 1140 | 1141 | def __unicode__(self): 1142 | return self.__str__() 1143 | 1144 | def __getitem__(self, *args, **kwargs): 1145 | return self.__str__().__getitem__(*args, **kwargs) 1146 | 1147 | def __fspath__(self): 1148 | return self.__str__() 1149 | 1150 | def rfind(self, *args, **kwargs): 1151 | """Proxy method to :py:func:`str.rfind`""" 1152 | return str(self).rfind(*args, **kwargs) 1153 | 1154 | def find(self, *args, **kwargs): 1155 | """Proxy method to :py:func:`str.find`""" 1156 | return str(self).find(*args, **kwargs) 1157 | 1158 | @staticmethod 1159 | def _absolute_join(base, *paths, **kwargs): 1160 | absolute_path = os.path.abspath(os.path.join(base, *paths)) 1161 | if kwargs.get('required', False) and not os.path.exists(absolute_path): 1162 | raise ImproperlyConfigured( 1163 | f'Create required path: {absolute_path}' 1164 | ) 1165 | return absolute_path 1166 | -------------------------------------------------------------------------------- /environ/fileaware_mapping.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | """Docker-style file variable support module.""" 10 | 11 | import os 12 | from collections.abc import MutableMapping 13 | 14 | 15 | class FileAwareMapping(MutableMapping): 16 | """ 17 | A mapping that wraps os.environ, first checking for the existence of a key 18 | appended with ``_FILE`` whenever reading a value. If a matching file key is 19 | found then the value is instead read from the file system at this location. 20 | 21 | By default, values read from the file system are cached so future lookups 22 | do not hit the disk again. 23 | 24 | A ``_FILE`` key has higher precedence than a value is set directly in the 25 | environment, and an exception is raised if the file can not be found. 26 | """ 27 | 28 | def __init__(self, env=None, cache=True): 29 | """ 30 | Initialize the mapping. 31 | 32 | :param env: 33 | where to read environment variables from (defaults to 34 | ``os.environ``) 35 | :param cache: 36 | cache environment variables read from the file system (defaults to 37 | ``True``) 38 | """ 39 | self.env = env if env is not None else os.environ 40 | self.cache = cache 41 | self.files_cache = {} 42 | 43 | def __getitem__(self, key): 44 | if self.cache and key in self.files_cache: 45 | return self.files_cache[key] 46 | key_file = self.env.get(key + "_FILE") 47 | if key_file: 48 | with open(key_file, encoding='utf-8') as f: 49 | value = f.read() 50 | if self.cache: 51 | self.files_cache[key] = value 52 | return value 53 | return self.env[key] 54 | 55 | def __iter__(self): 56 | """ 57 | Iterate all keys, also always including the shortened key if ``_FILE`` 58 | keys are found. 59 | """ 60 | for key in self.env: 61 | yield key 62 | if key.endswith("_FILE"): 63 | no_file_key = key[:-5] 64 | if no_file_key and no_file_key not in self.env: 65 | yield no_file_key 66 | 67 | def __len__(self): 68 | """ 69 | Return the length of the file, also always counting shortened keys for 70 | any ``_FILE`` key found. 71 | """ 72 | return len(tuple(iter(self))) 73 | 74 | def __setitem__(self, key, value): 75 | self.env[key] = value 76 | if self.cache and key.endswith("_FILE"): 77 | no_file_key = key[:-5] 78 | if no_file_key and no_file_key in self.files_cache: 79 | del self.files_cache[no_file_key] 80 | 81 | def __delitem__(self, key): 82 | file_key = key + "_FILE" 83 | if file_key in self.env: 84 | del self[file_key] 85 | if key in self.env: 86 | del self.env[key] 87 | return 88 | if self.cache and key.endswith("_FILE"): 89 | no_file_key = key[:-5] 90 | if no_file_key and no_file_key in self.files_cache: 91 | del self.files_cache[no_file_key] 92 | del self.env[key] 93 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # This file is part of the django-environ. 4 | # 5 | # Copyright (c) 2021-2024, Serghei Iakovlev 6 | # Copyright (c) 2013-2021, Daniele Faraglia 7 | # 8 | # For the full copyright and license information, please view 9 | # the LICENSE.txt file that was distributed with this source code. 10 | 11 | import codecs 12 | import re 13 | from os import path 14 | 15 | from setuptools import find_packages, setup 16 | 17 | # Use this code block for future deprecations of Python version: 18 | # 19 | # import warnings 20 | # import sys 21 | # 22 | # if sys.version_info < (3, 6): 23 | # warnings.warn( 24 | # "Support of Python < 3.6 is deprecated" 25 | # "and will be removed in a future release.", 26 | # DeprecationWarning 27 | # ) 28 | 29 | 30 | def read_file(filepath): 31 | """Read content from a UTF-8 encoded text file.""" 32 | with codecs.open(filepath, 'rb', 'utf-8') as file_handle: 33 | return file_handle.read() 34 | 35 | 36 | PKG_NAME = 'django-environ' 37 | PKG_DIR = path.abspath(path.dirname(__file__)) 38 | META_PATH = path.join(PKG_DIR, 'environ', '__init__.py') 39 | META_CONTENTS = read_file(META_PATH) 40 | 41 | 42 | def load_long_description(): 43 | """Load long description from file README.rst.""" 44 | def changes(): 45 | changelog = path.join(PKG_DIR, 'CHANGELOG.rst') 46 | pattern = ( 47 | r'(`(v\d+.\d+.\d+)`_( - \d{1,2}-\w+-\d{4}\r?\n-+\r?\n.*?))' 48 | r'\r?\n\r?\n\r?\n`v\d+.\d+.\d+`_' 49 | ) 50 | result = re.search(pattern, read_file(changelog), re.S) 51 | 52 | return result.group(2) + result.group(3) if result else '' 53 | 54 | try: 55 | title = PKG_NAME 56 | head = '=' * (len(title)) 57 | 58 | contents = ( 59 | head, 60 | format(title.strip(' .')), 61 | head, 62 | read_file(path.join(PKG_DIR, 'README.rst')).split( 63 | '.. -teaser-begin-' 64 | )[1], 65 | '', 66 | read_file(path.join(PKG_DIR, 'CONTRIBUTING.rst')), 67 | '', 68 | 'Release Information', 69 | '===================\n', 70 | changes(), 71 | '', 72 | '`Full changelog <{}/en/latest/changelog.html>`_.'.format( 73 | find_meta('url') 74 | ), 75 | '', 76 | read_file(path.join(PKG_DIR, 'SECURITY.rst')), 77 | '', 78 | read_file(path.join(PKG_DIR, 'AUTHORS.rst')), 79 | ) 80 | 81 | return '\n'.join(contents) 82 | except (RuntimeError, FileNotFoundError) as read_error: 83 | message = 'Long description could not be read from README.rst' 84 | raise RuntimeError('%s: %s' % (message, read_error)) from read_error 85 | 86 | 87 | def is_canonical_version(version): 88 | """Check if a version string is in the canonical format of PEP 440.""" 89 | pattern = ( 90 | r'^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))' 91 | r'*((a|b|rc)(0|[1-9][0-9]*))?(\.post(0|[1-9][0-9]*))' 92 | r'?(\.dev(0|[1-9][0-9]*))?$') 93 | return re.match(pattern, version) is not None 94 | 95 | 96 | def find_meta(meta): 97 | """Extract __*meta*__ from META_CONTENTS.""" 98 | meta_match = re.search( 99 | r"^__{meta}__\s+=\s+['\"]([^'\"]*)['\"]".format(meta=meta), 100 | META_CONTENTS, 101 | re.M 102 | ) 103 | 104 | if meta_match: 105 | return meta_match.group(1) 106 | raise RuntimeError( 107 | 'Unable to find __%s__ string in package meta file' % meta) 108 | 109 | 110 | def get_version_string(): 111 | """Return package version as listed in `__version__` in meta file.""" 112 | # Parse version string 113 | version_string = find_meta('version') 114 | 115 | # Check validity 116 | if not is_canonical_version(version_string): 117 | message = ( 118 | 'The detected version string "{}" is not in canonical ' 119 | 'format as defined in PEP 440.'.format(version_string)) 120 | raise ValueError(message) 121 | 122 | return version_string 123 | 124 | 125 | # What does this project relate to? 126 | KEYWORDS = [ 127 | 'environment', 128 | 'django', 129 | 'variables', 130 | '12factor', 131 | ] 132 | 133 | # Classifiers: available ones listed at https://pypi.org/classifiers 134 | CLASSIFIERS = [ 135 | 'Development Status :: 5 - Production/Stable', 136 | 137 | 'Framework :: Django', 138 | 'Framework :: Django :: 2.2', 139 | 'Framework :: Django :: 3.0', 140 | 'Framework :: Django :: 3.1', 141 | 'Framework :: Django :: 3.2', 142 | 'Framework :: Django :: 4.0', 143 | 'Framework :: Django :: 4.1', 144 | 'Framework :: Django :: 4.2', 145 | 'Framework :: Django :: 5.0', 146 | 'Framework :: Django :: 5.1', 147 | 148 | 'Operating System :: OS Independent', 149 | 150 | 'Intended Audience :: Developers', 151 | 'Natural Language :: English', 152 | 153 | 'Programming Language :: Python', 154 | 'Programming Language :: Python :: 3', 155 | 'Programming Language :: Python :: 3.9', 156 | 'Programming Language :: Python :: 3.10', 157 | 'Programming Language :: Python :: 3.11', 158 | 'Programming Language :: Python :: 3.12', 159 | 'Programming Language :: Python :: 3.13', 160 | 'Programming Language :: Python :: Implementation :: CPython', 161 | 'Programming Language :: Python :: Implementation :: PyPy', 162 | 163 | 'Topic :: Software Development :: Libraries :: Python Modules', 164 | 'Topic :: Utilities', 165 | 166 | 'License :: OSI Approved :: MIT License', 167 | ] 168 | 169 | # Dependencies that are downloaded by pip on installation and why. 170 | INSTALL_REQUIRES = [] 171 | 172 | DEPENDENCY_LINKS = [] 173 | 174 | # List additional groups of dependencies here (e.g. testing dependencies). 175 | # You can install these using the following syntax, for example: 176 | # 177 | # $ pip install -e .[testing,docs,develop] 178 | # 179 | EXTRAS_REQUIRE = { 180 | # Dependencies that are required to run tests 181 | 'testing': [ 182 | 'coverage[toml]>=5.0a4', # Code coverage measurement for Python 183 | 'pytest>=4.6.11', # Our tests framework 184 | 'setuptools>=71.0.0', # Needed as a dependency for some tests 185 | ], 186 | # Dependencies that are required to build documentation 187 | 'docs': [ 188 | 'furo>=2024.8.6', # Sphinx documentation theme 189 | 'sphinx>=5.0', # Python documentation generator 190 | 'sphinx-notfound-page', # Create a custom 404 page 191 | ], 192 | } 193 | 194 | # Dependencies that are required to develop package 195 | DEVELOP_REQUIRE = [] 196 | 197 | # Dependencies that are required to develop package 198 | EXTRAS_REQUIRE['develop'] = \ 199 | DEVELOP_REQUIRE + EXTRAS_REQUIRE['testing'] + EXTRAS_REQUIRE['docs'] 200 | 201 | # Project's URLs 202 | PROJECT_URLS = { 203 | 'Documentation': find_meta('url'), 204 | 'Funding': 'https://opencollective.com/django-environ', 205 | 'Say Thanks!': 'https://saythanks.io/to/joke2k', 206 | 'Changelog': '{}/en/latest/changelog.html'.format(find_meta('url')), 207 | 'Bug Tracker': 'https://github.com/joke2k/django-environ/issues', 208 | 'Source Code': 'https://github.com/joke2k/django-environ', 209 | } 210 | 211 | 212 | if __name__ == '__main__': 213 | setup( 214 | name=PKG_NAME, 215 | version=get_version_string(), 216 | author=find_meta('author'), 217 | author_email=find_meta('author_email'), 218 | maintainer=find_meta('maintainer'), 219 | maintainer_email=find_meta('maintainer_email'), 220 | license=find_meta('license'), 221 | description=find_meta('description'), 222 | long_description=load_long_description(), 223 | long_description_content_type='text/x-rst', 224 | keywords=KEYWORDS, 225 | url=find_meta('url'), 226 | project_urls=PROJECT_URLS, 227 | classifiers=CLASSIFIERS, 228 | packages=find_packages(exclude=['tests.*', 'tests']), 229 | platforms=['any'], 230 | include_package_data=True, 231 | zip_safe=False, 232 | python_requires='>=3.9,<4', 233 | install_requires=INSTALL_REQUIRES, 234 | dependency_links=DEPENDENCY_LINKS, 235 | extras_require=EXTRAS_REQUIRE, 236 | ) 237 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | -------------------------------------------------------------------------------- /tests/asserts.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | def assert_type_and_value(type_, expected, actual): 10 | assert isinstance(actual, type_) 11 | assert actual == expected 12 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import os 10 | import pathlib 11 | import sys 12 | 13 | import pytest 14 | 15 | 16 | @pytest.fixture 17 | def solr_url(): 18 | """Return Solr URL.""" 19 | return 'solr://127.0.0.1:8983/solr' 20 | 21 | 22 | @pytest.fixture 23 | def whoosh_url(): 24 | """Return Whoosh URL.""" 25 | return 'whoosh:///home/search/whoosh_index' 26 | 27 | 28 | @pytest.fixture 29 | def xapian_url(): 30 | """Return Xapian URL.""" 31 | return 'xapian:///home/search/xapian_index' 32 | 33 | 34 | @pytest.fixture 35 | def simple_url(): 36 | """Return simple URL.""" 37 | return 'simple:///' 38 | 39 | 40 | @pytest.fixture 41 | def volume(): 42 | """Return volume name is OS is Windows, otherwise None.""" 43 | if sys.platform == 'win32': 44 | return pathlib.Path(os.getcwd()).parts[0] 45 | return None 46 | 47 | 48 | @pytest.fixture(params=[ 49 | 'solr://127.0.0.1:8983/solr', 50 | 'elasticsearch://127.0.0.1:9200/index', 51 | 'whoosh:///home/search/whoosh_index', 52 | 'xapian:///home/search/xapian_index', 53 | 'simple:///' 54 | ]) 55 | def search_url(request): 56 | """Return Search Engine URL.""" 57 | return request.param 58 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | from environ.compat import json 10 | 11 | 12 | class FakeEnv: 13 | URL = 'http://www.google.com/' 14 | POSTGRES = 'postgres://uf07k1:wegauwhg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722' 15 | MYSQL = 'mysql://bea6eb0:69772142@us-cdbr-east.cleardb.com/heroku_97681?reconnect=true' 16 | MYSQL_CLOUDSQL_URL = 'mysql://djuser:hidden-password@//cloudsql/arvore-codelab:us-central1:mysqlinstance/mydatabase' 17 | MYSQLGIS = 'mysqlgis://user:password@127.0.0.1/some_database' 18 | SQLITE = 'sqlite:////full/path/to/your/database/file.sqlite' 19 | ORACLE_TNS = 'oracle://user:password@sid/' 20 | ORACLE = 'oracle://user:password@host:1521/sid' 21 | CUSTOM_BACKEND = 'custom.backend://user:password@example.com:5430/database' 22 | REDSHIFT = 'redshift://user:password@examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev' 23 | MEMCACHE = 'memcache://127.0.0.1:11211' 24 | REDIS = 'rediscache://127.0.0.1:6379/1?client_class=django_redis.client.DefaultClient&password=secret' 25 | EMAIL = 'smtps://user@domain.com:password@smtp.example.com:587' 26 | JSON = dict(one='bar', two=2, three=33.44) 27 | DICT = dict(foo='bar', test='on') 28 | DICT_WITH_EQ = dict(key1='sub_key1=sub_value1', key2='value2') 29 | PATH = '/home/dev' 30 | EXPORTED = 'exported var' 31 | SAML_ATTRIBUTE_MAPPING = dict( 32 | uid=('username',), 33 | mail=('email',), 34 | cn=('first_name',), 35 | sn=('last_name',) 36 | ) 37 | 38 | @classmethod 39 | def generate_data(cls): 40 | return dict(STR_VAR='bar', 41 | STR_QUOTED_IGNORE_COMMENT='foo', 42 | STR_QUOTED_INCLUDE_HASH='foo # with hash', 43 | MULTILINE_STR_VAR='foo\\nbar', 44 | MULTILINE_QUOTED_STR_VAR='---BEGIN---\\r\\n---END---', 45 | MULTILINE_ESCAPED_STR_VAR='---BEGIN---\\\\n---END---', 46 | INT_VAR='42', 47 | FLOAT_VAR='33.3', 48 | FLOAT_COMMA_VAR='33,3', 49 | FLOAT_STRANGE_VAR1='123,420,333.3', 50 | FLOAT_STRANGE_VAR2='123.420.333,3', 51 | FLOAT_NEGATIVE_VAR='-1.0', 52 | BOOL_TRUE_STRING_LIKE_INT='1', 53 | BOOL_TRUE_INT=1, 54 | BOOL_TRUE_STRING_LIKE_BOOL='True', 55 | BOOL_TRUE_STRING_LIKE_BOOL_WITH_COMMENT='True', 56 | BOOL_TRUE_STRING_1='on', 57 | BOOL_TRUE_STRING_2='ok', 58 | BOOL_TRUE_STRING_3='yes', 59 | BOOL_TRUE_STRING_4='y', 60 | BOOL_TRUE_STRING_5='true', 61 | BOOL_TRUE_BOOL=True, 62 | BOOL_TRUE_BOOL_WITH_COMMENT=True, 63 | BOOL_FALSE_STRING_LIKE_INT='0', 64 | BOOL_FALSE_INT=0, 65 | BOOL_FALSE_STRING_LIKE_BOOL='False', 66 | BOOL_FALSE_BOOL=False, 67 | PROXIED_VAR='$STR_VAR', 68 | ESCAPED_VAR=r'\$baz', 69 | INT_LIST='42,33', 70 | INT_TUPLE='(42,33)', 71 | MIX_TUPLE='(42,Test)', 72 | STR_LIST_WITH_SPACES=' foo, spaces', 73 | STR_LIST_WITH_SPACES_QUOTED="' foo', ' quoted'", 74 | EMPTY_LIST='', 75 | DICT_VAR='foo=bar,test=on', 76 | DICT_WITH_EQ_VAR='key1=sub_key1=sub_value1,key2=value2', 77 | DATABASE_URL=cls.POSTGRES, 78 | DATABASE_MYSQL_URL=cls.MYSQL, 79 | DATABASE_MYSQL_GIS_URL=cls.MYSQLGIS, 80 | DATABASE_SQLITE_URL=cls.SQLITE, 81 | DATABASE_ORACLE_URL=cls.ORACLE, 82 | DATABASE_ORACLE_TNS_URL=cls.ORACLE_TNS, 83 | DATABASE_REDSHIFT_URL=cls.REDSHIFT, 84 | DATABASE_CUSTOM_BACKEND_URL=cls.CUSTOM_BACKEND, 85 | DATABASE_MYSQL_CLOUDSQL_URL=cls.MYSQL_CLOUDSQL_URL, 86 | CACHE_URL=cls.MEMCACHE, 87 | CACHE_REDIS=cls.REDIS, 88 | EMAIL_URL=cls.EMAIL, 89 | URL_VAR=cls.URL, 90 | JSON_VAR=json.dumps(cls.JSON), 91 | PATH_VAR=cls.PATH, 92 | EXPORTED_VAR=cls.EXPORTED, 93 | SAML_ATTRIBUTE_MAPPING='uid=username;mail=email;cn=first_name;sn=last_name;', 94 | PREFIX_TEST='foo', 95 | ) 96 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | from unittest import mock 10 | 11 | import pytest 12 | 13 | import environ.compat 14 | from environ import Env 15 | from environ.compat import ( 16 | ImproperlyConfigured, 17 | PYMEMCACHE_DRIVER, 18 | REDIS_DRIVER, 19 | ) 20 | 21 | 22 | def test_base_options_parsing(): 23 | url = ('memcache://127.0.0.1:11211/?timeout=0&' 24 | 'key_prefix=cache_&key_function=foo.get_key&version=1') 25 | url = Env.cache_url_config(url) 26 | 27 | assert url['KEY_PREFIX'] == 'cache_' 28 | assert url['KEY_FUNCTION'] == 'foo.get_key' 29 | assert url['TIMEOUT'] == 0 30 | assert url['VERSION'] == 1 31 | 32 | url = 'redis://127.0.0.1:6379/?timeout=None' 33 | url = Env.cache_url_config(url) 34 | 35 | assert url['TIMEOUT'] is None 36 | 37 | 38 | @pytest.mark.parametrize( 39 | 'url,backend,location', 40 | [ 41 | ('dbcache://my_cache_table', 42 | 'django.core.cache.backends.db.DatabaseCache', 'my_cache_table'), 43 | ('filecache:///var/tmp/django_cache', 44 | 'django.core.cache.backends.filebased.FileBasedCache', 45 | '/var/tmp/django_cache'), 46 | ('filecache://C:/foo/bar', 47 | 'django.core.cache.backends.filebased.FileBasedCache', 'C:/foo/bar'), 48 | ('locmemcache://', 49 | 'django.core.cache.backends.locmem.LocMemCache', ''), 50 | ('locmemcache://unique-snowflake', 51 | 'django.core.cache.backends.locmem.LocMemCache', 'unique-snowflake'), 52 | ('dummycache://', 53 | 'django.core.cache.backends.dummy.DummyCache', ''), 54 | ('rediss://127.0.0.1:6379/1', REDIS_DRIVER, 55 | 'rediss://127.0.0.1:6379/1'), 56 | ('rediscache://:redispass@127.0.0.1:6379/0', REDIS_DRIVER, 57 | 'redis://:redispass@127.0.0.1:6379/0'), 58 | ('rediscache://host1:6379,host2:6379,host3:9999/1', REDIS_DRIVER, 59 | ['redis://host1:6379/1', 'redis://host2:6379/1', 60 | 'redis://host3:9999/1']), 61 | ('rediscache:///path/to/socket:1', REDIS_DRIVER, 62 | 'unix:///path/to/socket:1'), 63 | ('memcache:///tmp/memcached.sock', 64 | 'django.core.cache.backends.memcached.MemcachedCache', 65 | 'unix:/tmp/memcached.sock'), 66 | ('memcache://172.19.26.240:11211,172.19.26.242:11212', 67 | 'django.core.cache.backends.memcached.MemcachedCache', 68 | ['172.19.26.240:11211', '172.19.26.242:11212']), 69 | ('memcache://127.0.0.1:11211', 70 | 'django.core.cache.backends.memcached.MemcachedCache', 71 | '127.0.0.1:11211'), 72 | ('pymemcache://127.0.0.1:11211', 73 | PYMEMCACHE_DRIVER, 74 | '127.0.0.1:11211'), 75 | ('pymemcache://memcached:11211/?key_prefix=ci', 76 | PYMEMCACHE_DRIVER, 77 | 'memcached:11211'), 78 | ], 79 | ids=[ 80 | 'dbcache', 81 | 'filecache', 82 | 'filecache_win', 83 | 'locmemcache_empty', 84 | 'locmemcache', 85 | 'dummycache', 86 | 'rediss', 87 | 'redis_with_password', 88 | 'redis_multiple', 89 | 'redis_socket', 90 | 'memcached_socket', 91 | 'memcached_multiple', 92 | 'memcached', 93 | 'pylibmccache', 94 | 'pylibmccache_trailing_slash', 95 | ], 96 | ) 97 | def test_cache_parsing(url, backend, location): 98 | url = Env.cache_url_config(url) 99 | 100 | assert url['BACKEND'] == backend 101 | assert url['LOCATION'] == location 102 | 103 | 104 | @pytest.mark.parametrize('django_version', ((3, 2), (3, 1), None)) 105 | @pytest.mark.parametrize('pymemcache_installed', (True, False)) 106 | def test_pymemcache_compat(django_version, pymemcache_installed): 107 | old = 'django.core.cache.backends.memcached.PyLibMCCache' 108 | new = 'django.core.cache.backends.memcached.PyMemcacheCache' 109 | with mock.patch.object(environ.compat, 'DJANGO_VERSION', django_version): 110 | with mock.patch('environ.compat.find_spec') as mock_find_spec: 111 | mock_find_spec.return_value = pymemcache_installed 112 | driver = environ.compat.choose_pymemcache_driver() 113 | if django_version and django_version < (3, 2): 114 | assert driver == old 115 | else: 116 | assert driver == new if pymemcache_installed else old 117 | 118 | 119 | @pytest.mark.parametrize('django_version', ((4, 0), (3, 2), None)) 120 | @pytest.mark.parametrize('django_redis_installed', (True, False)) 121 | def test_rediscache_compat(django_version, django_redis_installed): 122 | django_new = 'django.core.cache.backends.redis.RedisCache' 123 | redis_cache = 'redis_cache.RedisCache' 124 | django_redis = 'django_redis.cache.RedisCache' 125 | 126 | with mock.patch.object(environ.compat, 'DJANGO_VERSION', django_version): 127 | with mock.patch('environ.compat.find_spec') as mock_find_spec: 128 | mock_find_spec.return_value = django_redis_installed 129 | driver = environ.compat.choose_rediscache_driver() 130 | if django_redis_installed: 131 | assert driver == django_redis 132 | elif django_version and django_version >= (4, 0): 133 | assert driver == django_new 134 | else: 135 | assert driver == redis_cache 136 | 137 | def test_redis_parsing(): 138 | url = ('rediscache://127.0.0.1:6379/1?client_class=' 139 | 'django_redis.client.DefaultClient&password=secret') 140 | url = Env.cache_url_config(url) 141 | 142 | assert url['BACKEND'] == REDIS_DRIVER 143 | assert url['LOCATION'] == 'redis://127.0.0.1:6379/1' 144 | assert url['OPTIONS'] == { 145 | 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 146 | 'PASSWORD': 'secret', 147 | } 148 | 149 | 150 | def test_redis_socket_url(): 151 | url = 'redis://:redispass@/path/to/socket.sock?db=0' 152 | url = Env.cache_url_config(url) 153 | assert REDIS_DRIVER == url['BACKEND'] 154 | assert url['LOCATION'] == 'unix://:redispass@/path/to/socket.sock' 155 | assert url['OPTIONS'] == { 156 | 'DB': 0 157 | } 158 | 159 | 160 | def test_options_parsing(): 161 | url = 'filecache:///var/tmp/django_cache?timeout=60&max_entries=1000&cull_frequency=0' 162 | url = Env.cache_url_config(url) 163 | 164 | assert url['BACKEND'] == 'django.core.cache.backends.filebased.FileBasedCache' 165 | assert url['LOCATION'] == '/var/tmp/django_cache' 166 | assert url['TIMEOUT'] == 60 167 | assert url['OPTIONS'] == { 168 | 'MAX_ENTRIES': 1000, 169 | 'CULL_FREQUENCY': 0, 170 | } 171 | 172 | 173 | def test_custom_backend(): 174 | url = 'memcache://127.0.0.1:5400?foo=option&bars=9001' 175 | backend = 'django_redis.cache.RedisCache' 176 | url = Env.cache_url_config(url, backend) 177 | 178 | assert url['BACKEND'] == backend 179 | assert url['LOCATION'] == '127.0.0.1:5400' 180 | assert url['OPTIONS'] == { 181 | 'FOO': 'option', 182 | 'BARS': 9001, 183 | } 184 | 185 | 186 | def test_unknown_backend(): 187 | url = 'unknown-scheme://127.0.0.1:1000' 188 | with pytest.raises(ImproperlyConfigured) as excinfo: 189 | Env.cache_url_config(url) 190 | assert str(excinfo.value) == 'Invalid cache schema unknown-scheme' 191 | 192 | 193 | def test_empty_url_is_mapped_to_empty_config(): 194 | assert Env.cache_url_config('') == {} 195 | assert Env.cache_url_config(None) == {} 196 | 197 | 198 | @pytest.mark.parametrize( 199 | 'chars', 200 | ['!', '$', '&', "'", '(', ')', '*', '+', ';', '=', '-', '.', '-v1.2'] 201 | ) 202 | def test_cache_url_password_using_sub_delims(monkeypatch, chars): 203 | """Ensure CACHE_URL passwords may contains some unsafe characters. 204 | 205 | See: https://github.com/joke2k/django-environ/issues/200 for details.""" 206 | url = 'rediss://enigma:secret{}@ondigitalocean.com:25061/2'.format(chars) 207 | monkeypatch.setenv('CACHE_URL', url) 208 | env = Env() 209 | 210 | result = env.cache() 211 | assert result['BACKEND'] == REDIS_DRIVER 212 | assert result['LOCATION'] == url 213 | 214 | result = env.cache_url_config(url) 215 | assert result['BACKEND'] == REDIS_DRIVER 216 | assert result['LOCATION'] == url 217 | 218 | url = 'rediss://enigma:sec{}ret@ondigitalocean.com:25061/2'.format(chars) 219 | monkeypatch.setenv('CACHE_URL', url) 220 | env = Env() 221 | 222 | result = env.cache() 223 | assert result['BACKEND'] == REDIS_DRIVER 224 | assert result['LOCATION'] == url 225 | 226 | result = env.cache_url_config(url) 227 | assert result['BACKEND'] == REDIS_DRIVER 228 | assert result['LOCATION'] == url 229 | 230 | url = 'rediss://enigma:{}secret@ondigitalocean.com:25061/2'.format(chars) 231 | monkeypatch.setenv('CACHE_URL', url) 232 | env = Env() 233 | 234 | result = env.cache() 235 | assert result['BACKEND'] == REDIS_DRIVER 236 | assert result['LOCATION'] == url 237 | 238 | result = env.cache_url_config(url) 239 | assert result['BACKEND'] == REDIS_DRIVER 240 | assert result['LOCATION'] == url 241 | 242 | 243 | @pytest.mark.parametrize( 244 | 'chars', ['%3A', '%2F', '%3F', '%23', '%5B', '%5D', '%40', '%2C'] 245 | ) 246 | def test_cache_url_password_using_gen_delims(monkeypatch, chars): 247 | """Ensure CACHE_URL passwords may contains %-encoded characters. 248 | 249 | See: https://github.com/joke2k/django-environ/issues/200 for details.""" 250 | url = 'rediss://enigma:secret{}@ondigitalocean.com:25061/2'.format(chars) 251 | monkeypatch.setenv('CACHE_URL', url) 252 | env = Env() 253 | 254 | result = env.cache() 255 | assert result['BACKEND'] == REDIS_DRIVER 256 | assert result['LOCATION'] == url 257 | 258 | url = 'rediss://enigma:sec{}ret@ondigitalocean.com:25061/2'.format(chars) 259 | monkeypatch.setenv('CACHE_URL', url) 260 | env = Env() 261 | 262 | result = env.cache() 263 | assert result['BACKEND'] == REDIS_DRIVER 264 | assert result['LOCATION'] == url 265 | 266 | url = 'rediss://enigma:{}secret@ondigitalocean.com:25061/2'.format(chars) 267 | monkeypatch.setenv('CACHE_URL', url) 268 | env = Env() 269 | 270 | result = env.cache() 271 | assert result['BACKEND'] == REDIS_DRIVER 272 | assert result['LOCATION'] == url 273 | 274 | 275 | def test_cache_url_env_using_default(): 276 | env = Env(CACHE_URL=(str, "locmemcache://")) 277 | result = env.cache() 278 | 279 | assert result["BACKEND"] == "django.core.cache.backends.locmem.LocMemCache" 280 | assert result["LOCATION"] == "" 281 | -------------------------------------------------------------------------------- /tests/test_channels.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | from environ import Env 10 | 11 | 12 | def test_channels_parsing(): 13 | url = "inmemory://" 14 | result = Env.channels_url_config(url) 15 | assert result["BACKEND"] == "channels.layers.InMemoryChannelLayer" 16 | 17 | url = "redis://user:password@localhost:6379/0" 18 | result = Env.channels_url_config(url) 19 | assert result["BACKEND"] == "channels_redis.core.RedisChannelLayer" 20 | assert result["CONFIG"]["hosts"][0] == "redis://user:password@localhost:6379/0" 21 | 22 | url = "redis+pubsub://user:password@localhost:6379/0" 23 | result = Env.channels_url_config(url) 24 | assert result["BACKEND"] == "channels_redis.pubsub.RedisPubSubChannelLayer" 25 | assert result["CONFIG"]["hosts"][0] == "redis://user:password@localhost:6379/0" 26 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import warnings 10 | 11 | import pytest 12 | 13 | from environ import Env 14 | from environ.compat import DJANGO_POSTGRES 15 | 16 | 17 | @pytest.mark.parametrize( 18 | 'url,engine,name,host,user,passwd,port', 19 | [ 20 | # postgres://user:password@host:port/dbname 21 | ('postgres://enigma:secret@example.com:5431/dbname', 22 | DJANGO_POSTGRES, 23 | 'dbname', 24 | 'example.com', 25 | 'enigma', 26 | 'secret', 27 | 5431), 28 | # postgres://path/dbname 29 | ('postgres:////var/run/postgresql/dbname', 30 | DJANGO_POSTGRES, 31 | 'dbname', 32 | '/var/run/postgresql', 33 | '', 34 | '', 35 | ''), 36 | # postgis://user:password@host:port/dbname 37 | ('postgis://enigma:secret@example.com:5431/dbname', 38 | 'django.contrib.gis.db.backends.postgis', 39 | 'dbname', 40 | 'example.com', 41 | 'enigma', 42 | 'secret', 43 | 5431), 44 | # postgres://user:password@host:port,host:port,host:port/dbname 45 | ('postgres://username:p@ss:12,wor:34d@host1:111,22.55.44.88:222,[2001:db8::1234]:333/db', 46 | DJANGO_POSTGRES, 47 | 'db', 48 | 'host1,22.55.44.88,[2001:db8::1234]', 49 | 'username', 50 | 'p@ss:12,wor:34d', 51 | '111,222,333' 52 | ), 53 | # postgres://host,host,host/dbname 54 | ('postgres://node1,node2,node3/db', 55 | DJANGO_POSTGRES, 56 | 'db', 57 | 'node1,node2,node3', 58 | '', 59 | '', 60 | '' 61 | ), 62 | # cockroachdb://username:secret@test.example.com:26258/dbname 63 | ('cockroachdb://username:secret@test.example.com:26258/dbname', 64 | 'django_cockroachdb', 65 | 'dbname', 66 | 'test.example.com', 67 | 'username', 68 | 'secret', 69 | 26258), 70 | # mysqlgis://user:password@host:port/dbname 71 | ('mysqlgis://enigma:secret@example.com:5431/dbname', 72 | 'django.contrib.gis.db.backends.mysql', 73 | 'dbname', 74 | 'example.com', 75 | 'enigma', 76 | 'secret', 77 | 5431), 78 | # mysql://user:password@host/dbname?options 79 | ('mysql://enigma:secret@reconnect.com/dbname?reconnect=true', 80 | 'django.db.backends.mysql', 81 | 'dbname', 82 | 'reconnect.com', 83 | 'enigma', 84 | 'secret', 85 | ''), 86 | # mysql://user@host/dbname 87 | ('mysql://enigma@localhost/dbname', 88 | 'django.db.backends.mysql', 89 | 'dbname', 90 | 'localhost', 91 | 'enigma', 92 | '', 93 | ''), 94 | # sqlite:// 95 | ('sqlite://', 96 | 'django.db.backends.sqlite3', 97 | ':memory:', 98 | '', 99 | '', 100 | '', 101 | ''), 102 | # sqlite:////absolute/path/to/db/file 103 | ('sqlite:////full/path/to/your/file.sqlite', 104 | 'django.db.backends.sqlite3', 105 | '/full/path/to/your/file.sqlite', 106 | '', 107 | '', 108 | '', 109 | ''), 110 | # sqlite://:memory: 111 | ('sqlite://:memory:', 112 | 'django.db.backends.sqlite3', 113 | ':memory:', 114 | '', 115 | '', 116 | '', 117 | ''), 118 | # ldap://user:password@host 119 | ('ldap://cn=admin,dc=nodomain,dc=org:secret@example.com', 120 | 'ldapdb.backends.ldap', 121 | 'ldap://example.com', 122 | 'example.com', 123 | 'cn=admin,dc=nodomain,dc=org', 124 | 'secret', 125 | ''), 126 | # mysql://user:password@host/dbname 127 | ('mssql://enigma:secret@example.com/dbname' 128 | '?driver=ODBC Driver 13 for SQL Server', 129 | 'mssql', 130 | 'dbname', 131 | 'example.com', 132 | 'enigma', 133 | 'secret', 134 | ''), 135 | # mysql://user:password@host:port/dbname 136 | ('mssql://enigma:secret@amazonaws.com\\insnsnss:12345/dbname' 137 | '?driver=ODBC Driver 13 for SQL Server', 138 | 'mssql', 139 | 'dbname', 140 | 'amazonaws.com\\insnsnss', 141 | 'enigma', 142 | 'secret', 143 | 12345), 144 | # mysql://user:password@host:port/dbname 145 | ('mysql://enigma:><{~!@#$%^&*}[]@example.com:1234/dbname', 146 | 'django.db.backends.mysql', 147 | 'dbname', 148 | 'example.com', 149 | 'enigma', 150 | '><{~!@#$%^&*}[]', 151 | 1234), 152 | # mysql://user:password@host/dbname 153 | ('mysql://enigma:]password]@example.com/dbname', 154 | 'django.db.backends.mysql', 155 | 'dbname', 156 | 'example.com', 157 | 'enigma', 158 | ']password]', 159 | ''), 160 | ], 161 | ids=[ 162 | 'postgres', 163 | 'postgres_unix_domain', 164 | 'postgis', 165 | 'postgres_cluster', 166 | 'postgres_no_ports', 167 | 'cockroachdb', 168 | 'mysqlgis', 169 | 'cleardb', 170 | 'mysql_no_password', 171 | 'sqlite_empty', 172 | 'sqlite_file', 173 | 'sqlite_memory', 174 | 'ldap', 175 | 'mssql', 176 | 'mssql_port', 177 | 'mysql_password_special_chars', 178 | 'mysql_invalid_ipv6_password', 179 | ], 180 | ) 181 | def test_db_parsing(url, engine, name, host, user, passwd, port): 182 | config = Env.db_url_config(url) 183 | 184 | assert config['ENGINE'] == engine 185 | assert config['NAME'] == name 186 | 187 | if url != 'sqlite://:memory:': 188 | assert config['PORT'] == port 189 | assert config['PASSWORD'] == passwd 190 | assert config['USER'] == user 191 | assert config['HOST'] == host 192 | 193 | if engine == 'sql_server.pyodbc': 194 | assert config['OPTIONS'] == {'driver': 'ODBC Driver 13 for SQL Server'} 195 | 196 | if host == 'reconnect.com': 197 | assert config['OPTIONS'] == {'reconnect': 'true'} 198 | 199 | 200 | def test_custom_db_engine(): 201 | """Override ENGINE determined from schema.""" 202 | env_url = 'postgres://enigma:secret@example.com:5431/dbname' 203 | 204 | engine = 'mypackage.backends.whatever' 205 | url = Env.db_url_config(env_url, engine=engine) 206 | 207 | assert url['ENGINE'] == engine 208 | 209 | 210 | def test_postgres_complex_db_name_parsing(): 211 | """Make sure we can use complex postgres host.""" 212 | env_url = ( 213 | 'postgres://user:password@//cloudsql/' 214 | 'project-1234:us-central1:instance/dbname' 215 | ) 216 | 217 | url = Env.db_url_config(env_url) 218 | 219 | assert url['ENGINE'] == DJANGO_POSTGRES 220 | assert url['HOST'] == '/cloudsql/project-1234:us-central1:instance' 221 | assert url['NAME'] == 'dbname' 222 | assert url['USER'] == 'user' 223 | assert url['PASSWORD'] == 'password' 224 | assert url['PORT'] == '' 225 | 226 | 227 | @pytest.mark.parametrize( 228 | 'scheme', 229 | ['postgres', 'postgresql', 'psql', 'pgsql', 'postgis'], 230 | ) 231 | def test_postgres_like_scheme_parsing(scheme): 232 | """Verify all the postgres-like schemes parsed the same as postgres.""" 233 | env_url1 = ( 234 | 'postgres://user:password@//cloudsql/' 235 | 'project-1234:us-central1:instance/dbname' 236 | ) 237 | env_url2 = ( 238 | '{}://user:password@//cloudsql/' 239 | 'project-1234:us-central1:instance/dbname' 240 | ).format(scheme) 241 | 242 | url1 = Env.db_url_config(env_url1) 243 | url2 = Env.db_url_config(env_url2) 244 | 245 | assert url2['NAME'] == url1['NAME'] 246 | assert url2['PORT'] == url1['PORT'] 247 | assert url2['PASSWORD'] == url1['PASSWORD'] 248 | assert url2['USER'] == url1['USER'] 249 | assert url2['HOST'] == url1['HOST'] 250 | 251 | if scheme == 'postgis': 252 | assert url2['ENGINE'] == 'django.contrib.gis.db.backends.postgis' 253 | else: 254 | assert url2['ENGINE'] == url1['ENGINE'] 255 | 256 | 257 | def test_memory_sqlite_url_warns_about_netloc(recwarn): 258 | warnings.simplefilter("always") 259 | 260 | url = 'sqlite://missing-slash-path' 261 | url = Env.db_url_config(url) 262 | 263 | assert len(recwarn) == 1 264 | assert recwarn.pop(UserWarning) 265 | 266 | assert url['ENGINE'] == 'django.db.backends.sqlite3' 267 | assert url['NAME'] == ':memory:' 268 | 269 | 270 | def test_database_options_parsing(): 271 | url = 'postgres://user:pass@host:1234/dbname?conn_max_age=600' 272 | url = Env.db_url_config(url) 273 | assert url['CONN_MAX_AGE'] == 600 274 | 275 | url = ('postgres://user:pass@host:1234/dbname?' 276 | 'conn_max_age=None&autocommit=True&atomic_requests=False') 277 | url = Env.db_url_config(url) 278 | assert url['CONN_MAX_AGE'] is None 279 | assert url['AUTOCOMMIT'] is True 280 | assert url['ATOMIC_REQUESTS'] is False 281 | 282 | url = ('mysql://user:pass@host:1234/dbname?init_command=SET ' 283 | 'storage_engine=INNODB') 284 | url = Env.db_url_config(url) 285 | assert url['OPTIONS'] == { 286 | 'init_command': 'SET storage_engine=INNODB', 287 | } 288 | -------------------------------------------------------------------------------- /tests/test_email.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | from environ import Env 10 | 11 | 12 | def test_smtp_parsing(): 13 | url = 'smtps://user@domain.com:password@smtp.example.com:587' 14 | url = Env.email_url_config(url) 15 | 16 | assert len(url) == 7 17 | 18 | assert url['EMAIL_BACKEND'] == 'django.core.mail.backends.smtp.EmailBackend' 19 | assert url['EMAIL_HOST'] == 'smtp.example.com' 20 | assert url['EMAIL_HOST_PASSWORD'] == 'password' 21 | assert url['EMAIL_HOST_USER'] == 'user@domain.com' 22 | assert url['EMAIL_PORT'] == 587 23 | assert url['EMAIL_USE_TLS'] is True 24 | assert url['EMAIL_FILE_PATH'] == '' 25 | 26 | 27 | def test_custom_email_backend(): 28 | """Override EMAIL_BACKEND determined from schema.""" 29 | url = 'smtps://user@domain.com:password@smtp.example.com:587' 30 | 31 | backend = 'mypackage.backends.whatever' 32 | url = Env.email_url_config(url, backend=backend) 33 | 34 | assert url['EMAIL_BACKEND'] == backend 35 | -------------------------------------------------------------------------------- /tests/test_env.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import os 10 | import tempfile 11 | from urllib.parse import quote 12 | 13 | import pytest 14 | 15 | from environ import Env, Path 16 | from environ.compat import ( 17 | DJANGO_POSTGRES, 18 | ImproperlyConfigured, 19 | REDIS_DRIVER, 20 | ) 21 | from .asserts import assert_type_and_value 22 | from .fixtures import FakeEnv 23 | 24 | 25 | @pytest.mark.parametrize( 26 | 'variable,value,raw_value,parse_comments', 27 | [ 28 | # parse_comments=True 29 | ('BOOL_TRUE_STRING_LIKE_BOOL_WITH_COMMENT', 'True', "'True' # comment\n", True), 30 | ('BOOL_TRUE_BOOL_WITH_COMMENT', 'True ', "True # comment\n", True), 31 | ('STR_QUOTED_IGNORE_COMMENT', 'foo', " 'foo' # comment\n", True), 32 | ('STR_QUOTED_INCLUDE_HASH', 'foo # with hash', "'foo # with hash' # not comment\n", True), 33 | ('SECRET_KEY_1', '"abc', '"abc#def"\n', True), 34 | ('SECRET_KEY_2', 'abc', 'abc#def\n', True), 35 | ('SECRET_KEY_3', 'abc#def', "'abc#def'\n", True), 36 | 37 | # parse_comments=False 38 | ('BOOL_TRUE_STRING_LIKE_BOOL_WITH_COMMENT', "'True' # comment", "'True' # comment\n", False), 39 | ('BOOL_TRUE_BOOL_WITH_COMMENT', 'True # comment', "True # comment\n", False), 40 | ('STR_QUOTED_IGNORE_COMMENT', " 'foo' # comment", " 'foo' # comment\n", False), 41 | ('STR_QUOTED_INCLUDE_HASH', "'foo # with hash' # not comment", "'foo # with hash' # not comment\n", False), 42 | ('SECRET_KEY_1', 'abc#def', '"abc#def"\n', False), 43 | ('SECRET_KEY_2', 'abc#def', 'abc#def\n', False), 44 | ('SECRET_KEY_3', 'abc#def', "'abc#def'\n", False), 45 | 46 | # parse_comments is not defined (default behavior) 47 | ('BOOL_TRUE_STRING_LIKE_BOOL_WITH_COMMENT', "'True' # comment", "'True' # comment\n", None), 48 | ('BOOL_TRUE_BOOL_WITH_COMMENT', 'True # comment', "True # comment\n", None), 49 | ('STR_QUOTED_IGNORE_COMMENT', " 'foo' # comment", " 'foo' # comment\n", None), 50 | ('STR_QUOTED_INCLUDE_HASH', "'foo # with hash' # not comment", "'foo # with hash' # not comment\n", None), 51 | ('SECRET_KEY_1', 'abc#def', '"abc#def"\n', None), 52 | ('SECRET_KEY_2', 'abc#def', 'abc#def\n', None), 53 | ('SECRET_KEY_3', 'abc#def', "'abc#def'\n", None), 54 | ], 55 | ) 56 | def test_parse_comments(variable, value, raw_value, parse_comments): 57 | old_environ = os.environ 58 | 59 | with tempfile.TemporaryDirectory() as temp_dir: 60 | env_path = os.path.join(temp_dir, '.env') 61 | 62 | with open(env_path, 'w') as f: 63 | f.write(f'{variable}={raw_value}\n') 64 | f.flush() 65 | 66 | env = Env() 67 | Env.ENVIRON = {} 68 | if parse_comments is None: 69 | env.read_env(env_path) 70 | else: 71 | env.read_env(env_path, parse_comments=parse_comments) 72 | 73 | assert env(variable) == value 74 | 75 | os.environ = old_environ 76 | 77 | 78 | class TestEnv: 79 | def setup_method(self, method): 80 | """ 81 | Setup environment variables. 82 | 83 | Setup any state tied to the execution of the given method in a 84 | class. setup_method is invoked for every test method of a class. 85 | """ 86 | self.old_environ = os.environ 87 | os.environ = Env.ENVIRON = FakeEnv.generate_data() 88 | self.env = Env() 89 | 90 | def teardown_method(self, method): 91 | """ 92 | Rollback environment variables. 93 | 94 | Teardown any state that was previously setup with a setup_method call. 95 | """ 96 | assert self.old_environ is not None 97 | os.environ = self.old_environ 98 | 99 | def test_not_present_with_default(self): 100 | assert self.env('not_present', default=3) == 3 101 | 102 | def test_not_present_without_default(self): 103 | with pytest.raises(ImproperlyConfigured) as excinfo: 104 | self.env('not_present') 105 | assert str(excinfo.value) == 'Set the not_present environment variable' 106 | assert excinfo.value.__cause__ is not None 107 | 108 | def test_contains(self): 109 | assert 'STR_VAR' in self.env 110 | assert 'EMPTY_LIST' in self.env 111 | assert 'I_AM_NOT_A_VAR' not in self.env 112 | 113 | @pytest.mark.parametrize( 114 | 'var,val,multiline', 115 | [ 116 | ('STR_VAR', 'bar', False), 117 | ('MULTILINE_STR_VAR', 'foo\\nbar', False), 118 | ('MULTILINE_STR_VAR', 'foo\nbar', True), 119 | ('MULTILINE_QUOTED_STR_VAR', '---BEGIN---\\r\\n---END---', False), 120 | ('MULTILINE_QUOTED_STR_VAR', '---BEGIN---\n---END---', True), 121 | ('MULTILINE_ESCAPED_STR_VAR', '---BEGIN---\\\\n---END---', False), 122 | ('MULTILINE_ESCAPED_STR_VAR', '---BEGIN---\\\n---END---', True), 123 | ], 124 | ) 125 | def test_str(self, var, val, multiline): 126 | assert isinstance(self.env(var), str) 127 | if not multiline: 128 | assert self.env(var) == val 129 | assert self.env.str(var, multiline=multiline) == val 130 | 131 | @pytest.mark.parametrize( 132 | 'var,val,default', 133 | [ 134 | ('STR_VAR', b'bar', Env.NOTSET), 135 | ('NON_EXISTENT_BYTES_VAR', b'some-default', b'some-default'), 136 | ('NON_EXISTENT_STR_VAR', b'some-default', 'some-default'), 137 | ] 138 | ) 139 | def test_bytes(self, var, val, default): 140 | assert_type_and_value(bytes, val, self.env.bytes(var, default=default)) 141 | 142 | def test_int(self): 143 | assert_type_and_value(int, 42, self.env('INT_VAR', cast=int)) 144 | assert_type_and_value(int, 42, self.env.int('INT_VAR')) 145 | 146 | def test_int_with_none_default(self): 147 | assert self.env('NOT_PRESENT_VAR', cast=int, default=None) is None 148 | assert self.env('EMPTY_INT_VAR', cast=int, default=None) is None 149 | 150 | @pytest.mark.parametrize( 151 | 'value,variable', 152 | [ 153 | (33.3, 'FLOAT_VAR'), 154 | (33.3, 'FLOAT_COMMA_VAR'), 155 | (123420333.3, 'FLOAT_STRANGE_VAR1'), 156 | (123420333.3, 'FLOAT_STRANGE_VAR2'), 157 | (-1.0, 'FLOAT_NEGATIVE_VAR'), 158 | ] 159 | ) 160 | def test_float(self, value, variable): 161 | assert_type_and_value(float, value, self.env.float(variable)) 162 | assert_type_and_value(float, value, self.env(variable, cast=float)) 163 | 164 | @pytest.mark.parametrize( 165 | 'value,variable', 166 | [ 167 | (True, 'BOOL_TRUE_STRING_LIKE_INT'), 168 | (True, 'BOOL_TRUE_STRING_LIKE_BOOL'), 169 | (True, 'BOOL_TRUE_INT'), 170 | (True, 'BOOL_TRUE_BOOL'), 171 | (True, 'BOOL_TRUE_STRING_1'), 172 | (True, 'BOOL_TRUE_STRING_2'), 173 | (True, 'BOOL_TRUE_STRING_3'), 174 | (True, 'BOOL_TRUE_STRING_4'), 175 | (True, 'BOOL_TRUE_STRING_5'), 176 | (False, 'BOOL_FALSE_STRING_LIKE_INT'), 177 | (False, 'BOOL_FALSE_INT'), 178 | (False, 'BOOL_FALSE_STRING_LIKE_BOOL'), 179 | (False, 'BOOL_FALSE_BOOL'), 180 | ] 181 | ) 182 | def test_bool_true(self, value, variable): 183 | assert_type_and_value(bool, value, self.env.bool(variable)) 184 | assert_type_and_value(bool, value, self.env(variable, cast=bool)) 185 | 186 | def test_proxied_value(self): 187 | assert self.env('PROXIED_VAR') == 'bar' 188 | 189 | def test_escaped_dollar_sign(self): 190 | self.env.escape_proxy = True 191 | assert self.env('ESCAPED_VAR') == '$baz' 192 | 193 | def test_escaped_dollar_sign_disabled(self): 194 | self.env.escape_proxy = False 195 | assert self.env('ESCAPED_VAR') == r'\$baz' 196 | 197 | def test_int_list(self): 198 | assert_type_and_value(list, [42, 33], self.env('INT_LIST', cast=[int])) 199 | assert_type_and_value(list, [42, 33], self.env.list('INT_LIST', int)) 200 | 201 | def test_int_list_cast_tuple(self): 202 | assert_type_and_value(tuple, (42, 33), self.env('INT_LIST', cast=(int,))) 203 | assert_type_and_value(tuple, (42, 33), self.env.tuple('INT_LIST', int)) 204 | assert_type_and_value(tuple, ('42', '33'), self.env.tuple('INT_LIST')) 205 | 206 | def test_int_tuple(self): 207 | assert_type_and_value(tuple, (42, 33), self.env('INT_TUPLE', cast=(int,))) 208 | assert_type_and_value(tuple, (42, 33), self.env.tuple('INT_TUPLE', int)) 209 | assert_type_and_value(tuple, ('42', '33'), self.env.tuple('INT_TUPLE')) 210 | 211 | def test_mix_tuple_issue_387(self): 212 | """Cast a tuple of mixed types. 213 | 214 | Casts a string like "(42,Test)" to a tuple like (42, 'Test'). 215 | See: https://github.com/joke2k/django-environ/issues/387 for details.""" 216 | assert_type_and_value( 217 | tuple, 218 | (42, 'Test'), 219 | self.env( 220 | 'MIX_TUPLE', 221 | default=(0, ''), 222 | cast=lambda t: tuple( 223 | map( 224 | lambda v: int(v) if v.isdigit() else v.strip(), 225 | [c for c in t.strip('()').split(',')] 226 | ) 227 | ), 228 | ) 229 | ) 230 | 231 | def test_str_list_with_spaces(self): 232 | assert_type_and_value(list, [' foo', ' spaces'], 233 | self.env('STR_LIST_WITH_SPACES', cast=[str])) 234 | assert_type_and_value(list, [' foo', ' spaces'], 235 | self.env.list('STR_LIST_WITH_SPACES')) 236 | 237 | def test_empty_list(self): 238 | assert_type_and_value(list, [], self.env('EMPTY_LIST', cast=[int])) 239 | 240 | def test_dict_value(self): 241 | assert_type_and_value(dict, FakeEnv.DICT, self.env.dict('DICT_VAR')) 242 | assert_type_and_value(dict, FakeEnv.DICT_WITH_EQ, self.env.dict('DICT_WITH_EQ_VAR')) 243 | 244 | def test_complex_dict_value(self): 245 | assert_type_and_value( 246 | dict, 247 | FakeEnv.SAML_ATTRIBUTE_MAPPING, 248 | self.env.dict('SAML_ATTRIBUTE_MAPPING', cast={'value': tuple}) 249 | ) 250 | 251 | @pytest.mark.parametrize( 252 | 'value,cast,expected', 253 | [ 254 | ('a=1', dict, {'a': '1'}), 255 | ('a=1', dict(value=int), {'a': 1}), 256 | ('a=1', dict(value=float), {'a': 1.0}), 257 | ('a=1,2,3', dict(value=[str]), {'a': ['1', '2', '3']}), 258 | ('a=1,2,3', dict(value=[int]), {'a': [1, 2, 3]}), 259 | ('a=1;b=1.1,2.2;c=3', dict(value=int, cast=dict(b=[float])), 260 | {'a': 1, 'b': [1.1, 2.2], 'c': 3}), 261 | ('a=uname;c=http://www.google.com;b=True', 262 | dict(value=str, cast=dict(b=bool)), 263 | {'a': "uname", 'c': "http://www.google.com", 'b': True}), 264 | ], 265 | ids=[ 266 | 'dict', 267 | 'dict_int', 268 | 'dict_float', 269 | 'dict_str_list', 270 | 'dict_int_list', 271 | 'dict_int_cast', 272 | 'dict_str_cast', 273 | ], 274 | ) 275 | def test_dict_parsing(self, value, cast, expected): 276 | assert self.env.parse_value(value, cast) == expected 277 | 278 | def test_url_value(self): 279 | url = self.env.url('URL_VAR') 280 | assert url.__class__ == self.env.URL_CLASS 281 | assert url.geturl() == FakeEnv.URL 282 | assert self.env.url('OTHER_URL', default=None) is None 283 | 284 | def test_url_empty_string_default_value(self): 285 | unset_var_name = 'VARIABLE_NOT_SET_IN_ENVIRONMENT' 286 | assert unset_var_name not in os.environ 287 | url = self.env.url(unset_var_name, '') 288 | assert url.__class__ == self.env.URL_CLASS 289 | assert url.geturl() == '' 290 | 291 | def test_url_encoded_parts(self): 292 | password_with_unquoted_characters = "#password" 293 | encoded_url = "mysql://user:%s@127.0.0.1:3306/dbname" % quote( 294 | password_with_unquoted_characters 295 | ) 296 | parsed_url = self.env.db_url_config(encoded_url) 297 | assert parsed_url['PASSWORD'] == password_with_unquoted_characters 298 | 299 | @pytest.mark.parametrize( 300 | 'var,engine,name,host,user,passwd,port', 301 | [ 302 | (Env.DEFAULT_DATABASE_ENV, DJANGO_POSTGRES, 'd8r82722', 303 | 'ec2-107-21-253-135.compute-1.amazonaws.com', 'uf07k1', 304 | 'wegauwhg', 5431), 305 | ('DATABASE_MYSQL_URL', 'django.db.backends.mysql', 'heroku_97681', 306 | 'us-cdbr-east.cleardb.com', 'bea6eb0', '69772142', ''), 307 | ('DATABASE_MYSQL_GIS_URL', 'django.contrib.gis.db.backends.mysql', 308 | 'some_database', '127.0.0.1', 'user', 'password', ''), 309 | ('DATABASE_ORACLE_TNS_URL', 'django.db.backends.oracle', 'sid', '', 310 | 'user', 'password', None), 311 | ('DATABASE_ORACLE_URL', 'django.db.backends.oracle', 'sid', 'host', 312 | 'user', 'password', '1521'), 313 | ('DATABASE_REDSHIFT_URL', 'django_redshift_backend', 'dev', 314 | 'examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com', 315 | 'user', 'password', 5439), 316 | ('DATABASE_SQLITE_URL', 'django.db.backends.sqlite3', 317 | '/full/path/to/your/database/file.sqlite', '', '', '', ''), 318 | ('DATABASE_CUSTOM_BACKEND_URL', 'custom.backend', 'database', 319 | 'example.com', 'user', 'password', 5430), 320 | ('DATABASE_MYSQL_CLOUDSQL_URL', 'django.db.backends.mysql', 'mydatabase', 321 | '/cloudsql/arvore-codelab:us-central1:mysqlinstance', 'djuser', 'hidden-password', ''), 322 | ], 323 | ids=[ 324 | 'postgres', 325 | 'mysql', 326 | 'mysql_gis', 327 | 'oracle_tns', 328 | 'oracle', 329 | 'redshift', 330 | 'sqlite', 331 | 'custom', 332 | 'cloudsql', 333 | ], 334 | ) 335 | def test_db_url_value(self, var, engine, name, host, user, passwd, port): 336 | config = self.env.db(var) 337 | 338 | assert config['ENGINE'] == engine 339 | assert config['NAME'] == name 340 | assert config['HOST'] == host 341 | assert config['USER'] == user 342 | assert config['PASSWORD'] == passwd 343 | 344 | if port is None: 345 | assert 'PORT' not in config 346 | else: 347 | assert config['PORT'] == port 348 | 349 | @pytest.mark.parametrize( 350 | 'var,backend,location,options', 351 | [ 352 | (Env.DEFAULT_CACHE_ENV, 353 | 'django.core.cache.backends.memcached.MemcachedCache', 354 | '127.0.0.1:11211', None), 355 | ('CACHE_REDIS', REDIS_DRIVER, 356 | 'redis://127.0.0.1:6379/1', 357 | {'CLIENT_CLASS': 'django_redis.client.DefaultClient', 358 | 'PASSWORD': 'secret'}), 359 | ], 360 | ids=[ 361 | 'memcached', 362 | 'redis', 363 | ], 364 | ) 365 | def test_cache_url_value(self, var, backend, location, options): 366 | config = self.env.cache_url(var) 367 | 368 | assert config['BACKEND'] == backend 369 | assert config['LOCATION'] == location 370 | 371 | if options is None: 372 | assert 'OPTIONS' not in config 373 | else: 374 | assert config['OPTIONS'] == options 375 | 376 | def test_email_url_value(self): 377 | email_config = self.env.email_url() 378 | assert email_config['EMAIL_BACKEND'] == ( 379 | 'django.core.mail.backends.smtp.EmailBackend' 380 | ) 381 | assert email_config['EMAIL_HOST'] == 'smtp.example.com' 382 | assert email_config['EMAIL_HOST_PASSWORD'] == 'password' 383 | assert email_config['EMAIL_HOST_USER'] == 'user@domain.com' 384 | assert email_config['EMAIL_PORT'] == 587 385 | assert email_config['EMAIL_USE_TLS'] 386 | 387 | def test_json_value(self): 388 | assert self.env.json('JSON_VAR') == FakeEnv.JSON 389 | 390 | def test_path(self): 391 | root = self.env.path('PATH_VAR') 392 | assert_type_and_value(Path, Path(FakeEnv.PATH), root) 393 | 394 | def test_smart_cast(self): 395 | assert self.env.get_value('STR_VAR', default='string') == 'bar' 396 | assert self.env.get_value('BOOL_TRUE_STRING_LIKE_INT', default=True) 397 | assert not self.env.get_value( 398 | 'BOOL_FALSE_STRING_LIKE_INT', 399 | default=True) 400 | assert self.env.get_value('INT_VAR', default=1) == 42 401 | assert self.env.get_value('FLOAT_VAR', default=1.2) == 33.3 402 | 403 | def test_exported(self): 404 | assert self.env('EXPORTED_VAR') == FakeEnv.EXPORTED 405 | 406 | def test_prefix(self): 407 | self.env.prefix = 'PREFIX_' 408 | assert self.env('TEST') == 'foo' 409 | 410 | def test_prefix_and_not_present_without_default(self): 411 | self.env.prefix = 'PREFIX_' 412 | with pytest.raises(ImproperlyConfigured) as excinfo: 413 | self.env('not_present') 414 | assert str(excinfo.value) == 'Set the PREFIX_not_present environment variable' 415 | assert excinfo.value.__cause__ is not None 416 | 417 | 418 | class TestFileEnv(TestEnv): 419 | def setup_method(self, method): 420 | """ 421 | Setup environment variables. 422 | 423 | Setup any state tied to the execution of the given method in a 424 | class. setup_method is invoked for every test method of a class. 425 | """ 426 | super().setup_method(method) 427 | 428 | Env.ENVIRON = {} 429 | self.env.read_env( 430 | Path(__file__, is_file=True)('test_env.txt'), 431 | PATH_VAR=Path(__file__, is_file=True).__root__ 432 | ) 433 | 434 | def create_temp_env_file(self, name): 435 | import pathlib 436 | import tempfile 437 | 438 | env_file_path = (pathlib.Path(tempfile.gettempdir()) / name) 439 | try: 440 | env_file_path.unlink() 441 | except FileNotFoundError: 442 | pass 443 | 444 | assert not env_file_path.exists() 445 | return env_file_path 446 | 447 | def test_read_env_path_like(self): 448 | env_file_path = self.create_temp_env_file('test_pathlib.env') 449 | 450 | env_key = 'SECRET' 451 | env_val = 'enigma' 452 | env_str = env_key + '=' + env_val 453 | 454 | # open() doesn't take path-like on Python < 3.6 455 | with open(str(env_file_path), 'w', encoding='utf-8') as f: 456 | f.write(env_str + '\n') 457 | 458 | self.env.read_env(env_file_path) 459 | assert env_key in self.env.ENVIRON 460 | assert self.env.ENVIRON[env_key] == env_val 461 | 462 | @pytest.mark.parametrize("overwrite", [True, False]) 463 | def test_existing_overwrite(self, overwrite): 464 | env_file_path = self.create_temp_env_file('test_existing.env') 465 | with open(str(env_file_path), 'w') as f: 466 | f.write("EXISTING=b") 467 | self.env.ENVIRON['EXISTING'] = "a" 468 | self.env.read_env(env_file_path, overwrite=overwrite) 469 | assert self.env.ENVIRON["EXISTING"] == ("b" if overwrite else "a") 470 | 471 | 472 | class TestSubClass(TestEnv): 473 | def setup_method(self, method): 474 | """ 475 | Setup environment variables. 476 | 477 | Setup any state tied to the execution of the given method in a 478 | class. setup_method is invoked for every test method of a class. 479 | """ 480 | super().setup_method(method) 481 | 482 | self.CONFIG = FakeEnv.generate_data() 483 | 484 | class MyEnv(Env): 485 | ENVIRON = self.CONFIG 486 | 487 | self.env = MyEnv() 488 | 489 | def test_singleton_environ(self): 490 | assert self.CONFIG is self.env.ENVIRON 491 | -------------------------------------------------------------------------------- /tests/test_env.txt: -------------------------------------------------------------------------------- 1 | DICT_VAR=foo=bar,test=on 2 | DICT_WITH_EQ_VAR=key1=sub_key1=sub_value1,key2=value2 3 | 4 | # Database variables 5 | DATABASE_MYSQL_URL=mysql://bea6eb0:69772142@us-cdbr-east.cleardb.com/heroku_97681?reconnect=true 6 | DATABASE_MYSQL_CLOUDSQL_URL=mysql://djuser:hidden-password@//cloudsql/arvore-codelab:us-central1:mysqlinstance/mydatabase 7 | DATABASE_MYSQL_GIS_URL=mysqlgis://user:password@127.0.0.1/some_database 8 | 9 | # Cache variables 10 | CACHE_URL=memcache://127.0.0.1:11211 11 | CACHE_REDIS=rediscache://127.0.0.1:6379/1?client_class=django_redis.client.DefaultClient&password=secret 12 | 13 | # Email variables 14 | EMAIL_URL=smtps://user@domain.com:password@smtp.example.com:587 15 | 16 | # Others 17 | URL_VAR=http://www.google.com/ 18 | PATH_VAR=/home/dev 19 | BOOL_TRUE_STRING_LIKE_INT='1' 20 | BOOL_TRUE_INT=1 21 | BOOL_TRUE_STRING_LIKE_BOOL='True' 22 | BOOL_TRUE_STRING_1='on' 23 | BOOL_TRUE_STRING_2='ok' 24 | BOOL_TRUE_STRING_3='yes' 25 | BOOL_TRUE_STRING_4='y' 26 | BOOL_TRUE_STRING_5='true' 27 | BOOL_TRUE_BOOL=True 28 | BOOL_FALSE_STRING_LIKE_INT='0' 29 | BOOL_FALSE_INT=0 30 | BOOL_FALSE_STRING_LIKE_BOOL='False' 31 | BOOL_FALSE_BOOL=False 32 | DATABASE_SQLITE_URL=sqlite:////full/path/to/your/database/file.sqlite 33 | JSON_VAR={"three": 33.44, "two": 2, "one": "bar"} 34 | DATABASE_URL=postgres://uf07k1:wegauwhg@ec2-107-21-253-135.compute-1.amazonaws.com:5431/d8r82722 35 | FLOAT_VAR=33.3 36 | FLOAT_COMMA_VAR=33,3 37 | FLOAT_STRANGE_VAR1=123,420,333.3 38 | FLOAT_STRANGE_VAR2=123.420.333,3 39 | FLOAT_NEGATIVE_VAR=-1.0 40 | PROXIED_VAR=$STR_VAR 41 | ESCAPED_VAR=\$baz 42 | EMPTY_LIST= 43 | EMPTY_INT_VAR= 44 | INT_VAR=42 45 | STR_LIST_WITH_SPACES= foo, spaces 46 | STR_LIST_WITH_SPACES_QUOTED=' foo',' quoted' 47 | STR_VAR=bar 48 | MULTILINE_STR_VAR=foo\nbar 49 | MULTILINE_QUOTED_STR_VAR="---BEGIN---\r\n---END---" 50 | MULTILINE_ESCAPED_STR_VAR=---BEGIN---\\n---END--- 51 | INT_LIST=42,33 52 | CYRILLIC_VAR=фуубар 53 | INT_TUPLE=(42,33) 54 | MIX_TUPLE=(42,Test) 55 | DATABASE_ORACLE_TNS_URL=oracle://user:password@sid 56 | DATABASE_ORACLE_URL=oracle://user:password@host:1521/sid 57 | DATABASE_REDSHIFT_URL=redshift://user:password@examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev 58 | DATABASE_CUSTOM_BACKEND_URL=custom.backend://user:password@example.com:5430/database 59 | 60 | # Djangosaml2's SAML_ATTRIBUTE_MAPPING 61 | SAML_ATTRIBUTE_MAPPING="uid=username;mail=email;cn=first_name;sn=last_name;" 62 | 63 | # Exports 64 | export EXPORTED_VAR="exported var" 65 | 66 | # Prefixed 67 | PREFIX_TEST='foo' 68 | -------------------------------------------------------------------------------- /tests/test_fileaware.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import os 10 | import tempfile 11 | from contextlib import contextmanager 12 | 13 | import pytest 14 | 15 | import environ 16 | 17 | 18 | @contextmanager 19 | def make_temp_file(text): 20 | with tempfile.NamedTemporaryFile("w", delete=False) as f: 21 | f.write(text) 22 | f.close() 23 | try: 24 | yield f.name 25 | finally: 26 | if os.path.exists(f.name): 27 | os.unlink(f.name) 28 | 29 | 30 | @pytest.fixture 31 | def tmp_f(): 32 | with make_temp_file(text="fish") as f_name: 33 | yield f_name 34 | 35 | 36 | def test_mapping(tmp_f): 37 | env = environ.FileAwareMapping(env={"ANIMAL_FILE": tmp_f}) 38 | assert env["ANIMAL"] == "fish" 39 | 40 | 41 | def test_precidence(tmp_f): 42 | env = environ.FileAwareMapping( 43 | env={ 44 | "ANIMAL_FILE": tmp_f, 45 | "ANIMAL": "cat", 46 | } 47 | ) 48 | assert env["ANIMAL"] == "fish" 49 | 50 | 51 | def test_missing_file_raises_exception(): 52 | env = environ.FileAwareMapping(env={"ANIMAL_FILE": "non-existant-file"}) 53 | with pytest.raises(FileNotFoundError): 54 | env["ANIMAL"] 55 | 56 | 57 | def test_iter(): 58 | env = environ.FileAwareMapping( 59 | env={ 60 | "ANIMAL_FILE": "some-file", 61 | "VEGETABLE": "leek", 62 | "VEGETABLE_FILE": "some-vegetable-file", 63 | } 64 | ) 65 | keys = set(env) 66 | assert keys == {"ANIMAL_FILE", "ANIMAL", "VEGETABLE", "VEGETABLE_FILE"} 67 | assert "ANIMAL" in keys 68 | 69 | 70 | def test_len(): 71 | env = environ.FileAwareMapping( 72 | env={ 73 | "ANIMAL_FILE": "some-file", 74 | "VEGETABLE": "leek", 75 | "VEGETABLE_FILE": "some-vegetable-file", 76 | } 77 | ) 78 | assert len(env) == 4 79 | 80 | 81 | def test_cache(tmp_f): 82 | env = environ.FileAwareMapping(env={"ANIMAL_FILE": tmp_f}) 83 | assert env["ANIMAL"] == "fish" 84 | 85 | with open(tmp_f, "w") as f: 86 | f.write("cat") 87 | assert env["ANIMAL"] == "fish" 88 | 89 | os.unlink(tmp_f) 90 | assert not os.path.exists(env["ANIMAL_FILE"]) 91 | assert env["ANIMAL"] == "fish" 92 | 93 | 94 | def test_no_cache(tmp_f): 95 | env = environ.FileAwareMapping( 96 | cache=False, 97 | env={"ANIMAL_FILE": tmp_f}, 98 | ) 99 | assert env["ANIMAL"] == "fish" 100 | 101 | with open(tmp_f, "w") as f: 102 | f.write("cat") 103 | assert env["ANIMAL"] == "cat" 104 | 105 | os.unlink(tmp_f) 106 | assert not os.path.exists(env["ANIMAL_FILE"]) 107 | with pytest.raises(FileNotFoundError): 108 | assert env["ANIMAL"] 109 | 110 | 111 | def test_setdefault(tmp_f): 112 | env = environ.FileAwareMapping(env={"ANIMAL_FILE": tmp_f}) 113 | assert env.setdefault("FRUIT", "apple") == "apple" 114 | assert env.setdefault("ANIMAL", "cat") == "fish" 115 | assert env.env == {"ANIMAL_FILE": tmp_f, "FRUIT": "apple"} 116 | 117 | 118 | class TestDelItem: 119 | def test_del_key(self): 120 | env = environ.FileAwareMapping(env={"FRUIT": "apple"}) 121 | del env["FRUIT"] 122 | with pytest.raises(KeyError): 123 | env["FRUIT"] 124 | 125 | def test_del_key_with_file_key(self): 126 | env = environ.FileAwareMapping(env={"ANIMAL_FILE": "some-file"}) 127 | del env["ANIMAL"] 128 | with pytest.raises(KeyError): 129 | env["ANIMAL"] 130 | 131 | def test_del_shadowed_key_with_file_key(self): 132 | env = environ.FileAwareMapping( 133 | env={"ANIMAL_FILE": "some-file", "ANIMAL": "cat"} 134 | ) 135 | del env["ANIMAL"] 136 | with pytest.raises(KeyError): 137 | env["ANIMAL"] 138 | 139 | def test_del_file_key(self): 140 | env = environ.FileAwareMapping( 141 | env={ 142 | "ANIMAL_FILE": "some-file", 143 | "ANIMAL": "fish", 144 | } 145 | ) 146 | del env["ANIMAL_FILE"] 147 | assert env["ANIMAL"] == "fish" 148 | 149 | def test_del_file_key_clears_cache(self, tmp_f): 150 | env = environ.FileAwareMapping( 151 | env={ 152 | "ANIMAL_FILE": tmp_f, 153 | "ANIMAL": "cat", 154 | } 155 | ) 156 | assert env["ANIMAL"] == "fish" 157 | del env["ANIMAL_FILE"] 158 | assert env["ANIMAL"] == "cat" 159 | 160 | 161 | class TestSetItem: 162 | def test_set_key(self): 163 | env = environ.FileAwareMapping(env={"FRUIT": "apple"}) 164 | env["FRUIT"] = "banana" 165 | assert env["FRUIT"] == "banana" 166 | 167 | def test_cant_override_key_with_file_key(self, tmp_f): 168 | env = environ.FileAwareMapping( 169 | env={ 170 | "FRUIT": "apple", 171 | "FRUIT_FILE": tmp_f, 172 | } 173 | ) 174 | with open(tmp_f, "w") as f: 175 | f.write("banana") 176 | env["FRUIT"] = "cucumber" 177 | assert env["FRUIT"] == "banana" 178 | 179 | def test_set_file_key(self, tmp_f): 180 | env = environ.FileAwareMapping(env={"ANIMAL": "cat"}) 181 | env["ANIMAL_FILE"] = tmp_f 182 | assert env["ANIMAL"] == "fish" 183 | 184 | def test_change_file_key_clears_cache(self, tmp_f): 185 | env = environ.FileAwareMapping(env={"ANIMAL_FILE": tmp_f}) 186 | assert env["ANIMAL"] == "fish" 187 | with make_temp_file(text="cat") as new_tmp_f: 188 | env["ANIMAL_FILE"] = new_tmp_f 189 | assert env["ANIMAL"] == "cat" 190 | -------------------------------------------------------------------------------- /tests/test_path.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import os 10 | import sys 11 | 12 | import pytest 13 | 14 | 15 | from environ import Path 16 | from environ.compat import ImproperlyConfigured 17 | 18 | 19 | def test_str(volume): 20 | root = Path('/home') 21 | 22 | if sys.platform == 'win32': 23 | assert str(root) == '{}home'.format(volume) 24 | assert str(root()) == '{}home'.format(volume) 25 | assert str(root('dev')) == '{}home\\dev'.format(volume) 26 | else: 27 | assert str(root) == '/home' 28 | assert str(root()) == '/home' 29 | assert str(root('dev')) == '/home/dev' 30 | 31 | 32 | def test_path_class(): 33 | root = Path(__file__, '..', is_file=True) 34 | root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) 35 | 36 | assert root() == root_path 37 | assert root.__root__ == root_path 38 | 39 | web = root.path('public') 40 | assert web() == os.path.join(root_path, 'public') 41 | assert web('css') == os.path.join(root_path, 'public', 'css') 42 | 43 | 44 | def test_repr(volume): 45 | root = Path('/home') 46 | if sys.platform == 'win32': 47 | assert root.__repr__() == ''.format(volume) 48 | else: 49 | assert root.__repr__() == '' 50 | 51 | 52 | def test_comparison(volume): 53 | root = Path('/home') 54 | assert root.__eq__(Path('/home')) 55 | assert root in Path('/') 56 | assert root not in Path('/other/path') 57 | 58 | assert Path('/home') == Path('/home') 59 | assert Path('/home') != Path('/home/dev') 60 | 61 | assert Path('/home/foo/').rfind('/') == str(Path('/home/foo')).rfind('/') 62 | assert Path('/home/foo/').find('/home') == str(Path('/home/foo/')).find('/home') 63 | assert Path('/home/foo/')[1] == str(Path('/home/foo/'))[1] 64 | assert Path('/home/foo/').__fspath__() == str(Path('/home/foo/')) 65 | assert ~Path('/home') == Path('/') 66 | 67 | if sys.platform == 'win32': 68 | assert Path('/home') == '{}home'.format(volume) 69 | assert '{}home'.format(volume) == Path('/home') 70 | else: 71 | assert Path('/home') == '/home' 72 | assert '/home' == Path('/home') 73 | 74 | assert Path('/home') != '/usr' 75 | 76 | 77 | def test_sum(): 78 | """Make sure Path correct handle __add__.""" 79 | assert Path('/') + 'home' == Path('/home') 80 | assert Path('/') + '/home/public' == Path('/home/public') 81 | 82 | 83 | def test_subtraction(): 84 | """Make sure Path correct handle __sub__.""" 85 | assert Path('/home/dev/public') - 2 == Path('/home') 86 | assert Path('/home/dev/public') - 'public' == Path('/home/dev') 87 | 88 | 89 | def test_subtraction_not_int(): 90 | """Subtraction with an invalid type should raise TypeError.""" 91 | with pytest.raises(TypeError) as excinfo: 92 | Path('/home/dev/') - 'not int' 93 | assert str(excinfo.value) == ( 94 | "unsupported operand type(s) for -: '' " 95 | "and '' unless value of " 96 | "ends with value of " 97 | ) 98 | 99 | 100 | def test_required_path(): 101 | root = Path('/home') 102 | with pytest.raises(ImproperlyConfigured) as excinfo: 103 | root('dev', 'not_existing_dir', required=True) 104 | assert "Create required path:" in str(excinfo.value) 105 | 106 | with pytest.raises(ImproperlyConfigured) as excinfo: 107 | Path('/not/existing/path/', required=True) 108 | assert "Create required path:" in str(excinfo.value) 109 | 110 | 111 | def test_complex_manipulation(volume): 112 | root = Path('/home') 113 | public = root.path('public') 114 | assets, scripts = public.path('assets'), public.path('assets', 'scripts') 115 | 116 | if sys.platform == 'win32': 117 | assert public.__repr__() == ''.format(volume) 118 | assert str(public.root) == '{}home\\public'.format(volume) 119 | assert str(public('styles')) == '{}home\\public\\styles'.format(volume) 120 | assert str(assets.root) == '{}home\\public\\assets'.format(volume) 121 | assert str(scripts.root) == '{}home\\public\\assets\\scripts'.format( 122 | volume 123 | ) 124 | 125 | assert (~assets).__repr__() == ''.format( 126 | volume 127 | ) 128 | assert str(assets + 'styles') == ( 129 | '{}home\\public\\assets\\styles'.format(volume) 130 | ) 131 | assert (assets + 'styles').__repr__() == ( 132 | ''.format(volume) 133 | ) 134 | else: 135 | assert public.__repr__() == '' 136 | assert str(public.root) == '/home/public' 137 | assert str(public('styles')) == '/home/public/styles' 138 | assert str(assets.root) == '/home/public/assets' 139 | assert str(scripts.root) == '/home/public/assets/scripts' 140 | 141 | assert str(assets + 'styles') == '/home/public/assets/styles' 142 | assert (~assets).__repr__() == '' 143 | assert (assets + 'styles').__repr__() == ( 144 | '' 145 | ) 146 | -------------------------------------------------------------------------------- /tests/test_schema.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import os 10 | 11 | from environ import Env 12 | from .fixtures import FakeEnv 13 | 14 | _old_environ = None 15 | 16 | 17 | def setup_module(): 18 | """Setup environment variables to the execution for the current module.""" 19 | global _old_environ 20 | 21 | _old_environ = os.environ 22 | os.environ = Env.ENVIRON = FakeEnv.generate_data() 23 | 24 | 25 | def teardown_module(): 26 | """Restore environment variables was previously setup in setup_module.""" 27 | global _old_environ 28 | 29 | assert _old_environ is not None 30 | os.environ = _old_environ 31 | 32 | 33 | def test_schema(): 34 | env = Env(INT_VAR=int, NOT_PRESENT_VAR=(float, 33.3), STR_VAR=str, 35 | INT_LIST=[int], DEFAULT_LIST=([int], [2])) 36 | 37 | assert isinstance(env('INT_VAR'), int) 38 | assert env('INT_VAR') == 42 39 | 40 | assert isinstance(env('NOT_PRESENT_VAR'), float) 41 | assert env('NOT_PRESENT_VAR') == 33.3 42 | 43 | assert 'bar' == env('STR_VAR') 44 | assert 'foo' == env('NOT_PRESENT2', default='foo') 45 | 46 | assert isinstance(env('INT_LIST'), list) 47 | assert env('INT_LIST') == [42, 33] 48 | 49 | assert isinstance(env('DEFAULT_LIST'), list) 50 | assert env('DEFAULT_LIST') == [2] 51 | 52 | # Override schema in this one case 53 | assert isinstance(env('INT_VAR', cast=str), str) 54 | assert env('INT_VAR', cast=str) == '42' 55 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import pytest 10 | 11 | from environ import Env 12 | 13 | 14 | def test_solr_parsing(solr_url): 15 | url = Env.search_url_config(solr_url) 16 | 17 | assert len(url) == 2 18 | assert url['ENGINE'] == 'haystack.backends.solr_backend.SolrEngine' 19 | assert url['URL'] == 'http://127.0.0.1:8983/solr' 20 | 21 | 22 | def test_solr_multicore_parsing(solr_url): 23 | timeout = 360 24 | index = 'solr_index' 25 | url = '{}/{}?TIMEOUT={}'.format(solr_url, index, timeout) 26 | url = Env.search_url_config(url) 27 | 28 | assert url['ENGINE'] == 'haystack.backends.solr_backend.SolrEngine' 29 | assert url['URL'] == 'http://127.0.0.1:8983/solr/solr_index' 30 | assert url['TIMEOUT'] == timeout 31 | assert 'INDEX_NAME' not in url 32 | assert 'PATH' not in url 33 | 34 | 35 | @pytest.mark.parametrize( 36 | 'url,engine,scheme', 37 | [ 38 | ('elasticsearch://127.0.0.1:9200/index', 39 | 'elasticsearch_backend.ElasticsearchSearchEngine', 40 | 'http',), 41 | ('elasticsearchs://127.0.0.1:9200/index', 42 | 'elasticsearch_backend.ElasticsearchSearchEngine', 43 | 'https',), 44 | ('elasticsearch2://127.0.0.1:9200/index', 45 | 'elasticsearch2_backend.Elasticsearch2SearchEngine', 46 | 'http',), 47 | ('elasticsearch2s://127.0.0.1:9200/index', 48 | 'elasticsearch2_backend.Elasticsearch2SearchEngine', 49 | 'https',), 50 | ('elasticsearch5://127.0.0.1:9200/index', 51 | 'elasticsearch5_backend.Elasticsearch5SearchEngine', 52 | 'http'), 53 | ('elasticsearch5s://127.0.0.1:9200/index', 54 | 'elasticsearch5_backend.Elasticsearch5SearchEngine', 55 | 'https'), 56 | ('elasticsearch7://127.0.0.1:9200/index', 57 | 'elasticsearch7_backend.Elasticsearch7SearchEngine', 58 | 'http'), 59 | ('elasticsearch7s://127.0.0.1:9200/index', 60 | 'elasticsearch7_backend.Elasticsearch7SearchEngine', 61 | 'https'), 62 | ], 63 | ids=[ 64 | 'elasticsearch', 65 | 'elasticsearchs', 66 | 'elasticsearch2', 67 | 'elasticsearch2s', 68 | 'elasticsearch5', 69 | 'elasticsearch5s', 70 | 'elasticsearch7', 71 | 'elasticsearch7s', 72 | ] 73 | ) 74 | def test_elasticsearch_parsing(url, engine, scheme): 75 | """Ensure all supported Elasticsearch engines are recognized.""" 76 | timeout = 360 77 | url = '{}?TIMEOUT={}'.format(url, timeout) 78 | url = Env.search_url_config(url) 79 | 80 | assert url['ENGINE'] == 'haystack.backends.{}'.format(engine) 81 | assert 'INDEX_NAME' in url.keys() 82 | assert url['INDEX_NAME'] == 'index' 83 | assert 'TIMEOUT' in url.keys() 84 | assert url['TIMEOUT'] == timeout 85 | assert 'PATH' not in url 86 | assert url["URL"].startswith(scheme + ":") 87 | 88 | 89 | def test_custom_search_engine(): 90 | """Override ENGINE determined from schema.""" 91 | env_url = 'elasticsearch://127.0.0.1:9200/index' 92 | 93 | engine = 'mypackage.backends.whatever' 94 | url = Env.db_url_config(env_url, engine=engine) 95 | 96 | assert url['ENGINE'] == engine 97 | 98 | 99 | @pytest.mark.parametrize('storage', ['file', 'ram']) 100 | def test_whoosh_parsing(whoosh_url, storage): 101 | post_limit = 128 * 1024 * 1024 102 | url = '{}?STORAGE={}&POST_LIMIT={}'.format(whoosh_url, storage, post_limit) 103 | url = Env.search_url_config(url) 104 | 105 | assert url['ENGINE'] == 'haystack.backends.whoosh_backend.WhooshEngine' 106 | assert 'PATH' in url.keys() 107 | assert url['PATH'] == '/home/search/whoosh_index' 108 | assert 'STORAGE' in url.keys() 109 | assert url['STORAGE'] == storage 110 | assert 'POST_LIMIT' in url.keys() 111 | assert url['POST_LIMIT'] == post_limit 112 | assert 'INDEX_NAME' not in url 113 | 114 | 115 | @pytest.mark.parametrize('flags', ['myflags']) 116 | def test_xapian_parsing(xapian_url, flags): 117 | url = '{}?FLAGS={}'.format(xapian_url, flags) 118 | url = Env.search_url_config(url) 119 | 120 | assert url['ENGINE'] == 'haystack.backends.xapian_backend.XapianEngine' 121 | assert 'PATH' in url.keys() 122 | assert url['PATH'] == '/home/search/xapian_index' 123 | assert 'FLAGS' in url.keys() 124 | assert url['FLAGS'] == flags 125 | assert 'INDEX_NAME' not in url 126 | 127 | 128 | def test_simple_parsing(simple_url): 129 | url = Env.search_url_config(simple_url) 130 | 131 | assert url['ENGINE'] == 'haystack.backends.simple_backend.SimpleEngine' 132 | assert 'INDEX_NAME' not in url 133 | assert 'PATH' not in url 134 | 135 | 136 | def test_common_args_parsing(search_url): 137 | excluded_indexes = 'myapp.indexes.A,myapp.indexes.B' 138 | include_spelling = 1 139 | batch_size = 100 140 | params = 'EXCLUDED_INDEXES={}&INCLUDE_SPELLING={}&BATCH_SIZE={}'.format( 141 | excluded_indexes, 142 | include_spelling, 143 | batch_size 144 | ) 145 | 146 | url = '?'.join([search_url, params]) 147 | url = Env.search_url_config(url) 148 | 149 | assert 'EXCLUDED_INDEXES' in url.keys() 150 | assert 'myapp.indexes.A' in url['EXCLUDED_INDEXES'] 151 | assert 'myapp.indexes.B' in url['EXCLUDED_INDEXES'] 152 | assert 'INCLUDE_SPELLING' in url.keys() 153 | assert url['INCLUDE_SPELLING'] 154 | assert 'BATCH_SIZE' in url.keys() 155 | assert url['BATCH_SIZE'] == 100 156 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | import pytest 10 | 11 | from environ.environ import _cast, _cast_urlstr 12 | 13 | 14 | @pytest.mark.parametrize( 15 | 'literal', 16 | ['anything-', 'anything*', '*anything', 'anything.', 17 | 'anything.1', '(anything', 'anything-v1.2', 'anything-1.2', 'anything='] 18 | ) 19 | def test_cast(literal): 20 | """Safely evaluate a string containing an invalid Python literal. 21 | 22 | See https://github.com/joke2k/django-environ/issues/200 for details.""" 23 | assert _cast(literal) == literal 24 | 25 | 26 | @pytest.mark.parametrize( 27 | "quoted_url_str,expected_unquoted_str", 28 | [ 29 | ("Le-%7BFsIaYnaQw%7Da2B%2F%5BV8bS+", "Le-{FsIaYnaQw}a2B/[V8bS+"), 30 | ("my_test-string+", "my_test-string+"), 31 | ("my%20test%20string+", "my test string+") 32 | ] 33 | ) 34 | def test_cast_urlstr(quoted_url_str, expected_unquoted_str): 35 | """Make sure that a url str that contains plus sign literals does not get unquoted incorrectly 36 | Plus signs should not be converted to spaces, since spaces are encoded with %20 in URIs 37 | 38 | see https://github.com/joke2k/django-environ/issues/357 for details. 39 | related to https://github.com/joke2k/django-environ/pull/69""" 40 | 41 | assert _cast_urlstr(quoted_url_str) == expected_unquoted_str 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # This file is part of the django-environ. 2 | # 3 | # Copyright (c) 2021-2024, Serghei Iakovlev 4 | # Copyright (c) 2013-2021, Daniele Faraglia 5 | # 6 | # For the full copyright and license information, please view 7 | # the LICENSE.txt file that was distributed with this source code. 8 | 9 | # Tox (https://tox.readthedocs.io) - run tests in multiple virtualenvs. 10 | # Also contains configuration settings for all tools executed by Tox. 11 | 12 | [tox] 13 | minversion = 3.22 14 | envlist = 15 | build 16 | coverage-report 17 | linkcheck 18 | docs 19 | lint 20 | manifest 21 | py{39,310,311,312,313}-django{22,30,31,32,40,41,42} 22 | py{310,311,312,313}-django{50,51} 23 | pypy-django{22,30,31,32} 24 | 25 | [gh-actions] 26 | python = 27 | 3.9: py39 28 | 3.10: py310 29 | 3.11: py311 30 | 3.12: py312 31 | 3.13: py313 32 | pypy-3.10: pypy 33 | 34 | [testenv] 35 | description = Unit tests 36 | extras = testing 37 | deps = 38 | django22: Django>=2.2,<3.0 39 | django30: Django>=3.0,<3.1 40 | django31: Django>=3.1,<3.2 41 | django32: Django>=3.2,<3.3 42 | django40: Django>=4.0,<4.1 43 | django41: Django>=4.1,<4.2 44 | django42: Django>=4.2,<5.0 45 | commands_pre = 46 | python -m pip install --upgrade pip 47 | python -m pip install . 48 | commands = 49 | coverage erase 50 | coverage run -m pytest {posargs} 51 | 52 | [testenv:coverage-report] 53 | description = Combine coverage reports 54 | skip_install = true 55 | deps = coverage[toml]>=5.4 56 | commands = 57 | coverage combine 58 | coverage report 59 | coverage html 60 | coverage xml 61 | 62 | [testenv:lint] 63 | description = Static code analysis and code style check 64 | skip_install = true 65 | deps = 66 | flake8 67 | flake8-blind-except 68 | flake8-import-order 69 | pylint 70 | commands_pre = 71 | python -m pip install --upgrade pip 72 | python -m pip install . 73 | commands = 74 | flake8 environ setup.py 75 | pylint \ 76 | --logging-format-style=old \ 77 | --good-names-rgxs=m[0-9],f,v \ 78 | --disable=too-few-public-methods \ 79 | --disable=import-error \ 80 | --disable=unused-import \ 81 | --disable=too-many-locals \ 82 | --disable=too-many-branches \ 83 | --disable=too-many-public-methods \ 84 | --disable=too-many-lines \ 85 | environ 86 | 87 | [testenv:linkcheck] 88 | description = Check external links in the package documentation 89 | # Keep basepython in sync with .readthedocs.yml and docs.yml 90 | # (GitHub Action Workflow). 91 | basepython = python3.12 92 | extras = docs 93 | commands = 94 | {envpython} -m sphinx \ 95 | -j auto \ 96 | -b linkcheck \ 97 | {tty:--color} \ 98 | -n -W -a \ 99 | --keep-going \ 100 | -d {envtmpdir}/doctrees \ 101 | docs \ 102 | docs/_build/linkcheck 103 | isolated_build = true 104 | 105 | [testenv:docs] 106 | description = Build package documentation (HTML) 107 | # Keep basepython in sync with .readthedocs.yml and docs.yml 108 | # (GitHub Action Workflow). 109 | basepython = python3.12 110 | extras = docs 111 | commands = 112 | {envpython} -m sphinx \ 113 | -j auto \ 114 | -b html \ 115 | {tty:--color} \ 116 | -n -T -W \ 117 | -d {envtmpdir}/doctrees \ 118 | docs \ 119 | docs/_build/html 120 | 121 | {envpython} -m sphinx \ 122 | -j auto \ 123 | -b doctest \ 124 | {tty:--color} \ 125 | -n -T -W \ 126 | -d {envtmpdir}/doctrees \ 127 | docs \ 128 | docs/_build/doctest 129 | 130 | {envpython} -m doctest \ 131 | AUTHORS.rst \ 132 | CHANGELOG.rst \ 133 | CONTRIBUTING.rst \ 134 | README.rst \ 135 | SECURITY.rst 136 | 137 | [testenv:manifest] 138 | description = Check MANIFEST.in in a source package for completeness 139 | deps = check-manifest 140 | skip_install = true 141 | commands = check-manifest -v 142 | 143 | [testenv:build] 144 | description = Build and test package distribution 145 | skip_install = true 146 | deps = 147 | twine 148 | check-wheel-contents 149 | commands_pre = 150 | python -m pip install -U pip setuptools wheel 151 | python setup.py bdist_wheel -d {envtmpdir}/build 152 | python setup.py sdist -d {envtmpdir}/build 153 | commands = 154 | twine check {envtmpdir}/build/* 155 | check-wheel-contents {envtmpdir}/build 156 | 157 | [pytest] 158 | testpaths = tests 159 | addopts = 160 | --verbose 161 | --ignore=.tox 162 | 163 | [coverage:run] 164 | branch = True 165 | parallel = True 166 | # A list of file name patterns, the files to leave 167 | # out of measurement or reporting. 168 | omit = 169 | .tox/* 170 | tests/* 171 | */__pycache__/* 172 | 173 | [coverage:report] 174 | precision = 2 175 | show_missing = True 176 | 177 | [flake8] 178 | # Base flake8 configuration: 179 | statistics = True 180 | show-source = True 181 | # TODO: max-complexity = 10 182 | # Plugins: 183 | import-order-style = smarkets 184 | # A list of mappings of files and the codes that should be ignored for 185 | # the entirety of the file: 186 | per-file-ignores = 187 | environ/__init__.py:F401,F403 188 | environ/compat.py:F401 189 | # Excluding some directories: 190 | extend-exclude = 191 | .tox 192 | build* 193 | dist 194 | htmlcov 195 | --------------------------------------------------------------------------------