Skip to content

Commit bfbbdcf

Browse files
committed
Add task instance management commands to airflowctl CLI
1 parent 621192d commit bfbbdcf

11 files changed

Lines changed: 535 additions & 74 deletions

File tree

airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ def date_param():
9696
"dags update example_bash_operator --no-is-paused",
9797
# Dag Run commands
9898
"dagrun list --dag-id example_bash_operator --state success --limit=1",
99+
# Task instance commands
100+
'tasks list example_bash_operator "manual__{date_param}"',
101+
'tasks get example_bash_operator "manual__{date_param}" runme_0',
102+
"tasks clear example_bash_operator --dry-run",
103+
'tasks update example_bash_operator "manual__{date_param}" runme_0 --new-state=failed',
99104
# XCom commands - need a Dag run with completed tasks
100105
'xcom add example_bash_operator "manual__{date_param}" runme_0 {xcom_key} \'{{"test": "value"}}\'',
101106
'xcom get example_bash_operator "manual__{date_param}" runme_0 {xcom_key}',

airflow-ctl/docs/images/command_hashes.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
main:27a22c00dcf32e7a1a4f06672dc8e3c8
1+
main:164bc97843d5be583c0b48f7a34dc8c8
22
assets:6419e20452692f577c4c6f570b74be0c
33
auth:d79e9c7d00c432bdbcbc2a86e2e32053
44
backfill:74c8737b0a62a86ed3605fa9e6165874
@@ -12,4 +12,5 @@ providers:34502fe09dc0b8b0a13e7e46efdffda6
1212
variables:f8fc76d3d398b2780f4e97f7cd816646
1313
version:31f4efdf8de0dbaaa4fac71ff7efecc3
1414
plugins:4864fd8f356704bd2b3cd1aec3567e35
15+
tasks:7ab24cac521242b6b6012e2bcd317831
1516
auth login:9fe2bb1dd5c602beea2eefb33a2b20a8

airflow-ctl/docs/images/output_main.svg

Lines changed: 69 additions & 65 deletions
Loading
Lines changed: 117 additions & 0 deletions
Loading

airflow-ctl/src/airflowctl/api/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
PoolsOperations,
5858
ProvidersOperations,
5959
ServerResponseError,
60+
TasksOperations,
6061
VariablesOperations,
6162
VersionOperations,
6263
XComOperations,
@@ -474,6 +475,12 @@ def plugins(self):
474475
"""Operations related to plugins."""
475476
return PluginsOperations(self)
476477

478+
@lru_cache() # type: ignore[prop-decorator]
479+
@property
480+
def tasks(self):
481+
"""Operations related to tasks."""
482+
return TasksOperations(self)
483+
477484

478485
# API Client Decorator for CLI Actions
479486
@contextlib.contextmanager

airflow-ctl/src/airflowctl/api/operations.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
BulkBodyPoolBody,
4040
BulkBodyVariableBody,
4141
BulkResponse,
42+
ClearTaskInstancesBody,
4243
Config,
4344
ConnectionBody,
4445
ConnectionCollectionResponse,
@@ -59,6 +60,7 @@
5960
ImportErrorCollectionResponse,
6061
ImportErrorResponse,
6162
JobCollectionResponse,
63+
PatchTaskInstanceBody,
6264
PluginCollectionResponse,
6365
PluginImportErrorCollectionResponse,
6466
PoolBody,
@@ -68,6 +70,8 @@
6870
ProviderCollectionResponse,
6971
QueuedEventCollectionResponse,
7072
QueuedEventResponse,
73+
TaskInstanceCollectionResponse,
74+
TaskInstanceResponse,
7175
TriggerDAGRunPostBody,
7276
VariableBody,
7377
VariableCollectionResponse,
@@ -950,3 +954,45 @@ def list_import_errors(self) -> PluginImportErrorCollectionResponse | ServerResp
950954
return PluginImportErrorCollectionResponse.model_validate_json(self.response.content)
951955
except ServerResponseError as e:
952956
raise e
957+
958+
959+
class TasksOperations(BaseOperations):
960+
"""Tasks operations."""
961+
962+
def get(self, dag_id: str, dag_run_id: str, task_id: str) -> TaskInstanceResponse | ServerResponseError:
963+
"""Get a task instance."""
964+
self.response = self.client.get(f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}")
965+
return TaskInstanceResponse.model_validate_json(self.response.content)
966+
967+
def list(self, dag_id: str, dag_run_id: str) -> TaskInstanceCollectionResponse | ServerResponseError:
968+
"""List task instances."""
969+
return super().execute_list(
970+
path=f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances",
971+
data_model=TaskInstanceCollectionResponse,
972+
)
973+
974+
def clear(
975+
self, dag_id: str, body: ClearTaskInstancesBody
976+
) -> TaskInstanceCollectionResponse | ServerResponseError:
977+
"""Clear task instances."""
978+
self.response = self.client.post(
979+
f"dags/{dag_id}/clearTaskInstances",
980+
json=body.model_dump(mode="json", exclude_unset=True),
981+
)
982+
return TaskInstanceCollectionResponse.model_validate_json(self.response.content)
983+
984+
def update(
985+
self,
986+
dag_id: str,
987+
dag_run_id: str,
988+
task_id: str,
989+
body: PatchTaskInstanceBody,
990+
map_index: int | None = None,
991+
) -> TaskInstanceCollectionResponse | ServerResponseError:
992+
"""Update task instance state. When map_index is given, only that mapped instance is affected."""
993+
if map_index is not None:
994+
path = f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}/{map_index}"
995+
else:
996+
path = f"dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}"
997+
self.response = self.client.patch(path, json=body.model_dump(mode="json", exclude_unset=True))
998+
return TaskInstanceCollectionResponse.model_validate_json(self.response.content)

airflow-ctl/src/airflowctl/ctl/cli_config.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import inspect
2727
import os
2828
import sys
29+
import types as builtin_types
30+
import typing
2931
from argparse import Namespace
3032
from collections.abc import Callable, Iterable
3133
from enum import Enum
@@ -52,6 +54,22 @@
5254
BUILD_DOCS = "BUILDING_AIRFLOW_DOCS" in os.environ
5355

5456

57+
def _is_list_annotation(annotation: Any) -> bool:
58+
"""Check whether a Pydantic field annotation is a list type (including Optional[list[...]])."""
59+
origin = typing.get_origin(annotation)
60+
if origin is list:
61+
return True
62+
# Handle both typing.Union (Optional[list[...]]) and PEP-604 X | Y (types.UnionType)
63+
if origin is typing.Union or isinstance(annotation, builtin_types.UnionType):
64+
return any(_is_list_annotation(arg) for arg in typing.get_args(annotation) if arg is not type(None))
65+
return False
66+
67+
68+
def _get_bool_flag_default(field_info: Any) -> bool:
69+
"""Return the CLI default for a generated bool flag: the datamodel field's default (the API default), or False."""
70+
return field_info.default if isinstance(field_info.default, bool) else False
71+
72+
5573
def lazy_load_command(import_path: str) -> Callable:
5674
"""Create a lazy loader for command."""
5775
_, _, name = import_path.rpartition(".")
@@ -399,7 +417,17 @@ def __init__(self, file_path: str | Path | None = None):
399417
# Exclude parameters that are not needed for CLI from datamodels
400418
self.excluded_parameters = ["schema_"]
401419
# This list is used to determine if the command/operation needs to output data
402-
self.output_command_list = ["list", "get", "create", "delete", "update", "trigger", "add", "edit"]
420+
self.output_command_list = [
421+
"list",
422+
"get",
423+
"create",
424+
"delete",
425+
"update",
426+
"trigger",
427+
"add",
428+
"edit",
429+
"clear",
430+
]
403431
self.exclude_operation_names = ["LoginOperations", "VersionOperations", "BaseOperations"]
404432
self.exclude_method_names = [
405433
"error",
@@ -411,6 +439,8 @@ def __init__(self, file_path: str | Path | None = None):
411439
]
412440
self.excluded_output_keys = [
413441
"total_entries",
442+
"next_cursor",
443+
"previous_cursor",
414444
]
415445

416446
def _inspect_operations(self) -> None:
@@ -587,7 +617,9 @@ def _create_arg_for_non_primitive_type(
587617
arg_type=self._python_type_from_string(field_type.annotation),
588618
arg_action=argparse.BooleanOptionalAction if field_type.annotation is bool else None, # type: ignore
589619
arg_help=f"{field} for {parameter_key} operation",
590-
arg_default=False if field_type.annotation is bool else None,
620+
arg_default=_get_bool_flag_default(field_type)
621+
if field_type.annotation is bool
622+
else None,
591623
)
592624
)
593625
else:
@@ -602,7 +634,7 @@ def _create_arg_for_non_primitive_type(
602634
arg_type=self._python_type_from_string(annotation),
603635
arg_action=argparse.BooleanOptionalAction if annotation is bool else None, # type: ignore
604636
arg_help=f"{field} for {parameter_key} operation",
605-
arg_default=False if annotation is bool else None,
637+
arg_default=_get_bool_flag_default(field_type) if annotation is bool else None,
606638
)
607639
)
608640
return commands
@@ -717,10 +749,19 @@ def _get_func(args: Namespace, api_operation: dict, api_client: Client = NEW_API
717749
datamodel_param_name = parameter_key
718750
if expanded_parameter in self.excluded_parameters:
719751
continue
720-
if expanded_parameter in args_dict.keys():
752+
if (
753+
expanded_parameter in args_dict.keys()
754+
and args_dict[expanded_parameter] is not None
755+
):
756+
val = args_dict[expanded_parameter]
757+
if isinstance(val, str) and expanded_parameter in datamodel.model_fields:
758+
if _is_list_annotation(
759+
datamodel.model_fields[expanded_parameter].annotation
760+
):
761+
val = [v.strip() for v in val.split(",") if v.strip()]
721762
method_params[parameter_key][
722763
self._sanitize_method_param_key(expanded_parameter)
723-
] = args_dict[expanded_parameter]
764+
] = val
724765

725766
if datamodel:
726767
if datamodel_param_name:

airflow-ctl/src/airflowctl/ctl/help_texts.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,9 @@ xcom:
102102
plugins:
103103
list: "List all installed Airflow plugins"
104104
list-import-errors: "List all plugin import errors"
105+
106+
tasks:
107+
get: "Retrieve a task instance by Dag ID, run ID, and task ID"
108+
list: "List all task instances for a given Dag run"
109+
clear: "Clear task instance state, optionally filtering by task IDs or run scope"
110+
update: "Update task instance state or note; use --map-index to target a single mapped instance"

0 commit comments

Comments
 (0)