diff --git a/CHANGES.rst b/CHANGES.rst index 118751e9..8c19cafa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,9 @@ Unreleased about resource limits to the security page. :issue:`5625` - Add support for the ``Partitioned`` cookie attribute (CHIPS), with the ``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 diff --git a/src/flask/cli.py b/src/flask/cli.py index 8e72db5a..dd03f3c5 100644 --- a/src/flask/cli.py +++ b/src/flask/cli.py @@ -297,6 +297,9 @@ class ScriptInfo: a bigger role. Typically it's created automatically by the :class:`FlaskGroup` but you can also manually create it and pass it onwards as click object. + + .. versionchanged:: 3.1 + Added the ``load_dotenv_defaults`` parameter and attribute. """ def __init__( @@ -304,6 +307,7 @@ class ScriptInfo: app_import_path: str | None = None, create_app: t.Callable[..., Flask] | None = None, set_debug_flag: bool = True, + load_dotenv_defaults: bool = True, ) -> None: #: Optionally the import path for the Flask application. self.app_import_path = app_import_path @@ -314,6 +318,16 @@ class ScriptInfo: #: this script info. self.data: dict[t.Any, t.Any] = {} 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 def load_app(self) -> Flask: @@ -479,23 +493,22 @@ _debug_option = click.Option( def _env_file_callback( ctx: click.Context, param: click.Option, value: str | None ) -> str | None: - if value is None: - return None - - import importlib - try: - importlib.import_module("dotenv") + import dotenv # noqa: F401 except ImportError: - raise click.BadParameter( - "python-dotenv must be installed to load an env file.", - ctx=ctx, - param=param, - ) from None + # Only show an error if a value was passed, otherwise we still want to + # call load_dotenv and show a message without exiting. + if value is not None: + raise click.BadParameter( + "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 @@ -504,7 +517,11 @@ def _env_file_callback( _env_file_option = click.Option( ["-e", "--env-file"], 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, expose_value=False, callback=_env_file_callback, @@ -528,6 +545,9 @@ class FlaskGroup(AppGroup): directory to the directory containing the first file found. :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 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. 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: 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) @@ -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 -def load_dotenv(path: str | os.PathLike[str] | None = None) -> bool: - """Load "dotenv" files in order of precedence to set environment variables. - - If an env var is already set it is not overwritten, so earlier files in the - list are preferred over later files. +def load_dotenv( + path: str | os.PathLike[str] | None = None, load_defaults: bool = True +) -> bool: + """Load "dotenv" files to set environment variables. A given path takes + 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. .. _python-dotenv: https://github.com/theskumar/python-dotenv#readme - :param path: Load the file at this location instead of searching. - :return: ``True`` if a file was loaded. + :param path: Load the file at this location. + :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 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: if path or os.path.isfile(".env") or os.path.isfile(".flaskenv"): click.secho( - " * Tip: There are .env or .flaskenv files present." - ' Do "pip install python-dotenv" to use them.', + " * Tip: There are .env files present. Install python-dotenv" + " to use them.", fg="yellow", err=True, ) return False - # Always return after attempting to load a given path, don't load - # the default files. - if path is not None: - if os.path.isfile(path): - return dotenv.load_dotenv(path, encoding="utf-8") + data: dict[str, str | None] = {} - 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"): - path = dotenv.find_dotenv(name, usecwd=True) + if path is not None and os.path.isfile(path): + 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 - dotenv.load_dotenv(path, encoding="utf-8") - loaded = True + os.environ[key] = value - 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: diff --git a/tests/test_cli.py b/tests/test_cli.py index 09995488..e254c1dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -398,7 +398,12 @@ def test_flaskgroup_nested(app, runner): def test_no_command_echo_loading_error(): 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"]) assert result.exit_code == 2 assert "FLASK_APP" in result.stderr @@ -408,7 +413,12 @@ def test_no_command_echo_loading_error(): def test_help_echo_loading_error(): 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"]) assert result.exit_code == 0 assert "FLASK_APP" in result.stderr @@ -420,7 +430,13 @@ def test_help_echo_exception(): raise Exception("oh no") 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"]) assert result.exit_code == 0 assert "Exception: oh no" in result.stderr @@ -537,7 +553,7 @@ def test_load_dotenv(monkeypatch): # test env file encoding assert os.environ["HAM"] == "火腿" # 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