Skip to content

Commit 7522c4b

Browse files
authored
update env file precedence (#5630)
2 parents ce08bc7 + 2c31603 commit 7522c4b

File tree

3 files changed

+90
-47
lines changed

3 files changed

+90
-47
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Unreleased
1717
about resource limits to the security page. :issue:`5625`
1818
- Add support for the ``Partitioned`` cookie attribute (CHIPS), with the
1919
``SESSION_COOKIE_PARTITIONED`` config. :issue`5472`
20+
- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
21+
``load_dotenv`` loads default files in addition to a path unless
22+
``load_defaults=False`` is passed. :issue:`5628`
2023

2124

2225
Version 3.0.3

src/flask/cli.py

Lines changed: 67 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,17 @@ class ScriptInfo:
297297
a bigger role. Typically it's created automatically by the
298298
:class:`FlaskGroup` but you can also manually create it and pass it
299299
onwards as click object.
300+
301+
.. versionchanged:: 3.1
302+
Added the ``load_dotenv_defaults`` parameter and attribute.
300303
"""
301304

302305
def __init__(
303306
self,
304307
app_import_path: str | None = None,
305308
create_app: t.Callable[..., Flask] | None = None,
306309
set_debug_flag: bool = True,
310+
load_dotenv_defaults: bool = True,
307311
) -> None:
308312
#: Optionally the import path for the Flask application.
309313
self.app_import_path = app_import_path
@@ -314,6 +318,16 @@ def __init__(
314318
#: this script info.
315319
self.data: dict[t.Any, t.Any] = {}
316320
self.set_debug_flag = set_debug_flag
321+
322+
self.load_dotenv_defaults = get_load_dotenv(load_dotenv_defaults)
323+
"""Whether default ``.flaskenv`` and ``.env`` files should be loaded.
324+
325+
``ScriptInfo`` doesn't load anything, this is for reference when doing
326+
the load elsewhere during processing.
327+
328+
.. versionadded:: 3.1
329+
"""
330+
317331
self._loaded_app: Flask | None = None
318332

319333
def load_app(self) -> Flask:
@@ -479,23 +493,22 @@ def _set_debug(ctx: click.Context, param: click.Option, value: bool) -> bool | N
479493
def _env_file_callback(
480494
ctx: click.Context, param: click.Option, value: str | None
481495
) -> str | None:
482-
if value is None:
483-
return None
484-
485-
import importlib
486-
487496
try:
488-
importlib.import_module("dotenv")
497+
import dotenv # noqa: F401
489498
except ImportError:
490-
raise click.BadParameter(
491-
"python-dotenv must be installed to load an env file.",
492-
ctx=ctx,
493-
param=param,
494-
) from None
499+
# Only show an error if a value was passed, otherwise we still want to
500+
# call load_dotenv and show a message without exiting.
501+
if value is not None:
502+
raise click.BadParameter(
503+
"python-dotenv must be installed to load an env file.",
504+
ctx=ctx,
505+
param=param,
506+
) from None
507+
508+
# Load if a value was passed, or we want to load default files, or both.
509+
if value is not None or ctx.obj.load_dotenv_defaults:
510+
load_dotenv(value, load_defaults=ctx.obj.load_dotenv_defaults)
495511

496-
# Don't check FLASK_SKIP_DOTENV, that only disables automatically
497-
# loading .env and .flaskenv files.
498-
load_dotenv(value)
499512
return value
500513

501514

@@ -504,7 +517,11 @@ def _env_file_callback(
504517
_env_file_option = click.Option(
505518
["-e", "--env-file"],
506519
type=click.Path(exists=True, dir_okay=False),
507-
help="Load environment variables from this file. python-dotenv must be installed.",
520+
help=(
521+
"Load environment variables from this file, taking precedence over"
522+
" those set by '.env' and '.flaskenv'. Variables set directly in the"
523+
" environment take highest precedence. python-dotenv must be installed."
524+
),
508525
is_eager=True,
509526
expose_value=False,
510527
callback=_env_file_callback,
@@ -528,6 +545,9 @@ class FlaskGroup(AppGroup):
528545
directory to the directory containing the first file found.
529546
:param set_debug_flag: Set the app's debug flag.
530547
548+
.. versionchanged:: 3.1
549+
``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files.
550+
531551
.. versionchanged:: 2.2
532552
Added the ``-A/--app``, ``--debug/--no-debug``, ``-e/--env-file`` options.
533553
@@ -654,14 +674,11 @@ def make_context(
654674
# when importing, blocking whatever command is being called.
655675
os.environ["FLASK_RUN_FROM_CLI"] = "true"
656676

657-
# Attempt to load .env and .flask env files. The --env-file
658-
# option can cause another file to be loaded.
659-
if get_load_dotenv(self.load_dotenv):
660-
load_dotenv()
661-
662677
if "obj" not in extra and "obj" not in self.context_settings:
663678
extra["obj"] = ScriptInfo(
664-
create_app=self.create_app, set_debug_flag=self.set_debug_flag
679+
create_app=self.create_app,
680+
set_debug_flag=self.set_debug_flag,
681+
load_dotenv_defaults=self.load_dotenv,
665682
)
666683

667684
return super().make_context(info_name, args, parent=parent, **extra)
@@ -684,18 +701,26 @@ def _path_is_ancestor(path: str, other: str) -> bool:
684701
return os.path.join(path, other[len(path) :].lstrip(os.sep)) == other
685702

686703

687-
def load_dotenv(path: str | os.PathLike[str] | None = None) -> bool:
688-
"""Load "dotenv" files in order of precedence to set environment variables.
689-
690-
If an env var is already set it is not overwritten, so earlier files in the
691-
list are preferred over later files.
704+
def load_dotenv(
705+
path: str | os.PathLike[str] | None = None, load_defaults: bool = True
706+
) -> bool:
707+
"""Load "dotenv" files to set environment variables. A given path takes
708+
precedence over ``.env``, which takes precedence over ``.flaskenv``. After
709+
loading and combining these files, values are only set if the key is not
710+
already set in ``os.environ``.
692711
693712
This is a no-op if `python-dotenv`_ is not installed.
694713
695714
.. _python-dotenv: https://github.com/theskumar/python-dotenv#readme
696715
697-
:param path: Load the file at this location instead of searching.
698-
:return: ``True`` if a file was loaded.
716+
:param path: Load the file at this location.
717+
:param load_defaults: Search for and load the default ``.flaskenv`` and
718+
``.env`` files.
719+
:return: ``True`` if at least one env var was loaded.
720+
721+
.. versionchanged:: 3.1
722+
Added the ``load_defaults`` parameter. A given path takes precedence
723+
over default files.
699724
700725
.. versionchanged:: 2.0
701726
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:
715740
except ImportError:
716741
if path or os.path.isfile(".env") or os.path.isfile(".flaskenv"):
717742
click.secho(
718-
" * Tip: There are .env or .flaskenv files present."
719-
' Do "pip install python-dotenv" to use them.',
743+
" * Tip: There are .env files present. Install python-dotenv"
744+
" to use them.",
720745
fg="yellow",
721746
err=True,
722747
)
723748

724749
return False
725750

726-
# Always return after attempting to load a given path, don't load
727-
# the default files.
728-
if path is not None:
729-
if os.path.isfile(path):
730-
return dotenv.load_dotenv(path, encoding="utf-8")
751+
data: dict[str, str | None] = {}
731752

732-
return False
753+
if load_defaults:
754+
for default_name in (".flaskenv", ".env"):
755+
if not (default_path := dotenv.find_dotenv(default_name, usecwd=True)):
756+
continue
733757

734-
loaded = False
758+
data |= dotenv.dotenv_values(default_path, encoding="utf-8")
735759

736-
for name in (".env", ".flaskenv"):
737-
path = dotenv.find_dotenv(name, usecwd=True)
760+
if path is not None and os.path.isfile(path):
761+
data |= dotenv.dotenv_values(path, encoding="utf-8")
738762

739-
if not path:
763+
for key, value in data.items():
764+
if key in os.environ or value is None:
740765
continue
741766

742-
dotenv.load_dotenv(path, encoding="utf-8")
743-
loaded = True
767+
os.environ[key] = value
744768

745-
return loaded # True if at least one file was located and loaded.
769+
return bool(data) # True if at least one env var was loaded.
746770

747771

748772
def show_server_banner(debug: bool, app_import_path: str | None) -> None:

tests/test_cli.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,12 @@ def show():
398398
def test_no_command_echo_loading_error():
399399
from flask.cli import cli
400400

401-
runner = CliRunner(mix_stderr=False)
401+
try:
402+
runner = CliRunner(mix_stderr=False)
403+
except (DeprecationWarning, TypeError):
404+
# Click >= 8.2
405+
runner = CliRunner()
406+
402407
result = runner.invoke(cli, ["missing"])
403408
assert result.exit_code == 2
404409
assert "FLASK_APP" in result.stderr
@@ -408,7 +413,12 @@ def test_no_command_echo_loading_error():
408413
def test_help_echo_loading_error():
409414
from flask.cli import cli
410415

411-
runner = CliRunner(mix_stderr=False)
416+
try:
417+
runner = CliRunner(mix_stderr=False)
418+
except (DeprecationWarning, TypeError):
419+
# Click >= 8.2
420+
runner = CliRunner()
421+
412422
result = runner.invoke(cli, ["--help"])
413423
assert result.exit_code == 0
414424
assert "FLASK_APP" in result.stderr
@@ -420,7 +430,13 @@ def create_app():
420430
raise Exception("oh no")
421431

422432
cli = FlaskGroup(create_app=create_app)
423-
runner = CliRunner(mix_stderr=False)
433+
434+
try:
435+
runner = CliRunner(mix_stderr=False)
436+
except (DeprecationWarning, TypeError):
437+
# Click >= 8.2
438+
runner = CliRunner()
439+
424440
result = runner.invoke(cli, ["--help"])
425441
assert result.exit_code == 0
426442
assert "Exception: oh no" in result.stderr
@@ -537,7 +553,7 @@ def test_load_dotenv(monkeypatch):
537553
# test env file encoding
538554
assert os.environ["HAM"] == "火腿"
539555
# Non existent file should not load
540-
assert not load_dotenv("non-existent-file")
556+
assert not load_dotenv("non-existent-file", load_defaults=False)
541557

542558

543559
@need_dotenv

0 commit comments

Comments
 (0)