Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow loading of environment variables into the config #4479

Merged
merged 3 commits into from Mar 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.rst
Expand Up @@ -55,6 +55,10 @@ Unreleased
- From Werkzeug, for redirect responses the ``Location`` header URL
will remain relative, and exclude the scheme and domain, by default.
:pr:`4496`
- Add ``Config.from_prefixed_env()`` to load config values from
environment variables that start with ``FLASK_`` or another prefix.
This parses values as JSON by default, and allows setting keys in
nested dicts. :pr:`4479`


Version 2.0.3
Expand Down
80 changes: 51 additions & 29 deletions docs/config.rst
Expand Up @@ -515,75 +515,93 @@ Or from a JSON file:
Configuring from Environment Variables
--------------------------------------

In addition to pointing to configuration files using environment variables, you
may find it useful (or necessary) to control your configuration values directly
from the environment.
In addition to pointing to configuration files using environment
variables, you may find it useful (or necessary) to control your
configuration values directly from the environment. Flask can be
instructed to load all environment variables starting with a specific
prefix into the config using :meth:`~flask.Config.from_prefixed_env`.

Environment variables can be set in the shell before starting the server:
Environment variables can be set in the shell before starting the
server:

.. tabs::

.. group-tab:: Bash

.. code-block:: text

$ export SECRET_KEY="5f352379324c22463451387a0aec5d2f"
$ export MAIL_ENABLED=false
$ export FLASK_SECRET_KEY="5f352379324c22463451387a0aec5d2f"
$ export FLASK_MAIL_ENABLED=false
$ flask run
* Running on http://127.0.0.1:5000/

.. group-tab:: Fish

.. code-block:: text

$ set -x SECRET_KEY "5f352379324c22463451387a0aec5d2f"
$ set -x MAIL_ENABLED false
$ set -x FLASK_SECRET_KEY "5f352379324c22463451387a0aec5d2f"
$ set -x FLASK_MAIL_ENABLED false
$ flask run
* Running on http://127.0.0.1:5000/

.. group-tab:: CMD

.. code-block:: text

> set SECRET_KEY="5f352379324c22463451387a0aec5d2f"
> set MAIL_ENABLED=false
> set FLASK_SECRET_KEY="5f352379324c22463451387a0aec5d2f"
> set FLASK_MAIL_ENABLED=false
> flask run
* Running on http://127.0.0.1:5000/

.. group-tab:: Powershell

.. code-block:: text

> $env:SECRET_KEY = "5f352379324c22463451387a0aec5d2f"
> $env:MAIL_ENABLED = "false"
> $env:FLASK_SECRET_KEY = "5f352379324c22463451387a0aec5d2f"
> $env:FLASK_MAIL_ENABLED = "false"
> flask run
* Running on http://127.0.0.1:5000/

While this approach is straightforward to use, it is important to remember that
environment variables are strings -- they are not automatically deserialized
into Python types.
The variables can then be loaded and accessed via the config with a key
equal to the environment variable name without the prefix i.e.

Here is an example of a configuration file that uses environment variables::
.. code-block:: python

app.config.from_prefixed_env()
app.config["SECRET_KEY"] # Is "5f352379324c22463451387a0aec5d2f"

The prefix is ``FLASK_`` by default. This is configurable via the
``prefix`` argument of :meth:`~flask.Config.from_prefixed_env`.

import os
Values will be parsed to attempt to convert them to a more specific type
than strings. By default :func:`json.loads` is used, so any valid JSON
value is possible, including lists and dicts. This is configurable via
the ``loads`` argument of :meth:`~flask.Config.from_prefixed_env`.

_mail_enabled = os.environ.get("MAIL_ENABLED", default="true")
MAIL_ENABLED = _mail_enabled.lower() in {"1", "t", "true"}
When adding a boolean value with the default JSON parsing, only "true"
and "false", lowercase, are valid values. Keep in mind that any
non-empty string is considered ``True`` by Python.

SECRET_KEY = os.environ.get("SECRET_KEY")
It is possible to set keys in nested dictionaries by separating the
keys with double underscore (``__``). Any intermediate keys that don't
exist on the parent dict will be initialized to an empty dict.

if not SECRET_KEY:
raise ValueError("No SECRET_KEY set for Flask application")
.. code-block:: text

$ export FLASK_MYAPI__credentials__username=user123

.. code-block:: python

Notice that any value besides an empty string will be interpreted as a boolean
``True`` value in Python, which requires care if an environment explicitly sets
values intended to be ``False``.
app.config["MYAPI"]["credentials"]["username"] # Is "user123"

Make sure to load the configuration very early on, so that extensions have the
ability to access the configuration when starting up. There are other methods
on the config object as well to load from individual files. For a complete
reference, read the :class:`~flask.Config` class documentation.
On Windows, environment variable keys are always uppercase, therefore
the above example would end up as ``MYAPI__CREDENTIALS__USERNAME``.

For even more config loading features, including merging and
case-insensitive Windows support, try a dedicated library such as
Dynaconf_, which includes integration with Flask.

.. _Dynaconf: https://www.dynaconf.com/


Configuration Best Practices
Expand All @@ -603,6 +621,10 @@ that experience:
limit yourself to request-only accesses to the configuration you can
reconfigure the object later on as needed.

3. Make sure to load the configuration very early on, so that
extensions can access the configuration when calling ``init_app``.


.. _config-dev-prod:

Development / Production
Expand Down
74 changes: 73 additions & 1 deletion src/flask/config.py
@@ -1,11 +1,19 @@
import errno
import json
import os
import types
import typing as t

from werkzeug.utils import import_string


def _json_loads(raw: t.Union[str, bytes]) -> t.Any:
try:
return json.loads(raw)
except json.JSONDecodeError:
return raw


class ConfigAttribute:
"""Makes an attribute forward to the config"""

Expand Down Expand Up @@ -70,7 +78,7 @@ class Config(dict):
"""

def __init__(self, root_path: str, defaults: t.Optional[dict] = None) -> None:
dict.__init__(self, defaults or {})
super().__init__(defaults or {})
self.root_path = root_path

def from_envvar(self, variable_name: str, silent: bool = False) -> bool:
Expand All @@ -97,6 +105,70 @@ def from_envvar(self, variable_name: str, silent: bool = False) -> bool:
)
return self.from_pyfile(rv, silent=silent)

def from_prefixed_env(
self, prefix: str = "FLASK", *, loads: t.Callable[[str], t.Any] = json.loads
) -> bool:
"""Load any environment variables that start with ``FLASK_``,
dropping the prefix from the env key for the config key. Values
are passed through a loading function to attempt to convert them
to more specific types than strings.

Keys are loaded in :func:`sorted` order.

The default loading function attempts to parse values as any
valid JSON type, including dicts and lists.

Specific items in nested dicts can be set by separating the
keys with double underscores (``__``). If an intermediate key
doesn't exist, it will be initialized to an empty dict.

:param prefix: Load env vars that start with this prefix,
separated with an underscore (``_``).
:param loads: Pass each string value to this function and use
the returned value as the config value. If any error is
raised it is ignored and the value remains a string. The
default is :func:`json.loads`.

.. versionadded:: 2.1
"""
prefix = f"{prefix}_"
len_prefix = len(prefix)

for key in sorted(os.environ):
if not key.startswith(prefix):
continue

value = os.environ[key]

try:
value = loads(value)
except Exception:
# Keep the value as a string if loading failed.
pass

# Change to key.removeprefix(prefix) on Python >= 3.9.
key = key[len_prefix:]

if "__" not in key:
# A non-nested key, set directly.
self[key] = value
continue

# Traverse nested dictionaries with keys separated by "__".
current = self
*parts, tail = key.split("__")

for part in parts:
# If an intermediate dict does not exist, create it.
if part not in current:
current[part] = {}

current = current[part]

current[tail] = value

return True

def from_pyfile(self, filename: str, silent: bool = False) -> bool:
"""Updates the values in the config from a Python file. This function
behaves as if the file was imported as module with the
Expand Down
62 changes: 62 additions & 0 deletions tests/test_config.py
Expand Up @@ -38,6 +38,68 @@ def test_config_from_file():
common_object_test(app)


def test_from_prefixed_env(monkeypatch):
monkeypatch.setenv("FLASK_STRING", "value")
monkeypatch.setenv("FLASK_BOOL", "true")
monkeypatch.setenv("FLASK_INT", "1")
monkeypatch.setenv("FLASK_FLOAT", "1.2")
monkeypatch.setenv("FLASK_LIST", "[1, 2]")
monkeypatch.setenv("FLASK_DICT", '{"k": "v"}')
monkeypatch.setenv("NOT_FLASK_OTHER", "other")

app = flask.Flask(__name__)
app.config.from_prefixed_env()

assert app.config["STRING"] == "value"
assert app.config["BOOL"] is True
assert app.config["INT"] == 1
assert app.config["FLOAT"] == 1.2
assert app.config["LIST"] == [1, 2]
assert app.config["DICT"] == {"k": "v"}
assert "OTHER" not in app.config
davidism marked this conversation as resolved.
Show resolved Hide resolved


def test_from_prefixed_env_custom_prefix(monkeypatch):
monkeypatch.setenv("FLASK_A", "a")
monkeypatch.setenv("NOT_FLASK_A", "b")

app = flask.Flask(__name__)
app.config.from_prefixed_env("NOT_FLASK")

assert app.config["A"] == "b"


def test_from_prefixed_env_nested(monkeypatch):
monkeypatch.setenv("FLASK_EXIST__ok", "other")
monkeypatch.setenv("FLASK_EXIST__inner__ik", "2")
monkeypatch.setenv("FLASK_EXIST__new__more", '{"k": false}')
monkeypatch.setenv("FLASK_NEW__K", "v")

app = flask.Flask(__name__)
app.config["EXIST"] = {"ok": "value", "flag": True, "inner": {"ik": 1}}
app.config.from_prefixed_env()

if os.name != "nt":
assert app.config["EXIST"] == {
"ok": "other",
"flag": True,
"inner": {"ik": 2},
"new": {"more": {"k": False}},
}
else:
# Windows env var keys are always uppercase.
assert app.config["EXIST"] == {
"ok": "value",
"OK": "other",
"flag": True,
"inner": {"ik": 1},
"INNER": {"IK": 2},
"NEW": {"MORE": {"k": False}},
}

assert app.config["NEW"] == {"K": "v"}


def test_config_from_mapping():
app = flask.Flask(__name__)
app.config.from_mapping({"SECRET_KEY": "config", "TEST_KEY": "foo"})
Expand Down