diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe711895..c2769ef2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,17 +27,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v3.0.2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2.2.2 + - name: Set up Python 3.10 + uses: actions/setup-python@v4.0.0 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip - pip --use-feature=in-tree-build install tox tox-gh-actions + pip install tox tox-gh-actions - name: Check MANIFEST.in for completeness run: tox -e manifest @@ -47,7 +47,7 @@ jobs: - name: Archive build artifacts if: success() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: # To ensure that jobs don't overwrite existing artifacts, # use a different name per job. @@ -67,12 +67,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v3.0.2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2.2.2 + - name: Set up Python 3.10 + uses: actions/setup-python@v4.0.0 with: - python-version: '3.9' + python-version: '3.10' - name: Install in dev mode run: python -m pip install -e . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c836edc9..7c5d59f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,18 +51,18 @@ jobs: - '3.7' - '3.8' - '3.9' - - '3.10.0-beta - 3.10' + - '3.10' - 'pypy-3.7' os: [ ubuntu-latest, macos-latest, windows-latest ] steps: - name: Checkout code - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v3.0.2 with: fetch-depth: 5 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2.2.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ matrix.python }} diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml index e4283d10..bf4e81fd 100644 --- a/.github/workflows/cs.yml +++ b/.github/workflows/cs.yml @@ -21,12 +21,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v3.0.2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2.2.2 + - name: Set up Python 3.10 + uses: actions/setup-python@v4.0.0 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies run: | diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c68593c..f89231e1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,12 +21,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2.3.5 + uses: actions/checkout@v3.0.2 - - name: Set up Python 3.8 - uses: actions/setup-python@v2.2.2 + - name: Set up Python 3.10 + uses: actions/setup-python@v4.0.0 with: - python-version: '3.8' + python-version: '3.10' - name: Install dependencies run: | @@ -41,7 +41,7 @@ jobs: - name: Archive docs artifacts if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docs path: docs diff --git a/.readthedocs.yml b/.readthedocs.yml index 78a20224..27f93688 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,13 +6,20 @@ # For the full copyright and license information, please view # the LICENSE.txt file that was distributed with this source code. +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + --- version: 2 -python: - # Keep version in sync with tox.ini (testenv:docs) and - # docs.yml (GitHub Action Workflow). - version: '3.8' +build: + os: ubuntu-20.04 + tools: + # Keep version in sync with tox.ini (testenv:docs) and + # docs.yml (GitHub Action Workflow). + python: '3.10' + +python: install: - method: pip path: . diff --git a/AUTHORS.rst b/AUTHORS.rst index 6bf39824..0a910fa7 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -13,7 +13,7 @@ The existence of ``django-environ`` would have been impossible without these projects: - `rconradharris/envparse `_ -- `jacobian/dj-database-url `_ +- `jazzband/dj-database-url `_ - `migonzalvar/dj-email-url `_ - `ghickman/django-cache-url `_ - `dstufft/dj-search-url `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index dd34e763..2c3e17dd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,44 @@ All notable changes to this project will be documented in this file. The format is inspired by `Keep a Changelog `_ and this project adheres to `Semantic Versioning `_. -`v0.8.1`_ - 20-October-2021 +`v0.9.0`_ - 15-June-2022 +------------------------------ +Added ++++++ +- Added support for Postgresql cluster URI + `#355 `_. +- Added support for Django 4.0 + `#371 `_. +- Added support for prefixed variables + `#362 `_. +- Amended documentation. + + +Deprecated +++++++++++ +- ``Env.unicode()`` is deprecated and will be removed in the next + major release. Use ``Env.str()`` instead. + + +Changed ++++++++ +- Attach cause to ``ImproperlyConfigured`` exception + `#360 `_. + +Fixed ++++++ +- Fixed ``_cast_urlstr`` unquoting + `#357 `_. +- Fixed documentation regarding unsafe characters in URLs + `#220 `_. +- Fixed ``environ.Path.__eq__()`` to compare paths correctly + `#86 `_, + `#197 `_. + + +`v0.8.1`_ - 20-October-2021 +--------------------------- Fixed +++++ - Fixed "Invalid line" spam logs on blank lines in env file @@ -16,7 +52,7 @@ Fixed `v0.8.0`_ - 17-October-2021 ------------------------------- +--------------------------- Added +++++ - Log invalid lines when parse .env file @@ -108,7 +144,7 @@ Added - Support for Django 2.1 & 2.2. - Added tox.ini targets. - Added secure redis backend URLs via ``rediss://``. -- Add ``cast=str`` to ``str()`` method. +- Added ``cast=str`` to ``str()`` method. Fixed +++++ @@ -149,7 +185,7 @@ Added - Support for Elasticsearch2. - Support for Mysql-connector. - Support for ``pyodbc``. -- Add ``__contains__`` feature to Environ class. +- Added ``__contains__`` feature to Environ class. Fixed +++++ @@ -171,7 +207,7 @@ Added Changed +++++++ -- Fix uwsgi settings reload problem +- Fixed uwsgi settings reload problem `#55 `_. - Update support for ``django-redis`` urls `#109 `_. @@ -184,26 +220,26 @@ Added Changed +++++++ -- Fix for unsafe characters into URLs. +- Fixed for unsafe characters into URLs. - Clarifying warning on missing or unreadable file. Thanks to `@nickcatal `_. -- Fix support for Oracle urls. -- Fix support for ``django-redis``. +- Fixed support for Oracle urls. +- Fixed support for ``django-redis``. `v0.4`_ - 23-September-2015 --------------------------- Added +++++ - New email schemes - ``smtp+ssl`` and ``smtp+tls`` (``smtps`` would be deprecated). -- Add tuple support. Thanks to `@anonymouzz `_. -- Add LDAP url support for database. Thanks to +- Added tuple support. Thanks to `@anonymouzz `_. +- Added LDAP url support for database. Thanks to `django-ldapdb/django-ldapdb `_. Changed +++++++ -- Fix non-ascii values (broken in Python 2.x). +- Fixed non-ascii values (broken in Python 2.x). - ``redis_cache`` replaced by ``django_redis``. -- Fix psql/pgsql url. +- Fixed psql/pgsql url. `v0.3.1`_ - 19 Sep 2015 @@ -225,9 +261,9 @@ Fixed ---------------------- Added +++++ -- Add cache url support. -- Add email url support. -- Add search url support. +- Added cache url support. +- Added email url support. +- Added search url support. Changed +++++++ @@ -243,7 +279,7 @@ v0.2 - 16-April-2013 -------------------- Added +++++ -- Add advanced float parsing (comma and dot symbols to separate thousands and decimals). +- Added advanced float parsing (comma and dot symbols to separate thousands and decimals). Fixed +++++ @@ -256,6 +292,7 @@ Added - Initial release. +.. _v0.9.0: https://github.com/joke2k/django-environ/compare/v0.8.1...v0.9.0 .. _v0.8.1: https://github.com/joke2k/django-environ/compare/v0.8.0...v0.8.1 .. _v0.8.0: https://github.com/joke2k/django-environ/compare/v0.7.0...v0.8.0 .. _v0.7.0: https://github.com/joke2k/django-environ/compare/v0.6.0...v0.7.0 diff --git a/README.rst b/README.rst index 99a52ef1..256f0a16 100644 --- a/README.rst +++ b/README.rst @@ -104,8 +104,8 @@ article. Using ``django-environ`` you can stop to make a lot of unversioned ``settings_*.py`` to configure your app. -See `cookiecutter-django `_ for -a concrete example on using with a django project. +See `cookiecutter-django `_ +for a concrete example on using with a django project. **Feature Support** @@ -127,7 +127,7 @@ the code on `GitHub `_, and the latest release on `PyPI `_. It’s rigorously tested on Python 3.5+, and officially supports -Django 1.11, 2.2, 3.0, 3.1 and 3.2. +Django 1.11, 2.2, 3.0, 3.1, 3.2 and 4.0. If you'd like to contribute to ``django-environ`` you're most welcome! diff --git a/docs/Makefile b/docs/Makefile index 2b50c273..887c0bba 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..e1556abc --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,46 @@ +============= +API Reference +============= + +.. currentmodule:: environ + + +The ``__init__`` module +======================= + +.. automodule:: environ + :members: + :special-members: + :no-undoc-members: + + +The ``compat`` module +====================== + +.. automodule:: environ.compat + :members: + :no-undoc-members: + + +The ``environ`` module +====================== + +.. autoclass:: environ.Env + :members: + :no-undoc-members: + +.. autoclass:: environ.FileAwareEnv + :members: + :no-undoc-members: + +.. autoclass:: environ.Path + :members: + :no-undoc-members: + + +The ``fileaware_mapping`` module +================================ + +.. autoclass:: environ.fileaware_mapping.FileAwareMapping + :members: + :no-undoc-members: diff --git a/docs/conf.py b/docs/conf.py index 17536146..9003ad89 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,19 +1,26 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view # the LICENSE.txt file that was distributed with this source code. # -# -- Utils ----------------------------------------------------- +# -- Utils --------------------------------------------------------- # import codecs import os +import sys import re +from datetime import date + + +PROJECT_DIR = os.path.abspath('..') +sys.path.insert(0, PROJECT_DIR) + def read_file(filepath): """Read content from a UTF-8 encoded text file.""" @@ -23,9 +30,7 @@ def read_file(filepath): def find_version(meta_file): """Extract ``__version__`` from meta_file.""" - here = os.path.abspath(os.path.dirname(__file__)) - contents = read_file(os.path.join(here, meta_file)) - + contents = read_file(os.path.join(PROJECT_DIR, meta_file)) meta_match = re.search( r"^__version__\s+=\s+['\"]([^'\"]*)['\"]", contents, @@ -35,7 +40,7 @@ def find_version(meta_file): if meta_match: return meta_match.group(1) raise RuntimeError( - 'Unable to find __version__ string in package meta file') + "Unable to find __version__ string in package meta file") # @@ -43,9 +48,9 @@ def find_version(meta_file): # # General information about the project. -project = 'django-environ' -copyright = '2013-2021, Daniele Faraglia and other contributors' -author = u"Daniele Faraglia" +project = "django-environ" +copyright = f'2013-{date.today().year}, Daniele Faraglia and other contributors' +author = u"Daniele Faraglia \\and Serghei Iakovlev" # # -- General configuration --------------------------------------------------- @@ -56,6 +61,7 @@ def find_version(meta_file): "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx.ext.todo", + "sphinx.ext.viewcode", "notfound.extension", ] @@ -75,7 +81,7 @@ def find_version(meta_file): # The version info # The short X.Y version. -release = find_version('../environ/__init__.py') +release = find_version(os.path.join("environ", "__init__.py")) version = release.rsplit(u".", 1)[0] # The full version, including alpha/beta/rc tags. @@ -90,9 +96,42 @@ def find_version(meta_file): # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = True +# +# -- Options for autodoc --------------------------------------------------- +# + +# This value selects if automatically documented members are sorted alphabetical +# (value 'alphabetical'), by member type (value 'groupwise') or by source order +# (value 'bysource'). The default is alphabetical. +# +# Note that for source order, the module must be a Python module with the +# source code available. +autodoc_member_order = 'bysource' + +# +# -- Options for linkcheck --------------------------------------------------- +# + +linkcheck_ignore = [ + # We run into GitHub's rate limits. + r"https://github.com/.*/(issues|pull)/\d+", + # Do not check links to compare tags. + r"https://github.com/joke2k/django-environ/compare/.*", +] + +# +# -- Options for nitpick ----------------------------------------------------- +# + +# In nitpick mode (-n), still ignore any of the following "broken" references +# to non-types. +nitpick_ignore = [ +] + # # -- Options for extlinks ---------------------------------------------------- # + extlinks = { "pypi": ("https://pypi.org/project/%s/", ""), } @@ -100,6 +139,7 @@ def find_version(meta_file): # # -- Options for intersphinx ------------------------------------------------- # + intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "sphinx": ("https://www.sphinx-doc.org/en/master", None), @@ -108,14 +148,15 @@ def find_version(meta_file): # # -- Options for TODOs ------------------------------------------------------- # + todo_include_todos = True -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output ------------------------------------------------- # html_favicon = None html_theme = "furo" -html_title = "django-environ" +html_title = project html_theme_options = {} @@ -151,7 +192,7 @@ def find_version(meta_file): htmlhelp_basename = "django-environ-doc" # -# -- Options for manual page output --------------------------------------- +# -- Options for manual page output ------------------------------------------ # # One entry per manual page. List of tuples @@ -161,7 +202,7 @@ def find_version(meta_file): ] # -# -- Options for Texinfo output ------------------------------------------- +# -- Options for Texinfo output ---------------------------------------------- # # Grouping the document tree into Texinfo files. List of tuples diff --git a/docs/deprecations.rst b/docs/deprecations.rst new file mode 100644 index 00000000..73189118 --- /dev/null +++ b/docs/deprecations.rst @@ -0,0 +1,12 @@ +============ +Deprecations +============ + +Features deprecated in 0.9.0 +============================ + +Methods +------- + +* The :meth:`.environ.Env.unicode` method is deprecated as it was used + for Python 2.x only. Use :meth:`.environ.Env.str` instead. diff --git a/docs/docutils.conf b/docs/docutils.conf index 841ea73e..4ed0bf5a 100644 --- a/docs/docutils.conf +++ b/docs/docutils.conf @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view diff --git a/docs/faq.rst b/docs/faq.rst new file mode 100644 index 00000000..9e23ef6f --- /dev/null +++ b/docs/faq.rst @@ -0,0 +1,31 @@ +=== +FAQ +=== + + +#. **Can django-environ determine the location of .env file automatically?** + + ``django-environ`` will try to get and read ``.env`` file from the project + root if you haven't specified the path for it when call :meth:`.environ.Env.read_env`. + However, this is not the recommended way. When it is possible always specify + the path tho ``.env`` file. Alternatively, you can use a trick with a + environment variable pointing to the actual location of ``.env`` file. + For details see ":ref:`multiple-env-files-label`". + +#. **What (where) is the root part of the project, is it part of the project where are settings?** + + Where your ``manage.py`` file is (that is your project root directory). + +#. **What kind of file should .env be?** + + ``.env`` is a plain text file. + +#. **Should name of the file be simply .env (or something.env)?** + + Just ``.env``. However, this is not a strict rule, but just a common + practice. Formally, you can use any filename. + +#. **Is .env file going to be imported in settings file?** + + No need to import, ``django-environ`` automatically picks variables + from there. diff --git a/docs/getting-started.rst b/docs/getting-started.rst deleted file mode 100644 index 007e92f3..00000000 --- a/docs/getting-started.rst +++ /dev/null @@ -1,137 +0,0 @@ -=============== -Getting Started -=============== - -Installation -============ - - -Requirements ------------- - -* `Django `_ >= 1.11 -* `Python `_ >= 3.4 - -Installing django-environ -_________________________ - -``django-environ`` is a Python-only package `hosted on PyPI `_. -The recommended installation method is `pip `_-installing into a virtualenv: - -.. code-block:: console - - $ python -m pip install django-environ - -.. note:: - - After installing ``django-environ``, no need to add it to ``INSTALLED_APPS``. - -Unstable version -________________ - -The master of all the material is the Git repository at https://github.com/joke2k/django-environ. -So, you can also install the latest unreleased development version directly from the -``develop`` branch on GitHub. It is a work-in-progress of a future stable release so the -experience might be not as smooth.: - -.. code-block:: console - - $ pip install -e git://github.com/joke2k/django-environ.git#egg=django-environ - # OR - $ pip install --upgrade https://github.com/joke2k/django-environ.git/archive/develop.tar.gz - -This command will download the latest version of ``django-environ`` and install -it to your system. - -.. note:: - - The ``develop`` branch will always contain the latest unstable version, so the experience - might be not as smooth. If you wish to check older versions or formal, tagged release, - please switch to the relevant `tag `_. - -More information about ``pip`` and PyPI can be found here: - -* `Install pip `_ -* `Python Packaging User Guide `_ - -Usage -===== - -Create a ``.env`` file in project root directory. The file format can be understood -from the example below: - -.. code-block:: shell - - DEBUG=on - SECRET_KEY=your-secret-key - DATABASE_URL=psql://user:un-githubbedpassword@127.0.0.1:8458/database - SQLITE_URL=sqlite:///my-local-sqlite.db - CACHE_URL=memcache://127.0.0.1:11211,127.0.0.1:11212,127.0.0.1:11213 - REDIS_URL=rediscache://127.0.0.1:6379/1?client_class=django_redis.client.DefaultClient&password=ungithubbed-secret - -And use it with ``settings.py`` as follows: - -.. include:: ../README.rst - :start-after: -code-begin- - :end-before: -overview- - -The ``.env`` file should be specific to the environment and not checked into -version control, it is best practice documenting the ``.env`` file with an example. -For example, you can also add ``.env.dist`` with a template of your variables to -the project repo. This file should describe the mandatory variables for the -Django application, and it can be committed to version control. This provides a -useful reference and speeds up the on-boarding process for new team members, since -the time to dig through the codebase to find out what has to be set up is reduced. - -A good ``.env.dist`` could look like this: - -.. code-block:: shell - - # SECURITY WARNING: don't run with the debug turned on in production! - DEBUG=True - - # Should robots.txt allow everything to be crawled? - ALLOW_ROBOTS=False - - # SECURITY WARNING: keep the secret key used in production secret! - SECRET_KEY=secret - - # A list of all the people who get code error notifications. - ADMINS="John Doe , Mary " - - # A list of all the people who should get broken link notifications. - MANAGERS="Blake , Alice Judge " - - # By default, Django will send system email from root@localhost. - # However, some mail providers reject all email from this address. - SERVER_EMAIL=webmaster@example.com - -FAQ -=== - -#. **Can django-environ determine the location of .env file automatically?** - - ``django-environ`` will try to get and read ``.env`` file from the project - root if you haven't specified the path for it when call ``read_env``. - However, this is not the recommended way. When it is possible always specify - the path tho ``.env`` file. Alternatively, you can use a trick with a - environment variable pointing to the actual location of .env file. - For details see ":ref:`multiple-env-files-label`". - -#. **What (where) is the root part of the project, is it part of the project where are settings?** - - Where your ``manage.py`` file is (that is your project root directory). - -#. **What kind of file should .env be?** - - ``.env`` is a plain text file. - -#. **Should name of the file be simply .env (or something.env)?** - - Just ``.env``. However, this is not a strict rule, but just a common - practice. Formally, you can use any filename. - -#. **Is .env file going to be imported in settings file?** - - No need to import, ``django-environ`` automatically picks variables - from there. diff --git a/docs/index.rst b/docs/index.rst index 08aaf750..0142a3b9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,36 +15,73 @@ Overview :start-after: -overview- :end-before: -project-information- -Quick Start -=========== - -.. include:: ../README.rst - :start-after: -code-begin- - :end-before: -overview- - -.. include:: ../README.rst - :start-after: -support- - ---- Full Table of Contents ====================== +The User Guide +-------------- + +This part of the documentation, which is mostly prose, begins with some +background information about django-environ, then focuses on step-by-step +instructions for getting the most out of django-environ. + +.. toctree:: + :maxdepth: 2 + + install + quickstart + + +The Community Guide +------------------- + +This part of the documentation, which is mostly prose, details the +django-environ ecosystem and community. + .. toctree:: :maxdepth: 2 - getting-started + faq types tips + +.. toctree:: + :maxdepth: 1 + + deprecations + changelog + + +The API Documentation / Guide +----------------------------- + +If you are looking for information on a specific function, class, or method, +this part of the documentation is for you. + +.. toctree:: + :maxdepth: 2 + + api + + +The Contributor Guide +--------------------- + +If you want to contribute to the project, this part of the documentation is for +you. + +.. toctree:: + :maxdepth: 3 + contributing backers + license + +.. include:: ../README.rst + :start-after: -support- .. include:: ../README.rst :start-after: -project-information- :end-before: -support- - -.. toctree:: - :maxdepth: 1 - - license - changelog diff --git a/docs/install.rst b/docs/install.rst new file mode 100644 index 00000000..a70ca1a3 --- /dev/null +++ b/docs/install.rst @@ -0,0 +1,58 @@ +============ +Installation +============ + + +Requirements +============ + +* `Django `_ >= 1.11 +* `Python `_ >= 3.5 + +Installing django-environ +========================= + +``django-environ`` is a Python-only package `hosted_on_pypi`_. +The recommended installation method is `pip`_-installing into a +:mod:`virtualenv `: + +.. code-block:: console + + $ python -m pip install django-environ + +.. note:: + + After installing ``django-environ``, no need to add it to ``INSTALLED_APPS``. + + +.. _hosted_on_pypi: https://pypi.org/project/django-environ/ +.. _pip: https://pip.pypa.io/en/stable/ + + +Unstable version +================ + +The master of all the material is the Git repository at https://github.com/joke2k/django-environ. +So, you can also install the latest unreleased development version directly from the +``develop`` branch on GitHub. It is a work-in-progress of a future stable release so the +experience might be not as smooth: + +.. code-block:: console + + $ pip install -e git://github.com/joke2k/django-environ.git#egg=django-environ + # OR + $ pip install --upgrade https://github.com/joke2k/django-environ.git/archive/develop.tar.gz + +This command will download the latest version of ``django-environ`` and install +it to your system. + +.. note:: + + The ``develop`` branch will always contain the latest unstable version, so the experience + might be not as smooth. If you wish to check older versions or formal, tagged release, + please switch to the relevant `tag `_. + +More information about ``pip`` and PyPI can be found here: + +* `Install pip `_ +* `Python Packaging User Guide `_ diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 00000000..f5c5b209 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,59 @@ +=========== +Quick Start +=========== + +.. include:: ../README.rst + :start-after: -code-begin- + :end-before: -overview- + +Usage +===== + +Create a ``.env`` file in project root directory. The file format can be understood +from the example below: + +.. code-block:: shell + + DEBUG=on + SECRET_KEY=your-secret-key + DATABASE_URL=psql://user:un-githubbedpassword@127.0.0.1:8458/database + SQLITE_URL=sqlite:///my-local-sqlite.db + CACHE_URL=memcache://127.0.0.1:11211,127.0.0.1:11212,127.0.0.1:11213 + REDIS_URL=rediscache://127.0.0.1:6379/1?client_class=django_redis.client.DefaultClient&password=ungithubbed-secret + +And use it with ``settings.py`` as follows: + +.. include:: ../README.rst + :start-after: -code-begin- + :end-before: -overview- + +The ``.env`` file should be specific to the environment and not checked into +version control, it is best practice documenting the ``.env`` file with an example. +For example, you can also add ``.env.dist`` with a template of your variables to +the project repo. This file should describe the mandatory variables for the +Django application, and it can be committed to version control. This provides a +useful reference and speeds up the on-boarding process for new team members, since +the time to dig through the codebase to find out what has to be set up is reduced. + +A good ``.env.dist`` could look like this: + +.. code-block:: shell + + # SECURITY WARNING: don't run with the debug turned on in production! + DEBUG=True + + # Should robots.txt allow everything to be crawled? + ALLOW_ROBOTS=False + + # SECURITY WARNING: keep the secret key used in production secret! + SECRET_KEY=secret + + # A list of all the people who get code error notifications. + ADMINS="John Doe , Mary " + + # A list of all the people who should get broken link notifications. + MANAGERS="Blake , Alice Judge " + + # By default, Django will send system email from root@localhost. + # However, some mail providers reject all email from this address. + SERVER_EMAIL=webmaster@example.com diff --git a/docs/tips.rst b/docs/tips.rst index dbb762fb..a8684eb9 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -10,7 +10,7 @@ Docker (swarm) and Kubernetes are two widely used platforms that store their secrets in tmpfs inside containers as individual files, providing a secure way to be able to share configuration data between containers. -Use ``environ.FileAwareEnv`` rather than ``environ.Env`` to first look for +Use :class:`.environ.FileAwareEnv` rather than :class:`.environ.Env` to first look for environment variables with ``_FILE`` appended. If found, their contents will be read from the file system and used instead. @@ -42,7 +42,8 @@ the example ``docker-compose.yml`` for would contain: Using unsafe characters in URLs =============================== -In order to use unsafe characters you have to encode with ``urllib.parse.encode`` before you set into ``.env`` file. +In order to use unsafe characters you have to encode with :py:func:`urllib.parse.quote` +before you set into ``.env`` file. Encode only the value (i.e. the password) not the whole url. .. code-block:: shell @@ -76,7 +77,7 @@ For redis cache, multiple master/slave or shard locations can be configured as f Email settings ============== -In order to set email configuration for django you can use this code: +In order to set email configuration for Django you can use this code: .. code-block:: python @@ -125,6 +126,50 @@ You can use something like this to handle similar cases. ADMINS = tuple(parseaddr(email) for email in env.list('DJANGO_ADMINS')) +.. _complex_dict_format: + +Complex dict format +=================== + +Sometimes we need to get a bit more complex dict type than usual. For example, +consider Djangosaml2's ``SAML_ATTRIBUTE_MAPPING``: + +.. code-block:: python + + SAML_ATTRIBUTE_MAPPING = { + 'uid': ('username', ), + 'mail': ('email', ), + 'cn': ('first_name', ), + 'sn': ('last_name', ), + } + +A dict of this format can be obtained as shown below: + +**.env file**: + +.. code-block:: shell + + # .env file contents + SAML_ATTRIBUTE_MAPPING="uid=username;mail=email;cn=first_name;sn=last_name;" + +**settings.py file**: + +.. code-block:: python + + # settings.py file contents + import environ + + + env = environ.Env() + + # {'uid': ('username',), 'mail': ('email',), 'cn': ('first_name',), 'sn': ('last_name',)} + SAML_ATTRIBUTE_MAPPING = env.dict( + 'SAML_ATTRIBUTE_MAPPING', + cast={'value': tuple}, + default={} + ) + + Multiline value =============== @@ -199,7 +244,7 @@ Escape Proxy ============ If you're having trouble with values starting with dollar sign ($) without the intention of proxying the value to -another, You should enbale the ``escape_proxy`` and prepend a backslash to it. +another, You should enable the ``escape_proxy`` and prepend a backslash to it. .. code-block:: python @@ -249,7 +294,8 @@ while ``./manage.py runserver`` uses ``.env``. Using Path objects when reading env ----------------------------------- -It is possible to use of ``pathlib.Path`` objects when reading environment file from the filesystem: +It is possible to use of :py:class:`pathlib.Path` objects when reading environment +file from the filesystem: .. code-block:: python @@ -274,11 +320,40 @@ It is possible to use of ``pathlib.Path`` objects when reading environment file Overwriting existing environment values from env files ------------------------------------------------------ -If you want variables set within your env files to take higher precidence than +If you want variables set within your env files to take higher precedence than an existing set environment variable, use the ``overwrite=True`` argument of -``read_env``. For example: +:meth:`.environ.Env.read_env`. For example: .. code-block:: python env = environ.Env() env.read_env(BASE_DIR('.env'), overwrite=True) + + +Handling prefixes +================= + +Sometimes it is desirable to be able to prefix all environment variables. For +example, if you are using Django, you may want to prefix all environment +variables with ``DJANGO_``. This can be done by setting the ``prefix`` +to desired prefix. For example: + +**.env file**: + +.. code-block:: shell + + # .env file contents + DJANGO_TEST="foo" + +**settings.py file**: + +.. code-block:: python + + # settings.py file contents + import environ + + + env = environ.Env() + env.prefix = 'DJANGO_' + + env.str('TEST') # foo diff --git a/docs/types.rst b/docs/types.rst index 292ed64f..3fcdcbbd 100644 --- a/docs/types.rst +++ b/docs/types.rst @@ -2,61 +2,181 @@ Supported types =============== -* ``str`` -* ``bool`` -* ``int`` -* ``float`` -* ``json`` -* ``list``: ``(FOO=a,b,c)`` -* ``tuple``: ``(FOO=(a,b,c))`` -* ``dict``: ``(BAR=key=val,foo=bar)``, ``environ.Env(BAR=(dict, {}))`` -* ``dict``: ``(BAR=key=val;foo=1.1;baz=True)``, ``environ.Env(BAR=(dict(value=unicode, cast=dict(foo=float,baz=bool)), {}))`` -* ``url`` -* ``path``: ``(environ.Path)`` -* ``db_url`` - - * PostgreSQL: ``postgres://``, ``pgsql://``, ``psql://`` or ``postgresql://`` - * PostGIS: ``postgis://`` - * MySQL: ``mysql://`` or ``mysql2://`` - * MySQL for GeoDjango: ``mysqlgis://`` - * MySQL Connector Python from Oracle: ``mysql-connector://`` - * SQLite: ``sqlite://`` - * SQLite with SpatiaLite for GeoDjango: ``spatialite://`` - * Oracle: ``oracle://`` - * Microsoft SQL Server: ``mssql://`` - * PyODBC: ``pyodbc://`` - * Amazon Redshift: ``redshift://`` - * LDAP: ``ldap://`` - -* ``cache_url`` - - * Database: ``dbcache://`` - * Dummy: ``dummycache://`` - * File: ``filecache://`` - * Memory: ``locmemcache://`` - * Memcached: - * ``memcache://`` (uses ``python-memcached`` backend, deprecated in Django 3.2) - * ``pymemcache://`` (uses ``pymemcache`` backend if Django >=3.2 and package is installed, otherwise will use ``pylibmc`` backend to keep config backwards compatibility) - * ``pylibmc://`` - * Redis: ``rediscache://``, ``redis://``, or ``rediss://`` - -* ``search_url`` - - * Elasticsearch: ``elasticsearch://`` - * Elasticsearch2: ``elasticsearch2://`` - * Elasticsearch5: ``elasticsearch5://`` - * Elasticsearch7: ``elasticsearch7://`` - * Solr: ``solr://`` - * Whoosh: ``whoosh://`` - * Xapian: ``xapian://`` - * Simple cache: ``simple://`` - -* ``email_url`` - - * SMTP: ``smtp://`` - * SMTP+SSL: ``smtp+ssl://`` - * SMTP+TLS: ``smtp+tls://`` - * Console mail: ``consolemail://`` - * File mail: ``filemail://`` - * LocMem mail: ``memorymail://`` - * Dummy mail: ``dummymail://`` +The following are all type-casting methods of :py:class:`.environ.Env`. + +* :py:meth:`~.environ.Env.str` +* :py:meth:`~.environ.Env.bool` +* :py:meth:`~.environ.Env.int` +* :py:meth:`~.environ.Env.float` +* :py:meth:`~.environ.Env.json` +* :py:meth:`~.environ.Env.url` +* :py:meth:`~.environ.Env.list`: (accepts values like ``(FOO=a,b,c)``) +* :py:meth:`~.environ.Env.tuple`: (accepts values like ``(FOO=(a,b,c))``) +* :py:meth:`~.environ.Env.path`: (accepts values like ``(environ.Path)``) +* :py:meth:`~.environ.Env.dict`: (see below, ":ref:`environ-env-dict`" section) +* :py:meth:`~.environ.Env.db_url` (see below, ":ref:`environ-env-db-url`" section) +* :py:meth:`~.environ.Env.cache_url` (see below, ":ref:`environ-env-cache-url`" section) +* :py:meth:`~.environ.Env.search_url` (see below, ":ref:`environ-env-search-url`" section) +* :py:meth:`~.environ.Env.email_url` (see below, ":ref:`environ-env-email-url`" section) + + +.. _environ-env-dict: + +``environ.Env.dict`` +====================== + +:py:class:`.environ.Env` may parse complex variables like with the complex type-casting. +For example: + +.. code-block:: python + + import environ + + + env = environ.Env() + + # {'key': 'val', 'foo': 'bar'} + env.parse_value('key=val,foo=bar', dict) + + # {'key': 'val', 'foo': 1.1, 'baz': True} + env.parse_value( + 'key=val;foo=1.1;baz=True', + dict(value=str, cast=dict(foo=float,baz=bool)) + ) + +For more detailed example see ":ref:`complex_dict_format`". + + +.. _environ-env-db-url: + +``environ.Env.db_url`` +====================== + +:py:meth:`~.environ.Env.db_url` supports the following URL schemas: + +.. glossary:: + + Amazon Redshift + **Database Backend:** ``django_redshift_backend`` + + **URL schema:** ``redshift://`` + + LDAP + **Database Backend:** ``ldapdb.backends.ldap`` + + **URL schema:** ``ldap://host:port/dn?attrs?scope?filter?exts`` + + MSSQL + **Database Backend:** ``sql_server.pyodbc`` + + **URL schema:** ``mssql://user:password@host:port/dbname`` + + With MySQL you can use the following schemas: ``mysql``, ``mysql2``. + + MySQL (GIS) + **Database Backend:** ``django.contrib.gis.db.backends.mysql`` + + **URL schema:** ``mysqlgis://user:password@host:port/dbname`` + + MySQL + **Database Backend:** ``django.db.backends.mysql`` + + **URL schema:** ``mysql://user:password@host:port/dbname`` + + MySQL Connector Python from Oracle + **Database Backend:** ``mysql.connector.django`` + + **URL schema:** ``mysql-connector://`` + + Oracle + **Database Backend:** ``django.db.backends.oracle`` + + **URL schema:** ``oracle://user:password@host:port/dbname`` + + PostgreSQL + **Database Backend:** ``django.db.backends.postgresql`` + + **URL schema:** ``postgres://user:password@host:port/dbname`` + + With PostgreSQL you can use the following schemas: ``postgres``, ``postgresql``, ``psql``, ``pgsql``, ``postgis``. + You can also use UNIX domain sockets path instead of hostname. For example: ``postgres://path/dbname``. + The ``django.db.backends.postgresql_psycopg2`` will be used if the Django version is less than ``2.0``. + + PostGIS + **Database Backend:** ``django.contrib.gis.db.backends.postgis`` + + **URL schema:** ``postgis://user:password@host:port/dbname`` + + PyODBC + **Database Backend:** ``sql_server.pyodbc`` + + **URL schema:** ``pyodbc://`` + + SQLite + **Database Backend:** ``django.db.backends.sqlite3`` + + **URL schema:** ``sqlite:////absolute/path/to/db/file`` + + SQLite connects to file based databases. URL schemas ``sqlite://`` or + ``sqlite://:memory:`` means the database is in the memory (not a file on disk). + + SpatiaLite + **Database Backend:** ``django.contrib.gis.db.backends.spatialite`` + + **URL schema:** ``spatialite:///PATH`` + + SQLite connects to file based databases. URL schemas ``sqlite://`` or + ``sqlite://:memory:`` means the database is in the memory (not a file on disk). + + +.. _environ-env-cache-url: + +``environ.Env.cache_url`` +========================= + +:py:meth:`~.environ.Env.cache_url` supports the following URL schemas: + +* Database: ``dbcache://`` +* Dummy: ``dummycache://`` +* File: ``filecache://`` +* Memory: ``locmemcache://`` +* Memcached: + + * ``memcache://`` (uses ``python-memcached`` backend, deprecated in Django 3.2) + * ``pymemcache://`` (uses ``pymemcache`` backend if Django >=3.2 and package is installed, otherwise will use ``pylibmc`` backend to keep config backwards compatibility) + * ``pylibmc://`` + +* Redis: ``rediscache://``, ``redis://``, or ``rediss://`` + + +.. _environ-env-search-url: + +``environ.Env.search_url`` +========================== + +:py:meth:`~.environ.Env.search_url` supports the following URL schemas: + +* Elasticsearch: ``elasticsearch://`` +* Elasticsearch2: ``elasticsearch2://`` +* Elasticsearch5: ``elasticsearch5://`` +* Elasticsearch7: ``elasticsearch7://`` +* Solr: ``solr://`` +* Whoosh: ``whoosh://`` +* Xapian: ``xapian://`` +* Simple cache: ``simple://`` + + +.. _environ-env-email-url: + +``environ.Env.email_url`` +========================== + +:py:meth:`~.environ.Env.email_url` supports the following URL schemas: + +* SMTP: ``smtp://`` +* SMTP+SSL: ``smtp+ssl://`` +* SMTP+TLS: ``smtp+tls://`` +* Console mail: ``consolemail://`` +* File mail: ``filemail://`` +* LocMem mail: ``memorymail://`` +* Dummy mail: ``dummymail://`` diff --git a/environ/__init__.py b/environ/__init__.py index 97733f4d..72a30ba9 100644 --- a/environ/__init__.py +++ b/environ/__init__.py @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view @@ -11,31 +11,36 @@ This module tracks the version of the package as well as the base package info used by various functions within django-environ. -Misc variables: - - __copyright__ - __version__ - __license__ - __author__ - __author_email__ - __maintainer__ - __maintainer_email__ - __url__ - __description__ - -Refer to the `documentation `_ +Refer to the `documentation `_ for details on the use of this package. -""" +""" # noqa: E501 from .environ import * __copyright__ = 'Copyright (C) 2021 Daniele Faraglia' -__version__ = '0.8.1' +"""The copyright notice of the package.""" + +__version__ = '0.9.0' +"""The version of the package.""" + __license__ = 'MIT' +"""The license of the package.""" + __author__ = 'Daniele Faraglia' +"""The author of the package.""" + __author_email__ = 'daniele.faraglia@gmail.com' +"""The email of the author of the package.""" + __maintainer__ = 'Serghei Iakovlev' +"""The maintainer of the package.""" + __maintainer_email__ = 'egrep@protonmail.ch' +"""The email of the maintainer of the package.""" + __url__ = 'https://django-environ.readthedocs.org' +"""The URL of the package.""" + __description__ = 'A package that allows you to utilize 12factor inspired environment variables to configure your Django application.' # noqa: E501 +"""The description of the package.""" diff --git a/environ/compat.py b/environ/compat.py index 8c259f85..a4ae3666 100644 --- a/environ/compat.py +++ b/environ/compat.py @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view @@ -39,8 +39,8 @@ class ImproperlyConfigured(Exception): REDIS_DRIVER = 'django_redis.cache.RedisCache' -# back compatibility for pymemcache def choose_pymemcache_driver(): + """Backward compatibility for pymemcache.""" old_django = DJANGO_VERSION is not None and DJANGO_VERSION < (3, 2) if old_django or not find_loader('pymemcache'): # The original backend choice for the 'pymemcache' scheme is diff --git a/environ/environ.py b/environ/environ.py index acdfb153..fc5eebbe 100644 --- a/environ/environ.py +++ b/environ/environ.py @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view @@ -12,6 +12,7 @@ """ import ast +import itertools import logging import os import re @@ -21,6 +22,7 @@ from urllib.parse import ( parse_qs, ParseResult, + unquote, unquote_plus, urlparse, urlunparse, @@ -61,7 +63,7 @@ def _cast_int(v): def _cast_urlstr(v): - return unquote_plus(v) if isinstance(v, str) else v + return unquote(v) if isinstance(v, str) else v class NoValue: @@ -72,13 +74,32 @@ def __repr__(self): class Env: """Provide scheme-based lookups of environment variables so that each - caller doesn't have to pass in `cast` and `default` parameters. + caller doesn't have to pass in ``cast`` and ``default`` parameters. Usage::: - env = Env(MAIL_ENABLED=bool, SMTP_LOGIN=(str, 'DEFAULT')) - if env('MAIL_ENABLED'): - ... + import environ + import os + + env = environ.Env( + # set casting, default value + MAIL_ENABLED=(bool, False), + SMTP_LOGIN=(str, 'DEFAULT') + ) + + # Set the project base directory + BASE_DIR = os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) + ) + + # Take environment variables from .env file + environ.Env.read_env(os.path.join(BASE_DIR, '.env')) + + # False if not in os.environ due to casting above + MAIL_ENABLED = env('MAIL_ENABLED') + + # 'DEFAULT' if not in os.environ due to casting above + SMTP_LOGIN = env('SMTP_LOGIN') """ ENVIRON = os.environ @@ -169,6 +190,7 @@ class Env: def __init__(self, **scheme): self.smart_cast = True self.escape_proxy = False + self.prefix = "" self.scheme = scheme def __call__(self, var, cast=None, default=NOTSET, parse_default=False): @@ -197,6 +219,15 @@ def unicode(self, var, default=NOTSET): """Helper for python2 :rtype: unicode """ + warnings.warn( + '`%s.unicode` is deprecated, use `%s.str` instead' % ( + self.__class__.__name__, + self.__class__.__name__, + ), + DeprecationWarning, + stacklevel=2 + ) + return self.get_value(var, cast=str, default=default) def bytes(self, var, default=NOTSET, encoding='utf8'): @@ -260,7 +291,7 @@ def dict(self, var, cast=dict, default=NOTSET): def url(self, var, default=NOTSET): """ - :rtype: urlparse.ParseResult + :rtype: urllib.parse.ParseResult """ return self.get_value( var, @@ -330,20 +361,25 @@ def path(self, var, default=NOTSET, **kwargs): def get_value(self, var, cast=None, default=NOTSET, parse_default=False): """Return value for given environment variable. - :param var: Name of variable. - :param cast: Type to cast return value as. - :param default: If var not present in environ, return this instead. - :param parse_default: force to parse default.. - - :returns: Value from environment or default (if set) + :param str var: + Name of variable. + :param collections.abc.Callable or None cast: + Type to cast return value as. + :param default: + If var not present in environ, return this instead. + :param bool parse_default: + Force to parse default. + :returns: Value from environment or default (if set). + :rtype: typing.IO[typing.Any] """ logger.debug("get '{}' casted as '{}' with default '{}'".format( var, cast, default )) - if var in self.scheme: - var_info = self.scheme[var] + var_name = "{}{}".format(self.prefix, var) + if var_name in self.scheme: + var_info = self.scheme[var_name] try: has_default = len(var_info) == 2 @@ -364,11 +400,11 @@ def get_value(self, var, cast=None, default=NOTSET, parse_default=False): cast = var_info try: - value = self.ENVIRON[var] - except KeyError: + value = self.ENVIRON[var_name] + except KeyError as exc: if default is self.NOTSET: error_msg = "Set the {} environment variable".format(var) - raise ImproperlyConfigured(error_msg) + raise ImproperlyConfigured(error_msg) from exc value = default @@ -456,15 +492,29 @@ def parse_value(cls, value, cast): @classmethod def db_url_config(cls, url, engine=None): - """Pulled from DJ-Database-URL, parse an arbitrary Database URL. - - Support currently exists for PostgreSQL, PostGIS, MySQL, Oracle and - SQLite. - - SQLite connects to file based databases. The same URL format is used, - omitting the hostname, and using the "file" portion as the filename of - the database. This has the effect of four slashes being present for an - absolute file path. + """Parse an arbitrary database URL. + + Supports the following URL schemas: + + * PostgreSQL: ``postgres[ql]?://`` or ``p[g]?sql://`` + * PostGIS: ``postgis://`` + * MySQL: ``mysql://`` or ``mysql2://`` + * MySQL (GIS): ``mysqlgis://`` + * MySQL Connector Python from Oracle: ``mysql-connector://`` + * SQLite: ``sqlite://`` + * SQLite with SpatiaLite for GeoDjango: ``spatialite://`` + * Oracle: ``oracle://`` + * Microsoft SQL Server: ``mssql://`` + * PyODBC: ``pyodbc://`` + * Amazon Redshift: ``redshift://`` + * LDAP: ``ldap://`` + + :param urllib.parse.ParseResult or str url: + Database URL to parse. + :param str or None engine: + If None, the database engine is evaluates from the ``url``. + :return: Parsed database URL. + :rtype: dict """ if not isinstance(url, cls.URL_CLASS): if url == 'sqlite://:memory:': @@ -501,13 +551,30 @@ def db_url_config(cls, url, engine=None): if url.port: path += ':{port}'.format(port=url.port) + user_host = url.netloc.rsplit('@', 1) + if url.scheme in cls.POSTGRES_FAMILY and ',' in user_host[-1]: + # Parsing postgres cluster dsn + hinfo = list( + itertools.zip_longest( + *( + host.rsplit(':', 1) + for host in user_host[-1].split(',') + ) + ) + ) + hostname = ','.join(hinfo[0]) + port = ','.join(filter(None, hinfo[1])) if len(hinfo) == 2 else '' + else: + hostname = url.hostname + port = url.port + # Update with environment configuration. config.update({ 'NAME': path or '', 'USER': _cast_urlstr(url.username) or '', 'PASSWORD': _cast_urlstr(url.password) or '', - 'HOST': url.hostname or '', - 'PORT': _cast_int(url.port) or '', + 'HOST': hostname or '', + 'PORT': _cast_int(port) or '', }) if ( @@ -552,11 +619,14 @@ def db_url_config(cls, url, engine=None): @classmethod def cache_url_config(cls, url, backend=None): - """Pulled from DJ-Cache-URL, parse an arbitrary Cache URL. + """Parse an arbitrary cache URL. - :param url: - :param backend: - :return: + :param urllib.parse.ParseResult or str url: + Cache URL to parse. + :param str or None backend: + If None, the backend is evaluates from the ``url``. + :return: Parsed cache URL. + :rtype: dict """ if not isinstance(url, cls.URL_CLASS): if not url: @@ -625,7 +695,15 @@ def cache_url_config(cls, url, backend=None): @classmethod def email_url_config(cls, url, backend=None): - """Parses an email URL.""" + """Parse an arbitrary email URL. + + :param urllib.parse.ParseResult or str url: + Email URL to parse. + :param str or None backend: + If None, the backend is evaluates from the ``url``. + :return: Parsed email URL. + :rtype: dict + """ config = {} @@ -670,6 +748,16 @@ def email_url_config(cls, url, backend=None): @classmethod def search_url_config(cls, url, engine=None): + """Parse an arbitrary search URL. + + :param urllib.parse.ParseResult or str url: + Search URL to parse. + :param str or None engine: + If None, the engine is evaluates from the ``url``. + :return: Parsed search URL. + :rtype: dict + """ + config = {} url = urlparse(url) if not isinstance(url, cls.URL_CLASS) else url @@ -759,26 +847,26 @@ def search_url_config(cls, url, engine=None): @classmethod def read_env(cls, env_file=None, overwrite=False, **overrides): - """Read a .env file into os.environ. + r"""Read a .env file into os.environ. If not given a path to a dotenv path, does filthy magic stack backtracking to find the dotenv in the same directory as the file that - called read_env. + called ``read_env``. Existing environment variables take precedent and are NOT overwritten by the file content. ``overwrite=True`` will force an overwrite of existing environment variables. Refs: - - https://wellfire.co/learn/easier-12-factor-django - - https://gist.github.com/bennylope/2999704 - :param env_file: The path to the `.env` file your application should + * https://wellfire.co/learn/easier-12-factor-django + + :param env_file: The path to the ``.env`` file your application should use. If a path is not provided, `read_env` will attempt to import the Django settings module from the Django project root. :param overwrite: ``overwrite=True`` will force an overwrite of existing environment variables. - :param **overrides: Any additional keyword arguments provided directly + :param \**overrides: Any additional keyword arguments provided directly to read_env will be added to the environment. If the key matches an existing environment variable, the value will be overridden. """ @@ -881,13 +969,12 @@ def path(self, *paths, **kwargs): return self.__class__(self.__root__, *paths, **kwargs) def file(self, name, *args, **kwargs): - """Open a file. - - :param name: Filename appended to self.root - :param args: passed to open() - :param kwargs: passed to open() + r"""Open a file. - :rtype: file + :param str name: Filename appended to :py:attr:`~root` + :param \*args: ``*args`` passed to :py:func:`open` + :param \**kwargs: ``**kwargs`` passed to :py:func:`open` + :rtype: typing.IO[typing.Any] """ return open(self(name), *args, **kwargs) @@ -914,7 +1001,9 @@ def __call__(self, *paths, **kwargs): return self._absolute_join(self.__root__, *paths, **kwargs) def __eq__(self, other): - return self.__root__ == other.__root__ + if isinstance(other, Path): + return self.__root__ == other.__root__ + return self.__root__ == other def __ne__(self, other): return not self.__eq__(other) diff --git a/environ/fileaware_mapping.py b/environ/fileaware_mapping.py index e26ee8d1..6da95f58 100644 --- a/environ/fileaware_mapping.py +++ b/environ/fileaware_mapping.py @@ -1,17 +1,27 @@ +# This file is part of the django-environ. +# +# Copyright (c) 2021-2022, Serghei Iakovlev +# Copyright (c) 2013-2021, Daniele Faraglia +# +# For the full copyright and license information, please view +# the LICENSE.txt file that was distributed with this source code. + +"""Docker-style file variable support module.""" + import os from collections.abc import MutableMapping class FileAwareMapping(MutableMapping): """ - A mapping that wraps os.environ, first checking for the existance of a key + A mapping that wraps os.environ, first checking for the existence of a key appended with ``_FILE`` whenever reading a value. If a matching file key is found then the value is instead read from the file system at this location. By default, values read from the file system are cached so future lookups do not hit the disk again. - A ``_FILE`` key has higher precidence than a value is set directly in the + A ``_FILE`` key has higher precedence than a value is set directly in the environment, and an exception is raised if the file can not be found. """ diff --git a/setup.py b/setup.py index 24e8a85f..4de0becc 100644 --- a/setup.py +++ b/setup.py @@ -130,6 +130,7 @@ def get_version_string(): 'Framework :: Django :: 3.0', 'Framework :: Django :: 3.1', 'Framework :: Django :: 3.2', + 'Framework :: Django :: 4.0', 'Operating System :: OS Independent', diff --git a/tests/fixtures.py b/tests/fixtures.py index 25213ac7..c7f8a0be 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view @@ -27,6 +27,12 @@ class FakeEnv: DICT = dict(foo='bar', test='on') PATH = '/home/dev' EXPORTED = 'exported var' + SAML_ATTRIBUTE_MAPPING = dict( + uid=('username',), + mail=('email',), + cn=('first_name',), + sn=('last_name',) + ) @classmethod def generate_data(cls): @@ -57,6 +63,7 @@ def generate_data(cls): ESCAPED_VAR=r'\$baz', INT_LIST='42,33', INT_TUPLE='(42,33)', + MIX_TUPLE='(42,Test)', STR_LIST_WITH_SPACES=' foo, bar', EMPTY_LIST='', DICT_VAR='foo=bar,test=on', @@ -75,4 +82,7 @@ def generate_data(cls): URL_VAR=cls.URL, JSON_VAR=json.dumps(cls.JSON), PATH_VAR=cls.PATH, - EXPORTED_VAR=cls.EXPORTED) + EXPORTED_VAR=cls.EXPORTED, + SAML_ATTRIBUTE_MAPPING='uid=username;mail=email;cn=first_name;sn=last_name;', + PREFIX_TEST='foo', + ) diff --git a/tests/test_db.py b/tests/test_db.py index c4958504..20d807ff 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -17,52 +17,73 @@ @pytest.mark.parametrize( 'url,engine,name,host,user,passwd,port', [ - ('postgres://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.' - 'compute-1.amazonaws.com:5431/d8r82722r2kuvn', + # postgres://user:password@host:port/dbname + ('postgres://enigma:secret@example.com:5431/dbname', DJANGO_POSTGRES, - 'd8r82722r2kuvn', - 'ec2-107-21-253-135.compute-1.amazonaws.com', - 'uf07k1i6d8ia0v', - 'wegauwhgeuioweg', + 'dbname', + 'example.com', + 'enigma', + 'secret', 5431), - ('postgres:////var/run/postgresql/db', + # postgres://path/dbname + ('postgres:////var/run/postgresql/dbname', DJANGO_POSTGRES, - 'db', + 'dbname', '/var/run/postgresql', '', '', ''), - ('postgis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.' - 'compute-1.amazonaws.com:5431/d8r82722r2kuvn', + # postgis://user:password@host:port/dbname + ('postgis://enigma:secret@example.com:5431/dbname', 'django.contrib.gis.db.backends.postgis', - 'd8r82722r2kuvn', - 'ec2-107-21-253-135.compute-1.amazonaws.com', - 'uf07k1i6d8ia0v', - 'wegauwhgeuioweg', + 'dbname', + 'example.com', + 'enigma', + 'secret', 5431), - ('mysqlgis://uf07k1i6d8ia0v:wegauwhgeuioweg@ec2-107-21-253-135.' - 'compute-1.amazonaws.com:5431/d8r82722r2kuvn', + # postgres://user:password@host:port,host:port,host:port/dbname + ('postgres://username:p@ss:12,wor:34d@host1:111,22.55.44.88:222,[2001:db8::1234]:333/db', + DJANGO_POSTGRES, + 'db', + 'host1,22.55.44.88,[2001:db8::1234]', + 'username', + 'p@ss:12,wor:34d', + '111,222,333' + ), + # postgres://host,host,host/dbname + ('postgres://node1,node2,node3/db', + DJANGO_POSTGRES, + 'db', + 'node1,node2,node3', + '', + '', + '' + ), + # mysqlgis://user:password@host:port/dbname + ('mysqlgis://enigma:secret@example.com:5431/dbname', 'django.contrib.gis.db.backends.mysql', - 'd8r82722r2kuvn', - 'ec2-107-21-253-135.compute-1.amazonaws.com', - 'uf07k1i6d8ia0v', - 'wegauwhgeuioweg', + 'dbname', + 'example.com', + 'enigma', + 'secret', 5431), - ('mysql://bea6eb025ca0d8:69772142@us-cdbr-east.cleardb.com' - '/heroku_97681db3eff7580?reconnect=true', + # mysql://user:password@host/dbname?options + ('mysql://enigma:secret@reconnect.com/dbname?reconnect=true', 'django.db.backends.mysql', - 'heroku_97681db3eff7580', - 'us-cdbr-east.cleardb.com', - 'bea6eb025ca0d8', - '69772142', + 'dbname', + 'reconnect.com', + 'enigma', + 'secret', ''), - ('mysql://travis@localhost/test_db', + # mysql://user@host/dbname + ('mysql://enigma@localhost/dbname', 'django.db.backends.mysql', - 'test_db', + 'dbname', 'localhost', - 'travis', + 'enigma', '', ''), + # sqlite:// ('sqlite://', 'django.db.backends.sqlite3', ':memory:', @@ -70,6 +91,7 @@ '', '', ''), + # sqlite:////absolute/path/to/db/file ('sqlite:////full/path/to/your/file.sqlite', 'django.db.backends.sqlite3', '/full/path/to/your/file.sqlite', @@ -77,6 +99,7 @@ '', '', ''), + # sqlite://:memory: ('sqlite://:memory:', 'django.db.backends.sqlite3', ':memory:', @@ -84,19 +107,39 @@ '', '', ''), - ('ldap://cn=admin,dc=nodomain,dc=org:' - 'some_secret_password@ldap.nodomain.org/', + # ldap://user:password@host + ('ldap://cn=admin,dc=nodomain,dc=org:secret@example.com', 'ldapdb.backends.ldap', - 'ldap://ldap.nodomain.org', - 'ldap.nodomain.org', + 'ldap://example.com', + 'example.com', 'cn=admin,dc=nodomain,dc=org', - 'some_secret_password', + 'secret', + ''), + # mysql://user:password@host/dbname + ('mssql://enigma:secret@example.com/dbname' + '?driver=ODBC Driver 13 for SQL Server', + 'sql_server.pyodbc', + 'dbname', + 'example.com', + 'enigma', + 'secret', ''), + # mysql://user:password@host:port/dbname + ('mssql://enigma:secret@amazonaws.com\\insnsnss:12345/dbname' + '?driver=ODBC Driver 13 for SQL Server', + 'sql_server.pyodbc', + 'dbname', + 'amazonaws.com\\insnsnss', + 'enigma', + 'secret', + 12345), ], ids=[ 'postgres', 'postgres_unix_domain', 'postgis', + 'postgres_cluster', + 'postgres_no_ports', 'mysqlgis', 'cleardb', 'mysql_no_password', @@ -104,6 +147,8 @@ 'sqlite_file', 'sqlite_memory', 'ldap', + 'mssql', + 'mssql_port', ], ) def test_db_parsing(url, engine, name, host, user, passwd, port): @@ -118,6 +163,13 @@ def test_db_parsing(url, engine, name, host, user, passwd, port): assert config['USER'] == user assert config['HOST'] == host + if engine == 'sql_server.pyodbc': + assert config['OPTIONS'] == {'driver': 'ODBC Driver 13 for SQL Server'} + + if host == 'reconnect.com': + assert config['OPTIONS'] == {'reconnect': 'true'} + + def test_postgres_complex_db_name_parsing(): """Make sure we can use complex postgres host.""" diff --git a/tests/test_env.py b/tests/test_env.py index 0be9a60a..b5c1e397 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -1,6 +1,6 @@ # This file is part of the django-environ. # -# Copyright (c) 2021, Serghei Iakovlev +# Copyright (c) 2021-2022, Serghei Iakovlev # Copyright (c) 2013-2021, Daniele Faraglia # # For the full copyright and license information, please view @@ -8,6 +8,7 @@ import os from urllib.parse import quote +from warnings import catch_warnings import pytest @@ -45,6 +46,7 @@ def test_not_present_without_default(self): with pytest.raises(ImproperlyConfigured) as excinfo: self.env('not_present') assert str(excinfo.value) == 'Set the not_present environment variable' + assert excinfo.value.__cause__ is not None def test_contains(self): assert 'STR_VAR' in self.env @@ -69,6 +71,21 @@ def test_str(self, var, val, multiline): assert self.env(var) == val assert self.env.str(var, multiline=multiline) == val + def test_unicode(self, recwarn): + actual = self.env.unicode('CYRILLIC_VAR', default='фуубар') + expected = self.env.str('CYRILLIC_VAR', default='фуубар') + + assert actual == expected + assert len(recwarn) == 1 + w = recwarn.pop(DeprecationWarning) + assert issubclass(w.category, DeprecationWarning) + assert str(w.message) == '`%s.unicode` is deprecated, use `%s.str` instead' %( + self.env.__class__.__name__, + self.env.__class__.__name__, + ) + assert w.filename + assert w.lineno + @pytest.mark.parametrize( 'var,val,default', [ @@ -139,11 +156,25 @@ def test_int_list(self): assert_type_and_value(list, [42, 33], self.env('INT_LIST', cast=[int])) assert_type_and_value(list, [42, 33], self.env.list('INT_LIST', int)) - def test_int_tuple(self): + def test_int_list_cast_tuple(self): assert_type_and_value(tuple, (42, 33), self.env('INT_LIST', cast=(int,))) assert_type_and_value(tuple, (42, 33), self.env.tuple('INT_LIST', int)) assert_type_and_value(tuple, ('42', '33'), self.env.tuple('INT_LIST')) + def test_int_tuple(self): + assert_type_and_value(tuple, (42, 33), self.env('INT_TUPLE', cast=(int,))) + assert_type_and_value(tuple, (42, 33), self.env.tuple('INT_TUPLE', int)) + assert_type_and_value(tuple, ('42', '33'), self.env.tuple('INT_TUPLE')) + + def test_mix_tuple_issue_387(self): + """Cast a tuple of mixed types. + + Casts a string like "(42,Test)" to a tuple like (42, 'Test'). + See: https://github.com/joke2k/django-environ/issues/387 for details.""" + caster = lambda v: int(v) if v.isdigit() else v.strip() + cast = lambda t: tuple(map(caster, [c for c in t.strip('()').split(',')])) + assert_type_and_value(tuple, (42, 'Test'), self.env( 'MIX_TUPLE', default=(0, ''), cast=cast)) + def test_str_list_with_spaces(self): assert_type_and_value(list, [' foo', ' bar'], self.env('STR_LIST_WITH_SPACES', cast=[str])) @@ -156,6 +187,13 @@ def test_empty_list(self): def test_dict_value(self): assert_type_and_value(dict, FakeEnv.DICT, self.env.dict('DICT_VAR')) + def test_complex_dict_value(self): + assert_type_and_value( + dict, + FakeEnv.SAML_ATTRIBUTE_MAPPING, + self.env.dict('SAML_ATTRIBUTE_MAPPING', cast={'value': tuple}) + ) + @pytest.mark.parametrize( 'value,cast,expected', [ @@ -304,6 +342,10 @@ def test_smart_cast(self): def test_exported(self): assert self.env('EXPORTED_VAR') == FakeEnv.EXPORTED + def test_prefix(self): + self.env.prefix = 'PREFIX_' + assert self.env('TEST') == 'foo' + class TestFileEnv(TestEnv): def setup_method(self, method): diff --git a/tests/test_env.txt b/tests/test_env.txt index 78422b18..69dc84e0 100644 --- a/tests/test_env.txt +++ b/tests/test_env.txt @@ -49,10 +49,17 @@ MULTILINE_ESCAPED_STR_VAR=---BEGIN---\\n---END--- INT_LIST=42,33 CYRILLIC_VAR=фуубар INT_TUPLE=(42,33) +MIX_TUPLE=(42,Test) DATABASE_ORACLE_TNS_URL=oracle://user:password@sid DATABASE_ORACLE_URL=oracle://user:password@host:1521/sid DATABASE_REDSHIFT_URL=redshift://user:password@examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev DATABASE_CUSTOM_BACKEND_URL=custom.backend://user:password@example.com:5430/database +# Djangosaml2's SAML_ATTRIBUTE_MAPPING +SAML_ATTRIBUTE_MAPPING="uid=username;mail=email;cn=first_name;sn=last_name;" + # Exports export EXPORTED_VAR="exported var" + +# Prefixed +PREFIX_TEST='foo' diff --git a/tests/test_path.py b/tests/test_path.py index 502cf780..ed33a7cb 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -47,7 +47,7 @@ def test_repr(volume): assert root.__repr__() == '' -def test_comparison(): +def test_comparison(volume): root = Path('/home') assert root.__eq__(Path('/home')) assert root in Path('/') @@ -62,6 +62,14 @@ def test_comparison(): assert Path('/home/foo/').__fspath__() == str(Path('/home/foo/')) assert ~Path('/home') == Path('/') + if sys.platform == 'win32': + assert Path('/home') == '{}home'.format(volume) + assert '{}home'.format(volume) == Path('/home') + else: + assert Path('/home') == '/home' + assert '/home' == Path('/home') + + assert Path('/home') != '/usr' def test_sum(): """Make sure Path correct handle __add__.""" diff --git a/tests/test_search.py b/tests/test_search.py index a81471e5..0992bf98 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -43,6 +43,12 @@ def test_solr_multicore_parsing(solr_url): 'elasticsearch5_backend.Elasticsearch5SearchEngine'), ('elasticsearch7://127.0.0.1:9200/index', 'elasticsearch7_backend.Elasticsearch7SearchEngine'), + ], + ids=[ + 'elasticsearch', + 'elasticsearch2', + 'elasticsearch5', + 'elasticsearch7', ] ) def test_elasticsearch_parsing(url, engine): diff --git a/tests/test_utils.py b/tests/test_utils.py index 523a72d3..f32d7cc3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,7 +7,7 @@ # the LICENSE.txt file that was distributed with this source code. import pytest -from environ.environ import _cast +from environ.environ import _cast, _cast_urlstr @pytest.mark.parametrize( @@ -20,3 +20,20 @@ def test_cast(literal): See https://github.com/joke2k/django-environ/issues/200 for details.""" assert _cast(literal) == literal + +@pytest.mark.parametrize( + "quoted_url_str,expected_unquoted_str", + [ + ("Le-%7BFsIaYnaQw%7Da2B%2F%5BV8bS+", "Le-{FsIaYnaQw}a2B/[V8bS+"), + ("my_test-string+", "my_test-string+"), + ("my%20test%20string+", "my test string+") + ] +) +def test_cast_urlstr(quoted_url_str, expected_unquoted_str): + """Make sure that a url str that contains plus sign literals does not get unquoted incorrectly + Plus signs should not be converted to spaces, since spaces are encoded with %20 in URIs + + see https://github.com/joke2k/django-environ/issues/357 for details. + related to https://github.com/joke2k/django-environ/pull/69""" + + assert _cast_urlstr(quoted_url_str) == expected_unquoted_str diff --git a/tox.ini b/tox.ini index 4877c140..73e6a6ba 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,7 @@ envlist = manifest py{35,36,37,38,39,310}-django{111,22} py{36,37,38,39,310}-django{30,31,32} + py{38,39,310}-django{40} pypy-django{111,22,30,31,32} [gh-actions] @@ -41,6 +42,7 @@ deps = django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 + django40: Django>=4.0,<4.1 commands_pre = python -m pip install --upgrade pip python -m pip install . @@ -74,7 +76,7 @@ commands = flake8 environ setup.py description = Check external links in the package documentation # Keep basepython in sync with .readthedocs.yml and docs.yml # (GitHub Action Workflow). -basepython = python3.8 +basepython = python3.10 extras = docs commands = {envpython} -m sphinx \ @@ -92,7 +94,7 @@ isolated_build = true description = Build package documentation (HTML) # Keep basepython in sync with .readthedocs.yml and docs.yml # (GitHub Action Workflow). -basepython = python3.8 +basepython = python3.10 extras = docs commands = {envpython} -m sphinx \