From bc46ecae092ba7605302805c417f6326602f5fcd Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 8 Mar 2022 21:40:48 +0000 Subject: [PATCH] Allow loading of environment variables into the config This new method will pick out any environment variables with a certain prefix and place them into the config named without the prefix. This makes it easy to use environment variables to configure the app as is now more popular than when Flask started. The prefix should ensure that the environment isn't polluted and the config isn't polluted by environment variables. I've followed the dynaconf convention of trying to parse the environment variable and then falling back to the raw value if parsing fails. --- CHANGES.rst | 2 ++ docs/config.rst | 47 ++++++++++++++++++++++---------------------- src/flask/config.py | 46 +++++++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 24 ++++++++++++++++++++++ 4 files changed, 96 insertions(+), 23 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ec3009a3f6..633e6ea7c4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -52,6 +52,8 @@ Unreleased :issue:`4095, 4295, 4297` - Fix typing for ``__exit__`` methods for better compatibility with ``ExitStack``. :issue:`4474` +- Allow loading of prefixed environment variables into the Config. + :pr:`4479` Version 2.0.3 diff --git a/docs/config.rst b/docs/config.rst index 0b86674d88..9e66e01e50 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -515,9 +515,11 @@ 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: @@ -527,8 +529,8 @@ Environment variables can be set in the shell before starting the server: .. 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/ @@ -536,8 +538,8 @@ Environment variables can be set in the shell before starting the server: .. 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/ @@ -545,8 +547,8 @@ Environment variables can be set in the shell before starting the server: .. 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/ @@ -554,27 +556,26 @@ Environment variables can be set in the shell before starting the server: .. 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:: - - import os - - _mail_enabled = os.environ.get("MAIL_ENABLED", default="true") - MAIL_ENABLED = _mail_enabled.lower() in {"1", "t", "true"} +.. code-block:: python - SECRET_KEY = os.environ.get("SECRET_KEY") + app.config.from_prefixed_env() + app.config["SECRET_KEY"] # Is "5f352379324c22463451387a0aec5d2f" - if not SECRET_KEY: - raise ValueError("No SECRET_KEY set for Flask application") +The prefix is ``FLASK_`` by default, however it is an configurable via +the ``prefix`` argument of :meth:`~flask.Config.from_prefixed_env`. +Whilst the value of any environment variable is a string, it will be +parsed before being placed into the flask config. By default the +parsing is done by json.loads, however this is configurable via the +``loads`` argument of :meth:`~flask.Config.from_prefixed_env`. 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 diff --git a/src/flask/config.py b/src/flask/config.py index 9657edc80a..c02db27218 100644 --- a/src/flask/config.py +++ b/src/flask/config.py @@ -1,4 +1,5 @@ import errno +import json import os import types import typing as t @@ -6,6 +7,13 @@ 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""" @@ -97,6 +105,44 @@ 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[[t.Union[str, bytes]], t.Any] = _json_loads, + ) -> bool: + """Updates the config from environment variables with the prefix. + + Calling this method will result in every environment variable + starting with **prefix** being placed into the configuration + without the **prefix**. The prefix is configurable as an + argument. Note that this method updates the existing config. + + For example if there is an environment variable + ``FLASK_SECRET_KEY`` with value ``secretly`` and the prefix is + ``FLASK_`` the config will contain the key ``SECRET_KEY`` with + the value ``secretly`` after calling this method. + + The value of the environment variable will be passed to the + **loads** parameter before being placed into the config. By + default **loads** utilises the stdlib json.loads to parse the + value, falling back to the value itself on parsing error. + + :param loads: A callable that takes a str (or bytes) returns + the parsed value. + :return: Always returns ``True``. + + .. versionadded:: 2.1.0 + + """ + mapping = {} + for raw_key, value in os.environ.items(): + if raw_key.startswith(prefix): + key = raw_key[len(prefix) :] # Use removeprefix with Python 3.9 + mapping[key] = loads(value) + + return self.from_mapping(mapping) + 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 diff --git a/tests/test_config.py b/tests/test_config.py index a3cd3d25bd..e2d5cd632a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -38,6 +38,30 @@ def test_config_from_file(): common_object_test(app) +def test_config_from_prefixed_env(monkeypatch): + app = flask.Flask(__name__) + monkeypatch.setenv("FLASK_A", "A value") + monkeypatch.setenv("FLASK_B", "true") + monkeypatch.setenv("FLASK_C", "1") + monkeypatch.setenv("FLASK_D", "1.2") + monkeypatch.setenv("NOT_FLASK_A", "Another value") + app.config.from_prefixed_env() + assert app.config["A"] == "A value" + assert app.config["B"] is True + assert app.config["C"] == 1 + assert app.config["D"] == 1.2 + assert "Another value" not in app.config.items() + + +def test_config_from_custom_prefixed_env(monkeypatch): + app = flask.Flask(__name__) + monkeypatch.setenv("FLASK_A", "A value") + monkeypatch.setenv("NOT_FLASK_A", "Another value") + app.config.from_prefixed_env("NOT_FLASK_") + assert app.config["A"] == "Another value" + assert "A value" not in app.config.items() + + def test_config_from_mapping(): app = flask.Flask(__name__) app.config.from_mapping({"SECRET_KEY": "config", "TEST_KEY": "foo"})