add --app, --env, --debug, and --env-file CLI options

This commit is contained in:
David Lord 2022-06-17 07:54:06 -07:00
parent fe4003b3c9
commit 99fa3c36ab
No known key found for this signature in database
GPG key ID: 7A1C87E3F5BC42A8
3 changed files with 197 additions and 54 deletions

View file

@ -27,6 +27,12 @@ Unreleased
instance on every request. :issue:`2520`. instance on every request. :issue:`2520`.
- A ``flask.cli.FlaskGroup`` Click group can be nested as a - A ``flask.cli.FlaskGroup`` Click group can be nested as a
sub-command in a custom CLI. :issue:`3263` sub-command in a custom CLI. :issue:`3263`
- Add ``--app``, ``--env``, and ``--debug`` options to the ``flask``
CLI, instead of requiring that they are set through environment
variables. :issue:`2836`
- Add ``--env-file`` option to the ``flask`` CLI. This allows
specifying a dotenv file to load in addition to ``.env`` and
``.flaskenv``. :issue:`3108`
Version 2.1.3 Version 2.1.3

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import ast import ast
import inspect import inspect
import os import os
@ -12,6 +14,7 @@ from threading import Lock
from threading import Thread from threading import Thread
import click import click
from click.core import ParameterSource
from werkzeug.serving import is_running_from_reloader from werkzeug.serving import is_running_from_reloader
from werkzeug.utils import import_string from werkzeug.utils import import_string
@ -20,6 +23,9 @@ from .helpers import get_debug_flag
from .helpers import get_env from .helpers import get_env
from .helpers import get_load_dotenv from .helpers import get_load_dotenv
if t.TYPE_CHECKING:
from .app import Flask
class NoAppException(click.UsageError): class NoAppException(click.UsageError):
"""Raised if an application cannot be found or loaded.""" """Raised if an application cannot be found or loaded."""
@ -46,8 +52,8 @@ def find_best_app(module):
elif len(matches) > 1: elif len(matches) > 1:
raise NoAppException( raise NoAppException(
"Detected multiple Flask applications in module" "Detected multiple Flask applications in module"
f" {module.__name__!r}. Use 'FLASK_APP={module.__name__}:name'" f" '{module.__name__}'. Use '{module.__name__}:name'"
f" to specify the correct one." " to specify the correct one."
) )
# Search for app factory functions. # Search for app factory functions.
@ -65,15 +71,15 @@ def find_best_app(module):
raise raise
raise NoAppException( raise NoAppException(
f"Detected factory {attr_name!r} in module {module.__name__!r}," f"Detected factory '{attr_name}' in module '{module.__name__}',"
" but could not call it without arguments. Use" " but could not call it without arguments. Use"
f" \"FLASK_APP='{module.__name__}:{attr_name}(args)'\"" f" '{module.__name__}:{attr_name}(args)'"
" to specify arguments." " to specify arguments."
) from e ) from e
raise NoAppException( raise NoAppException(
"Failed to find Flask application or factory in module" "Failed to find Flask application or factory in module"
f" {module.__name__!r}. Use 'FLASK_APP={module.__name__}:name'" f" '{module.__name__}'. Use '{module.__name__}:name'"
" to specify one." " to specify one."
) )
@ -253,7 +259,7 @@ def get_version(ctx, param, value):
version_option = click.Option( version_option = click.Option(
["--version"], ["--version"],
help="Show the flask version", help="Show the Flask version.",
expose_value=False, expose_value=False,
callback=get_version, callback=get_version,
is_flag=True, is_flag=True,
@ -338,19 +344,24 @@ class ScriptInfo:
onwards as click object. onwards as click object.
""" """
def __init__(self, app_import_path=None, create_app=None, set_debug_flag=True): def __init__(
self,
app_import_path: str | None = None,
create_app: t.Callable[..., Flask] | None = None,
set_debug_flag: bool = True,
) -> None:
#: Optionally the import path for the Flask application. #: Optionally the import path for the Flask application.
self.app_import_path = app_import_path or os.environ.get("FLASK_APP") self.app_import_path = app_import_path
#: Optionally a function that is passed the script info to create #: Optionally a function that is passed the script info to create
#: the instance of the application. #: the instance of the application.
self.create_app = create_app self.create_app = create_app
#: A dictionary with arbitrary data that can be associated with #: A dictionary with arbitrary data that can be associated with
#: this script info. #: this script info.
self.data = {} self.data: t.Dict[t.Any, t.Any] = {}
self.set_debug_flag = set_debug_flag self.set_debug_flag = set_debug_flag
self._loaded_app = None self._loaded_app: Flask | None = None
def load_app(self): def load_app(self) -> Flask:
"""Loads the Flask app (if not yet loaded) and returns it. Calling """Loads the Flask app (if not yet loaded) and returns it. Calling
this multiple times will just result in the already loaded app to this multiple times will just result in the already loaded app to
be returned. be returned.
@ -379,9 +390,10 @@ class ScriptInfo:
if not app: if not app:
raise NoAppException( raise NoAppException(
"Could not locate a Flask application. You did not provide " "Could not locate a Flask application. Use the"
'the "FLASK_APP" environment variable, and a "wsgi.py" or ' " 'flask --app' option, 'FLASK_APP' environment"
'"app.py" module was not found in the current directory.' " variable, or a 'wsgi.py' or 'app.py' file in the"
" current directory."
) )
if self.set_debug_flag: if self.set_debug_flag:
@ -442,6 +454,117 @@ class AppGroup(click.Group):
return click.Group.group(self, *args, **kwargs) return click.Group.group(self, *args, **kwargs)
def _set_app(ctx: click.Context, param: click.Option, value: str | None) -> str | None:
if value is None:
return None
info = ctx.ensure_object(ScriptInfo)
info.app_import_path = value
return value
# This option is eager so the app will be available if --help is given.
# --help is also eager, so --app must be before it in the param list.
# no_args_is_help bypasses eager processing, so this option must be
# processed manually in that case to ensure FLASK_APP gets picked up.
_app_option = click.Option(
["-A", "--app"],
metavar="IMPORT",
help=(
"The Flask application or factory function to load, in the form 'module:name'."
" Module can be a dotted import or file path. Name is not required if it is"
" 'app', 'application', 'create_app', or 'make_app', and can be 'name(args)' to"
" pass arguments."
),
is_eager=True,
expose_value=False,
callback=_set_app,
)
def _set_env(ctx: click.Context, param: click.Option, value: str | None) -> str | None:
if value is None:
return None
# Set with env var instead of ScriptInfo.load so that it can be
# accessed early during a factory function.
os.environ["FLASK_ENV"] = value
return value
_env_option = click.Option(
["-E", "--env"],
metavar="NAME",
help=(
"The execution environment name to set in 'app.env'. Defaults to"
" 'production'. 'development' will enable 'app.debug' and start the"
" debugger and reloader when running the server."
),
expose_value=False,
callback=_set_env,
)
def _set_debug(ctx: click.Context, param: click.Option, value: bool) -> bool | None:
# If the flag isn't provided, it will default to False. Don't use
# that, let debug be set by env in that case.
source = ctx.get_parameter_source(param.name) # type: ignore[arg-type]
if source is not None and source in (
ParameterSource.DEFAULT,
ParameterSource.DEFAULT_MAP,
):
return None
# Set with env var instead of ScriptInfo.load so that it can be
# accessed early during a factory function.
os.environ["FLASK_DEBUG"] = "1" if value else "0"
return value
_debug_option = click.Option(
["--debug/--no-debug"],
help="Set 'app.debug' separately from '--env'.",
expose_value=False,
callback=_set_debug,
)
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")
except ImportError:
raise click.BadParameter(
"python-dotenv must be installed to load an env file.",
ctx=ctx,
param=param,
) from None
# Don't check FLASK_SKIP_DOTENV, that only disables automatically
# loading .env and .flaskenv files.
load_dotenv(value)
return value
# This option is eager so env vars are loaded as early as possible to be
# used by other options.
_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.",
is_eager=True,
expose_value=False,
callback=_env_file_callback,
)
class FlaskGroup(AppGroup): class FlaskGroup(AppGroup):
"""Special subclass of the :class:`AppGroup` group that supports """Special subclass of the :class:`AppGroup` group that supports
loading more commands from the configured Flask app. Normally a loading more commands from the configured Flask app. Normally a
@ -460,6 +583,10 @@ class FlaskGroup(AppGroup):
:param set_debug_flag: Set the app's debug flag based on the active :param set_debug_flag: Set the app's debug flag based on the active
environment environment
.. versionchanged:: 2.2
Added the ``-A/--app``, ``-E/--env``, ``--debug/--no-debug``,
and ``-e/--env-file`` options.
.. versionchanged:: 1.0 .. versionchanged:: 1.0
If installed, python-dotenv will be used to load environment variables If installed, python-dotenv will be used to load environment variables
from :file:`.env` and :file:`.flaskenv` files. from :file:`.env` and :file:`.flaskenv` files.
@ -467,14 +594,19 @@ class FlaskGroup(AppGroup):
def __init__( def __init__(
self, self,
add_default_commands=True, add_default_commands: bool = True,
create_app=None, create_app: t.Callable[..., Flask] | None = None,
add_version_option=True, add_version_option: bool = True,
load_dotenv=True, load_dotenv: bool = True,
set_debug_flag=True, set_debug_flag: bool = True,
**extra, **extra: t.Any,
): ) -> None:
params = list(extra.pop("params", None) or ()) params = list(extra.pop("params", None) or ())
# Processing is done with option callbacks instead of a group
# callback. This allows users to make a custom group callback
# without losing the behavior. --env-file must come first so
# that it is eagerly evaluated before --app.
params.extend((_env_file_option, _app_option, _env_option, _debug_option))
if add_version_option: if add_version_option:
params.append(version_option) params.append(version_option)
@ -555,11 +687,13 @@ class FlaskGroup(AppGroup):
def make_context( def make_context(
self, self,
info_name: t.Optional[str], info_name: str | None,
args: t.List[str], args: list[str],
parent: t.Optional[click.Context] = None, parent: click.Context | None = None,
**extra: t.Any, **extra: t.Any,
) -> click.Context: ) -> click.Context:
# 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): if get_load_dotenv(self.load_dotenv):
load_dotenv() load_dotenv()
@ -570,6 +704,16 @@ class FlaskGroup(AppGroup):
return super().make_context(info_name, args, parent=parent, **extra) return super().make_context(info_name, args, parent=parent, **extra)
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
if not args and self.no_args_is_help:
# Attempt to load --env-file and --app early in case they
# were given as env vars. Otherwise no_args_is_help will not
# see commands from app.cli.
_env_file_option.handle_parse_result(ctx, {}, [])
_app_option.handle_parse_result(ctx, {}, [])
return super().parse_args(ctx, args)
def _path_is_ancestor(path, other): def _path_is_ancestor(path, other):
"""Take ``other`` and remove the length of ``path`` from it. Then join it """Take ``other`` and remove the length of ``path`` from it. Then join it
@ -578,7 +722,7 @@ def _path_is_ancestor(path, other):
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=None): def load_dotenv(path: str | os.PathLike | None = None) -> bool:
"""Load "dotenv" files in order of precedence to set environment variables. """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 If an env var is already set it is not overwritten, so earlier files in the
@ -591,13 +735,17 @@ def load_dotenv(path=None):
:param path: Load the file at this location instead of searching. :param path: Load the file at this location instead of searching.
:return: ``True`` if a file was loaded. :return: ``True`` if a file was loaded.
.. versionchanged:: 1.1.0 .. versionchanged:: 2.0
Returns ``False`` when python-dotenv is not installed, or when The current directory is not changed to the location of the
the given path isn't a file. loaded file.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
When loading the env files, set the default encoding to UTF-8. When loading the env files, set the default encoding to UTF-8.
.. versionchanged:: 1.1.0
Returns ``False`` when python-dotenv is not installed, or when
the given path isn't a file.
.. versionadded:: 1.0 .. versionadded:: 1.0
""" """
try: try:
@ -613,15 +761,15 @@ def load_dotenv(path=None):
return False return False
# if the given path specifies the actual file then return True, # Always return after attempting to load a given path, don't load
# else False # the default files.
if path is not None: if path is not None:
if os.path.isfile(path): if os.path.isfile(path):
return dotenv.load_dotenv(path, encoding="utf-8") return dotenv.load_dotenv(path, encoding="utf-8")
return False return False
new_dir = None loaded = False
for name in (".env", ".flaskenv"): for name in (".env", ".flaskenv"):
path = dotenv.find_dotenv(name, usecwd=True) path = dotenv.find_dotenv(name, usecwd=True)
@ -629,12 +777,10 @@ def load_dotenv(path=None):
if not path: if not path:
continue continue
if new_dir is None:
new_dir = os.path.dirname(path)
dotenv.load_dotenv(path, encoding="utf-8") dotenv.load_dotenv(path, encoding="utf-8")
loaded = True
return new_dir is not None # at least one file was located and loaded return loaded # True if at least one file was located and loaded.
def show_server_banner(env, debug, app_import_path, eager_loading): def show_server_banner(env, debug, app_import_path, eager_loading):
@ -837,9 +983,10 @@ def run_command(
This server is for development purposes only. It does not provide This server is for development purposes only. It does not provide
the stability, security, or performance of production WSGI servers. the stability, security, or performance of production WSGI servers.
The reloader and debugger are enabled by default if The reloader and debugger are enabled by default with the
FLASK_ENV=development or FLASK_DEBUG=1. '--env development' or '--debug' options.
""" """
app = DispatchingApp(info.load_app, use_eager_loading=eager_loading)
debug = get_debug_flag() debug = get_debug_flag()
if reload is None: if reload is None:
@ -849,7 +996,6 @@ def run_command(
debugger = debug debugger = debug
show_server_banner(get_env(), debug, info.app_import_path, eager_loading) show_server_banner(get_env(), debug, info.app_import_path, eager_loading)
app = DispatchingApp(info.load_app, use_eager_loading=eager_loading)
from werkzeug.serving import run_simple from werkzeug.serving import run_simple
@ -971,19 +1117,10 @@ cli = FlaskGroup(
help="""\ help="""\
A general utility script for Flask applications. A general utility script for Flask applications.
Provides commands from Flask, extensions, and the application. Loads the An application to load must be given with the '--app' option,
application defined in the FLASK_APP environment variable, or from a wsgi.py 'FLASK_APP' environment variable, or with a 'wsgi.py' or 'app.py' file
file. Setting the FLASK_ENV environment variable to 'development' will enable in the current directory.
debug mode. """,
\b
{prefix}{cmd} FLASK_APP=hello.py
{prefix}{cmd} FLASK_ENV=development
{prefix}flask run
""".format(
cmd="export" if os.name == "posix" else "set",
prefix="$ " if os.name == "posix" else "> ",
),
) )

View file

@ -48,9 +48,9 @@ def get_debug_flag() -> bool:
def get_load_dotenv(default: bool = True) -> bool: def get_load_dotenv(default: bool = True) -> bool:
"""Get whether the user has disabled loading dotenv files by setting """Get whether the user has disabled loading default dotenv files by
:envvar:`FLASK_SKIP_DOTENV`. The default is ``True``, load the setting :envvar:`FLASK_SKIP_DOTENV`. The default is ``True``, load
files. the files.
:param default: What to return if the env var isn't set. :param default: What to return if the env var isn't set.
""" """