This is an automated email from the ASF dual-hosted git repository.
kaxilnaik pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new afae2972c1 Lay the groundwork for migrating Airflow CLI to Rich+Click
(#24590)
afae2972c1 is described below
commit afae2972c1e4c7beca2f1a20eff06bfd617f0c9d
Author: blag <[email protected]>
AuthorDate: Thu Jun 23 09:28:17 2022 -0700
Lay the groundwork for migrating Airflow CLI to Rich+Click (#24590)
This is the first PR of what will be a series of PRs breaking up #22613
into smaller, more reviewable chunks. The end result will be rewriting the
existing `airflow` CLI to use Click instead of argparse. For motivation, please
see #22708.
This PR installs Click, adds constraints to Rich_Click so we can rely on
some nice features in recent versions of that, adds a new barebones
`airflow-ng` console script, and tweaks some CLI internals to be more flexible
between argparse and Click.
To see how this initial groundwork will be used by future PRs, see #22613,
and to see how some of this will be used please see #24591.
---
airflow/cli/__init__.py | 105 +++++++++++++++++++++++++++++++
airflow/cli/{__init__.py => __main__.py} | 6 ++
airflow/utils/cli.py | 71 +++++++++++++--------
setup.cfg | 2 +
tests/utils/test_cli_util.py | 4 +-
5 files changed, 161 insertions(+), 27 deletions(-)
diff --git a/airflow/cli/__init__.py b/airflow/cli/__init__.py
index 217e5db960..9a913a3415 100644
--- a/airflow/cli/__init__.py
+++ b/airflow/cli/__init__.py
@@ -15,3 +15,108 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+import os
+
+import rich_click as click
+
+from airflow import settings
+from airflow.utils.cli import ColorMode
+from airflow.utils.timezone import parse as parsedate
+
+BUILD_DOCS = "BUILDING_AIRFLOW_DOCS" in os.environ
+
+click_color = click.option(
+ '--color',
+ type=click.Choice([ColorMode.ON, ColorMode.OFF, ColorMode.AUTO]),
+ default=ColorMode.AUTO,
+ help="Do emit colored output (default: auto)",
+)
+click_conf = click.option(
+ '-c', '--conf', help="JSON string that gets pickled into the DagRun's conf
attribute"
+)
+click_daemon = click.option(
+ "-D", "--daemon", 'daemon_', is_flag=True, help="Daemonize instead of
running in the foreground"
+)
+click_dag_id = click.argument("dag_id", help="The id of the dag")
+click_dag_id_opt = click.option("-d", "--dag-id", help="The id of the dag")
+click_debug = click.option(
+ "-d", "--debug", is_flag=True, help="Use the server that ships with Flask
in debug mode"
+)
+click_dry_run = click.option(
+ '-n',
+ '--dry-run',
+ is_flag=True,
+ default=False,
+ help="Perform a dry run for each task. Only renders Template Fields for
each task, nothing else",
+)
+click_end_date = click.option(
+ "-e",
+ "--end-date",
+ type=parsedate,
+ help="Override end_date YYYY-MM-DD",
+)
+click_execution_date = click.argument("execution_date", help="The execution
date of the DAG", type=parsedate)
+click_execution_date_or_run_id = click.argument(
+ "execution_date_or_run_id", help="The execution_date of the DAG or run_id
of the DAGRun"
+)
+click_log_file = click.option(
+ "-l",
+ "--log-file",
+ metavar="LOG_FILE",
+ type=click.Path(exists=False, dir_okay=False, writable=True),
+ help="Location of the log file",
+)
+click_output = click.option(
+ "-o",
+ "--output",
+ type=click.Choice(["table", "json", "yaml", "plain"]),
+ default="table",
+ help="Output format.",
+)
+click_pid = click.option("--pid", metavar="PID",
type=click.Path(exists=False), help="PID file location")
+click_start_date = click.option(
+ "-s",
+ "--start-date",
+ type=parsedate,
+ help="Override start_date YYYY-MM-DD",
+)
+click_stderr = click.option(
+ "--stderr",
+ metavar="STDERR",
+ type=click.Path(exists=False, dir_okay=False, writable=True),
+ help="Redirect stderr to this file",
+)
+click_stdout = click.option(
+ "--stdout",
+ metavar="STDOUT",
+ type=click.Path(exists=False, dir_okay=False, writable=True),
+ help="Redirect stdout to this file",
+)
+click_subdir = click.option(
+ "-S",
+ "--subdir",
+ default='[AIRFLOW_HOME]/dags' if BUILD_DOCS else settings.DAGS_FOLDER,
+ type=click.Path(),
+ help=(
+ "File location or directory from which to look for the dag. "
+ "Defaults to '[AIRFLOW_HOME]/dags' where [AIRFLOW_HOME] is the "
+ "value you set for 'AIRFLOW_HOME' config you set in 'airflow.cfg' "
+ ),
+)
+click_task_id = click.argument("task_id", help="The id of the task")
+click_task_regex = click.option(
+ "-t", "--task-regex", help="The regex to filter specific task_ids to
backfill (optional)"
+)
+click_verbose = click.option(
+ '-v', '--verbose', is_flag=True, default=False, help="Make logging output
more verbose"
+)
+click_yes = click.option(
+ '-y', '--yes', is_flag=True, default=False, help="Do not prompt to
confirm. Use with care!"
+)
+
+
+#
https://click.palletsprojects.com/en/8.1.x/documentation/#help-parameter-customization
[email protected](context_settings={'help_option_names': ['-h', '--help']})
[email protected]_context
+def airflow_cmd(ctx):
+ pass
diff --git a/airflow/cli/__init__.py b/airflow/cli/__main__.py
similarity index 87%
copy from airflow/cli/__init__.py
copy to airflow/cli/__main__.py
index 217e5db960..2ae6c91cb3 100644
--- a/airflow/cli/__init__.py
+++ b/airflow/cli/__main__.py
@@ -1,3 +1,4 @@
+#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
@@ -15,3 +16,8 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
+from airflow.cli import airflow_cmd
+
+if __name__ == '__main__':
+ airflow_cmd(obj={})
diff --git a/airflow/utils/cli.py b/airflow/utils/cli.py
index d3322ac13f..8b04e49d91 100644
--- a/airflow/utils/cli.py
+++ b/airflow/utils/cli.py
@@ -46,14 +46,10 @@ if TYPE_CHECKING:
def _check_cli_args(args):
if not args:
- raise ValueError("Args should be set")
- if not isinstance(args[0], Namespace):
- raise ValueError(
- f"1st positional argument should be argparse.Namespace instance,
but is {type(args[0])}"
- )
+ raise ValueError(f"Args should be set: {args} [{type(args)}]")
-def action_cli(func=None, check_db=True):
+def action_cli(func=None, check_db=True, check_cli_args=True):
def action_logging(f: T) -> T:
"""
Decorates function to execute function at the same time submitting
action_logging
@@ -79,15 +75,14 @@ def action_cli(func=None, check_db=True):
@functools.wraps(f)
def wrapper(*args, **kwargs):
"""
- An wrapper for cli functions. It assumes to have Namespace instance
- at 1st positional argument
+ An wrapper for cli functions.
- :param args: Positional argument. It assumes to have Namespace
instance
- at 1st positional argument
+ :param args: Positional argument.
:param kwargs: A passthrough keyword argument
"""
- _check_cli_args(args)
- metrics = _build_metrics(f.__name__, args[0])
+ if check_cli_args:
+ _check_cli_args(args)
+ metrics = _build_metrics(f.__name__, args, kwargs)
cli_action_loggers.on_pre_execution(**metrics)
try:
# Check and run migrations if necessary
@@ -111,15 +106,16 @@ def action_cli(func=None, check_db=True):
return action_logging
-def _build_metrics(func_name, namespace):
+def _build_metrics(func_name, args, kwargs):
"""
Builds metrics dict from function args
- It assumes that function arguments is from airflow.bin.cli module's
function
- and has Namespace instance where it optionally contains "dag_id",
"task_id",
- and "execution_date".
+ If the first item in args is a Namespace instance, it assumes that it
+ optionally contains "dag_id", "task_id", and "execution_date".
:param func_name: name of function
- :param namespace: Namespace instance from argparse
+ :param args: Arguments from wrapped function, possibly including the
Namespace instance from
+ argparse as the first argument
+ :param kwargs: Keyword arguments from wrapped function
:return: dict with metrics
"""
from airflow.models import Log
@@ -146,11 +142,7 @@ def _build_metrics(func_name, namespace):
'user': getuser(),
}
- if not isinstance(namespace, Namespace):
- raise ValueError(
- f"namespace argument should be argparse.Namespace instance, but is
{type(namespace)}"
- )
- tmp_dic = vars(namespace)
+ tmp_dic = vars(args[0]) if (args and isinstance(args[0], Namespace)) else
kwargs
metrics['dag_id'] = tmp_dic.get('dag_id')
metrics['task_id'] = tmp_dic.get('task_id')
metrics['execution_date'] = tmp_dic.get('execution_date')
@@ -306,11 +298,13 @@ class ColorMode:
AUTO = "auto"
-def should_use_colors(args) -> bool:
+def should_use_colors(args_or_color):
"""Processes arguments and decides whether to enable color in output"""
- if args.color == ColorMode.ON:
+ # args.color is from argparse, Click CLI will pass in the color directly
+ color = args_or_color.color if hasattr(args_or_color, 'color') else
args_or_color
+ if color == ColorMode.ON:
return True
- if args.color == ColorMode.OFF:
+ if color == ColorMode.OFF:
return False
return is_terminal_support_colors()
@@ -338,3 +332,30 @@ def suppress_logs_and_warning(f: T) -> T:
logging.disable(logging.NOTSET)
return cast(T, _wrapper)
+
+
+def suppress_logs_and_warning_click_compatible(f: T) -> T:
+ """
+ Click compatible version of suppress_logs_and_warning.
+ Place after click_verbose decorator.
+
+ Decorator to suppress logging and warning messages
+ in cli functions.
+ """
+
+ @functools.wraps(f)
+ def _wrapper(*args, **kwargs):
+ if kwargs.get("verbose"):
+ f(*args, **kwargs)
+ else:
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ logging.disable(logging.CRITICAL)
+ try:
+ f(*args, **kwargs)
+ finally:
+ # logging output again depends on the effective
+ # levels of individual loggers
+ logging.disable(logging.NOTSET)
+
+ return cast(T, _wrapper)
diff --git a/setup.cfg b/setup.cfg
index a32326cee2..175d49ceb8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -140,6 +140,7 @@ install_requires =
python-nvd3>=0.15.0
python-slugify>=5.0
rich>=12.4.4
+ rich-click>=1.3.1
setproctitle>=1.1.8
# SQL Alchemy 1.4.10 introduces a bug where for PyODBC driver UTCDateTime
fields get wrongly converted
# as string and fail to be converted back to datetime. It was supposed to
be fixed in
@@ -174,6 +175,7 @@ airflow.utils=
[options.entry_points]
console_scripts=
airflow=airflow.__main__:main
+ airflow-ng=airflow.cli.__main__:airflow_cmd
[bdist_wheel]
python-tag=py3
diff --git a/tests/utils/test_cli_util.py b/tests/utils/test_cli_util.py
index d69e651a20..da0e47a495 100644
--- a/tests/utils/test_cli_util.py
+++ b/tests/utils/test_cli_util.py
@@ -38,7 +38,7 @@ class TestCliUtil(unittest.TestCase):
func_name = 'test'
exec_date = datetime.utcnow()
namespace = Namespace(dag_id='foo', task_id='bar', subcommand='test',
execution_date=exec_date)
- metrics = cli._build_metrics(func_name, namespace)
+ metrics = cli._build_metrics(func_name, [namespace], {})
expected = {
'user': os.environ.get('USER'),
@@ -132,7 +132,7 @@ class TestCliUtil(unittest.TestCase):
exec_date = datetime.utcnow()
namespace = Namespace(dag_id='foo', task_id='bar', subcommand='test',
execution_date=exec_date)
with mock.patch.object(sys, "argv", args):
- metrics = cli._build_metrics(args[1], namespace)
+ metrics = cli._build_metrics(args[1], [namespace], {})
assert metrics.get('start_datetime') <= datetime.utcnow()