Skip to content

Commit

Permalink
update env file precedence
Browse files Browse the repository at this point in the history
  • Loading branch information
davidism committed Nov 7, 2024
1 parent ce08bc7 commit 06b072f
Show file tree
Hide file tree
Showing 3 changed files with 66 additions and 43 deletions.
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 62 additions & 42 deletions src/flask/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,13 +297,17 @@ 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.
.. verionchnaged:: 3.1
Added the ``load_dotenv_defaults`` parameter and attribute.
"""

def __init__(
self,
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
Expand All @@ -314,6 +318,16 @@ def __init__(
#: 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:
Expand Down Expand Up @@ -479,23 +493,22 @@ def _set_debug(ctx: click.Context, param: click.Option, value: bool) -> bool | N
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


Expand Down Expand Up @@ -528,6 +541,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.
Expand Down Expand Up @@ -654,14 +670,11 @@ def make_context(
# 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)
Expand All @@ -684,18 +697,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
Expand All @@ -715,34 +736,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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,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
Expand Down

0 comments on commit 06b072f

Please sign in to comment.