update env file precedence (#5630)

This commit is contained in:
David Lord 2024-11-07 11:56:26 -08:00 committed by GitHub
commit 7522c4bcdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 90 additions and 47 deletions

View file

@ -17,6 +17,9 @@ Unreleased
about resource limits to the security page. :issue:`5625` about resource limits to the security page. :issue:`5625`
- Add support for the ``Partitioned`` cookie attribute (CHIPS), with the - Add support for the ``Partitioned`` cookie attribute (CHIPS), with the
``SESSION_COOKIE_PARTITIONED`` config. :issue`5472` ``SESSION_COOKIE_PARTITIONED`` config. :issue`5472`
- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
``load_dotenv`` loads default files in addition to a path unless
``load_defaults=False`` is passed. :issue:`5628`
Version 3.0.3 Version 3.0.3

View file

@ -297,6 +297,9 @@ class ScriptInfo:
a bigger role. Typically it's created automatically by the a bigger role. Typically it's created automatically by the
:class:`FlaskGroup` but you can also manually create it and pass it :class:`FlaskGroup` but you can also manually create it and pass it
onwards as click object. onwards as click object.
.. versionchanged:: 3.1
Added the ``load_dotenv_defaults`` parameter and attribute.
""" """
def __init__( def __init__(
@ -304,6 +307,7 @@ class ScriptInfo:
app_import_path: str | None = None, app_import_path: str | None = None,
create_app: t.Callable[..., Flask] | None = None, create_app: t.Callable[..., Flask] | None = None,
set_debug_flag: bool = True, set_debug_flag: bool = True,
load_dotenv_defaults: bool = True,
) -> None: ) -> None:
#: Optionally the import path for the Flask application. #: Optionally the import path for the Flask application.
self.app_import_path = app_import_path self.app_import_path = app_import_path
@ -314,6 +318,16 @@ class ScriptInfo:
#: this script info. #: this script info.
self.data: dict[t.Any, t.Any] = {} self.data: dict[t.Any, t.Any] = {}
self.set_debug_flag = set_debug_flag self.set_debug_flag = set_debug_flag
self.load_dotenv_defaults = get_load_dotenv(load_dotenv_defaults)
"""Whether default ``.flaskenv`` and ``.env`` files should be loaded.
``ScriptInfo`` doesn't load anything, this is for reference when doing
the load elsewhere during processing.
.. versionadded:: 3.1
"""
self._loaded_app: Flask | None = None self._loaded_app: Flask | None = None
def load_app(self) -> Flask: def load_app(self) -> Flask:
@ -479,23 +493,22 @@ _debug_option = click.Option(
def _env_file_callback( def _env_file_callback(
ctx: click.Context, param: click.Option, value: str | None ctx: click.Context, param: click.Option, value: str | None
) -> str | None: ) -> str | None:
if value is None:
return None
import importlib
try: try:
importlib.import_module("dotenv") import dotenv # noqa: F401
except ImportError: except ImportError:
raise click.BadParameter( # Only show an error if a value was passed, otherwise we still want to
"python-dotenv must be installed to load an env file.", # call load_dotenv and show a message without exiting.
ctx=ctx, if value is not None:
param=param, raise click.BadParameter(
) from None "python-dotenv must be installed to load an env file.",
ctx=ctx,
param=param,
) from None
# Load if a value was passed, or we want to load default files, or both.
if value is not None or ctx.obj.load_dotenv_defaults:
load_dotenv(value, load_defaults=ctx.obj.load_dotenv_defaults)
# Don't check FLASK_SKIP_DOTENV, that only disables automatically
# loading .env and .flaskenv files.
load_dotenv(value)
return value return value
@ -504,7 +517,11 @@ def _env_file_callback(
_env_file_option = click.Option( _env_file_option = click.Option(
["-e", "--env-file"], ["-e", "--env-file"],
type=click.Path(exists=True, dir_okay=False), type=click.Path(exists=True, dir_okay=False),
help="Load environment variables from this file. python-dotenv must be installed.", help=(
"Load environment variables from this file, taking precedence over"
" those set by '.env' and '.flaskenv'. Variables set directly in the"
" environment take highest precedence. python-dotenv must be installed."
),
is_eager=True, is_eager=True,
expose_value=False, expose_value=False,
callback=_env_file_callback, callback=_env_file_callback,
@ -528,6 +545,9 @@ class FlaskGroup(AppGroup):
directory to the directory containing the first file found. directory to the directory containing the first file found.
:param set_debug_flag: Set the app's debug flag. :param set_debug_flag: Set the app's debug flag.
.. versionchanged:: 3.1
``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
.. versionchanged:: 2.2 .. versionchanged:: 2.2
Added the ``-A/--app``, ``--debug/--no-debug``, ``-e/--env-file`` options. Added the ``-A/--app``, ``--debug/--no-debug``, ``-e/--env-file`` options.
@ -654,14 +674,11 @@ class FlaskGroup(AppGroup):
# when importing, blocking whatever command is being called. # when importing, blocking whatever command is being called.
os.environ["FLASK_RUN_FROM_CLI"] = "true" os.environ["FLASK_RUN_FROM_CLI"] = "true"
# Attempt to load .env and .flask env files. The --env-file
# option can cause another file to be loaded.
if get_load_dotenv(self.load_dotenv):
load_dotenv()
if "obj" not in extra and "obj" not in self.context_settings: if "obj" not in extra and "obj" not in self.context_settings:
extra["obj"] = ScriptInfo( extra["obj"] = ScriptInfo(
create_app=self.create_app, set_debug_flag=self.set_debug_flag create_app=self.create_app,
set_debug_flag=self.set_debug_flag,
load_dotenv_defaults=self.load_dotenv,
) )
return super().make_context(info_name, args, parent=parent, **extra) return super().make_context(info_name, args, parent=parent, **extra)
@ -684,18 +701,26 @@ def _path_is_ancestor(path: str, other: str) -> bool:
return os.path.join(path, other[len(path) :].lstrip(os.sep)) == other return os.path.join(path, other[len(path) :].lstrip(os.sep)) == other
def load_dotenv(path: str | os.PathLike[str] | None = None) -> bool: def load_dotenv(
"""Load "dotenv" files in order of precedence to set environment variables. path: str | os.PathLike[str] | None = None, load_defaults: bool = True
) -> bool:
If an env var is already set it is not overwritten, so earlier files in the """Load "dotenv" files to set environment variables. A given path takes
list are preferred over later files. precedence over ``.env``, which takes precedence over ``.flaskenv``. After
loading and combining these files, values are only set if the key is not
already set in ``os.environ``.
This is a no-op if `python-dotenv`_ is not installed. This is a no-op if `python-dotenv`_ is not installed.
.. _python-dotenv: https://github.com/theskumar/python-dotenv#readme .. _python-dotenv: https://github.com/theskumar/python-dotenv#readme
:param path: Load the file at this location instead of searching. :param path: Load the file at this location.
:return: ``True`` if a file was loaded. :param load_defaults: Search for and load the default ``.flaskenv`` and
``.env`` files.
:return: ``True`` if at least one env var was loaded.
.. versionchanged:: 3.1
Added the ``load_defaults`` parameter. A given path takes precedence
over default files.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
The current directory is not changed to the location of the The current directory is not changed to the location of the
@ -715,34 +740,33 @@ def load_dotenv(path: str | os.PathLike[str] | None = None) -> bool:
except ImportError: except ImportError:
if path or os.path.isfile(".env") or os.path.isfile(".flaskenv"): if path or os.path.isfile(".env") or os.path.isfile(".flaskenv"):
click.secho( click.secho(
" * Tip: There are .env or .flaskenv files present." " * Tip: There are .env files present. Install python-dotenv"
' Do "pip install python-dotenv" to use them.', " to use them.",
fg="yellow", fg="yellow",
err=True, err=True,
) )
return False return False
# Always return after attempting to load a given path, don't load data: dict[str, str | None] = {}
# the default files.
if path is not None:
if os.path.isfile(path):
return dotenv.load_dotenv(path, encoding="utf-8")
return False if load_defaults:
for default_name in (".flaskenv", ".env"):
if not (default_path := dotenv.find_dotenv(default_name, usecwd=True)):
continue
loaded = False data |= dotenv.dotenv_values(default_path, encoding="utf-8")
for name in (".env", ".flaskenv"): if path is not None and os.path.isfile(path):
path = dotenv.find_dotenv(name, usecwd=True) data |= dotenv.dotenv_values(path, encoding="utf-8")
if not path: for key, value in data.items():
if key in os.environ or value is None:
continue continue
dotenv.load_dotenv(path, encoding="utf-8") os.environ[key] = value
loaded = True
return loaded # True if at least one file was located and loaded. return bool(data) # True if at least one env var was loaded.
def show_server_banner(debug: bool, app_import_path: str | None) -> None: def show_server_banner(debug: bool, app_import_path: str | None) -> None:

View file

@ -398,7 +398,12 @@ def test_flaskgroup_nested(app, runner):
def test_no_command_echo_loading_error(): def test_no_command_echo_loading_error():
from flask.cli import cli from flask.cli import cli
runner = CliRunner(mix_stderr=False) try:
runner = CliRunner(mix_stderr=False)
except (DeprecationWarning, TypeError):
# Click >= 8.2
runner = CliRunner()
result = runner.invoke(cli, ["missing"]) result = runner.invoke(cli, ["missing"])
assert result.exit_code == 2 assert result.exit_code == 2
assert "FLASK_APP" in result.stderr assert "FLASK_APP" in result.stderr
@ -408,7 +413,12 @@ def test_no_command_echo_loading_error():
def test_help_echo_loading_error(): def test_help_echo_loading_error():
from flask.cli import cli from flask.cli import cli
runner = CliRunner(mix_stderr=False) try:
runner = CliRunner(mix_stderr=False)
except (DeprecationWarning, TypeError):
# Click >= 8.2
runner = CliRunner()
result = runner.invoke(cli, ["--help"]) result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "FLASK_APP" in result.stderr assert "FLASK_APP" in result.stderr
@ -420,7 +430,13 @@ def test_help_echo_exception():
raise Exception("oh no") raise Exception("oh no")
cli = FlaskGroup(create_app=create_app) cli = FlaskGroup(create_app=create_app)
runner = CliRunner(mix_stderr=False)
try:
runner = CliRunner(mix_stderr=False)
except (DeprecationWarning, TypeError):
# Click >= 8.2
runner = CliRunner()
result = runner.invoke(cli, ["--help"]) result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Exception: oh no" in result.stderr assert "Exception: oh no" in result.stderr
@ -537,7 +553,7 @@ def test_load_dotenv(monkeypatch):
# test env file encoding # test env file encoding
assert os.environ["HAM"] == "火腿" assert os.environ["HAM"] == "火腿"
# Non existent file should not load # Non existent file should not load
assert not load_dotenv("non-existent-file") assert not load_dotenv("non-existent-file", load_defaults=False)
@need_dotenv @need_dotenv