airflow-api-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airflow_api_client/__init__.py +1 -0
- airflow_api_client/auth.py +164 -0
- airflow_api_client/commands/__init__.py +36 -0
- airflow_api_client/commands/create_payload.py +64 -0
- airflow_api_client/commands/get/__init__.py +6 -0
- airflow_api_client/commands/get/dag.py +23 -0
- airflow_api_client/commands/get/dag_run.py +27 -0
- airflow_api_client/commands/get/log.py +96 -0
- airflow_api_client/commands/get/main.py +72 -0
- airflow_api_client/commands/get/task_instance.py +32 -0
- airflow_api_client/commands/list/__init__.py +6 -0
- airflow_api_client/commands/list/base.py +63 -0
- airflow_api_client/commands/list/dag_runs.py +62 -0
- airflow_api_client/commands/list/dags.py +53 -0
- airflow_api_client/commands/list/main.py +39 -0
- airflow_api_client/commands/list/task_instances.py +56 -0
- airflow_api_client/commands/pause.py +35 -0
- airflow_api_client/commands/profile.py +216 -0
- airflow_api_client/commands/raw.py +99 -0
- airflow_api_client/commands/run.py +110 -0
- airflow_api_client/commands/unpause.py +33 -0
- airflow_api_client/commands/xcom.py +54 -0
- airflow_api_client/compat.py +66 -0
- airflow_api_client/filters.py +84 -0
- airflow_api_client/io.py +197 -0
- airflow_api_client/log.py +80 -0
- airflow_api_client/main.py +357 -0
- airflow_api_client/models/__init__.py +674 -0
- airflow_api_client/typedefs.py +10 -0
- airflow_api_client-0.1.0.dist-info/METADATA +509 -0
- airflow_api_client-0.1.0.dist-info/RECORD +35 -0
- airflow_api_client-0.1.0.dist-info/WHEEL +4 -0
- airflow_api_client-0.1.0.dist-info/entry_points.txt +5 -0
- airflow_api_client-0.1.0.dist-info/licenses/LICENSE +201 -0
- airflow_api_client-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""List DAGs command"""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.filters import filter_obj
|
|
5
|
+
from airflow_api_client.io import output
|
|
6
|
+
from airflow_api_client.models import DAGBag
|
|
7
|
+
from airflow_api_client.typedefs import App
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ListDagsCommand:
|
|
11
|
+
"""Helper class for listing DAGs"""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def add_subparser(cls, subparsers):
|
|
15
|
+
"""Add subparser for dags"""
|
|
16
|
+
_ = subparsers.add_parser("dags", help="List all DAGs")
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def list_dags(app: App, add_extra_attrs_fn) -> None:
|
|
20
|
+
"""List all DAGs in Airflow"""
|
|
21
|
+
|
|
22
|
+
# Use the lighter API call to get the list of DAGs unless extra details are needed
|
|
23
|
+
details = any(
|
|
24
|
+
[app.args.extra_attrs, app.args.filter, app.args.output_format in ["yaml", "json"]]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
all_dags = [
|
|
28
|
+
dag
|
|
29
|
+
for dag in DAGBag(app).get_all_dags(details=details)
|
|
30
|
+
if filter_obj(dag, app.args.filter)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
if app.args.output_format in ("json", "yaml"):
|
|
34
|
+
output({"dags": [d.to_dict() for d in all_dags]}, app.args)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
headers = ["DAG ID", "Display Name", "Is Paused", "Owners", "Tags", "Next Dag Run"]
|
|
38
|
+
data = []
|
|
39
|
+
for dag in all_dags:
|
|
40
|
+
attrs = [
|
|
41
|
+
dag.dag_id,
|
|
42
|
+
dag.dag_display_name,
|
|
43
|
+
"Yes" if dag.is_paused else "No",
|
|
44
|
+
", ".join(dag.owners),
|
|
45
|
+
", ".join([t.name for t in dag.tags]),
|
|
46
|
+
dag.next_dagrun_logical_date or "N/A",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
add_extra_attrs_fn(app, dag, headers, attrs)
|
|
50
|
+
|
|
51
|
+
data.append(attrs)
|
|
52
|
+
|
|
53
|
+
output(data, app.args, headers=headers)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Main list command with subcommand dispatch"""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.commands.list.base import ListCommandBase
|
|
5
|
+
from airflow_api_client.commands.list.dag_runs import ListDagRunsCommand
|
|
6
|
+
from airflow_api_client.commands.list.dags import ListDagsCommand
|
|
7
|
+
from airflow_api_client.commands.list.task_instances import ListTaskInstancesCommand
|
|
8
|
+
from airflow_api_client.log import logger
|
|
9
|
+
|
|
10
|
+
DESCRIPTION = """
|
|
11
|
+
Command to list various resources in Airflow, such as DAGs, DAG runs, and task instances.
|
|
12
|
+
Supports filtering and custom attribute selection for flexible querying.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ListCommand(ListCommandBase):
|
|
17
|
+
"""Command to list various resources in Airflow"""
|
|
18
|
+
|
|
19
|
+
name = "list"
|
|
20
|
+
help = DESCRIPTION
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def add_arguments(cls, parser) -> None:
|
|
24
|
+
cls.add_common_arguments(parser)
|
|
25
|
+
|
|
26
|
+
subparsers = parser.add_subparsers(dest="list_resource", required=True)
|
|
27
|
+
ListDagsCommand.add_subparser(subparsers)
|
|
28
|
+
ListDagRunsCommand.add_subparser(subparsers)
|
|
29
|
+
ListTaskInstancesCommand.add_subparser(subparsers)
|
|
30
|
+
|
|
31
|
+
def execute(self, **kwargs) -> None:
|
|
32
|
+
logger.debug("Executing ListCommand")
|
|
33
|
+
|
|
34
|
+
if self.app.args.list_resource == "dags":
|
|
35
|
+
ListDagsCommand.list_dags(self.app, self._add_extra_attrs)
|
|
36
|
+
elif self.app.args.list_resource == "dag-runs":
|
|
37
|
+
ListDagRunsCommand.list_dag_runs(self.app, self._add_extra_attrs)
|
|
38
|
+
elif self.app.args.list_resource == "task-instances":
|
|
39
|
+
ListTaskInstancesCommand.list_task_instances(self.app, self._add_extra_attrs)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""List task instances command"""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.filters import filter_obj
|
|
5
|
+
from airflow_api_client.io import output
|
|
6
|
+
from airflow_api_client.models import ResourceSpec
|
|
7
|
+
from airflow_api_client.typedefs import App
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ListTaskInstancesCommand:
|
|
11
|
+
"""Helper class for listing task instances"""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def add_subparser(cls, subparsers):
|
|
15
|
+
"""Add subparser for task-instances"""
|
|
16
|
+
tasks_parser = subparsers.add_parser("task-instances", help="List task instances")
|
|
17
|
+
tasks_parser.add_argument(
|
|
18
|
+
"spec",
|
|
19
|
+
metavar="DAG_ID/RUN_ID[/TASK_ID]",
|
|
20
|
+
help=(
|
|
21
|
+
"Retrieve information about the task instance(s) related to the specified DAG run."
|
|
22
|
+
" The format should be DAG_ID:DAG_RUN_ID Optionally you can append :TASK_ID."
|
|
23
|
+
" to retrieve a specific task instance."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def list_task_instances(app: App, add_extra_attrs_fn) -> None:
|
|
29
|
+
"""List task instances for a given DAG run"""
|
|
30
|
+
|
|
31
|
+
spec = ResourceSpec.from_string(app, app.args.spec)
|
|
32
|
+
|
|
33
|
+
task_instances = (
|
|
34
|
+
[spec.task_instance] if spec.task_instance is not None else spec.dag_run.task_instances
|
|
35
|
+
)
|
|
36
|
+
task_instances = [t for t in task_instances if filter_obj(t, app.args.filter)]
|
|
37
|
+
|
|
38
|
+
if app.args.output_format in ("json", "yaml"):
|
|
39
|
+
output({"task_instances": [t.to_dict() for t in task_instances]}, app.args)
|
|
40
|
+
else:
|
|
41
|
+
headers = ["Task ID", "State", "Duration", "Try Number", "Start Date", "End Date"]
|
|
42
|
+
data = []
|
|
43
|
+
for ti in task_instances:
|
|
44
|
+
attrs = [
|
|
45
|
+
ti.task_id,
|
|
46
|
+
ti.state.value,
|
|
47
|
+
ti.duration,
|
|
48
|
+
ti.try_number,
|
|
49
|
+
ti.start_date,
|
|
50
|
+
ti.end_date,
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
add_extra_attrs_fn(app, ti, headers, attrs)
|
|
54
|
+
|
|
55
|
+
data.append(attrs)
|
|
56
|
+
output(data, app.args, headers=headers)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Code related to the `pause` sub-command."""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.commands import Command
|
|
5
|
+
from airflow_api_client.io import output
|
|
6
|
+
from airflow_api_client.log import logger
|
|
7
|
+
from airflow_api_client.models import DAG
|
|
8
|
+
|
|
9
|
+
DESCRIPTION = """
|
|
10
|
+
Command to pause a DAG. This command allows you to pause a DAG by its ID. When a DAG is paused, it
|
|
11
|
+
will not be scheduled to run until it is resumed. This can be useful for temporarily stopping a DAG
|
|
12
|
+
from running without deleting it or changing its schedule.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PauseDagCommand(Command):
|
|
17
|
+
"""Command to pause a DAG."""
|
|
18
|
+
|
|
19
|
+
name = "pause"
|
|
20
|
+
help = DESCRIPTION
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def add_arguments(cls, parser) -> None:
|
|
24
|
+
parser.add_argument("dag_id", help="DAG ID to pause")
|
|
25
|
+
|
|
26
|
+
def execute(self, **kwargs) -> None:
|
|
27
|
+
"""Pause a DAG"""
|
|
28
|
+
logger.debug("Executing PauseDagCommand")
|
|
29
|
+
|
|
30
|
+
dag = DAG(self.app, self.app.args.dag_id)
|
|
31
|
+
response = dag.pause()
|
|
32
|
+
logger.info(f"DAG '{self.app.args.dag_id}' has been paused")
|
|
33
|
+
|
|
34
|
+
if self.app.args.output_format in ("json", "yaml"):
|
|
35
|
+
output(response.to_dict(), self.app.args)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Code related to the `profile` sub-command."""
|
|
3
|
+
|
|
4
|
+
import configparser
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from getpass import getpass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from airflow_api_client.commands import Command
|
|
12
|
+
from airflow_api_client.io import output
|
|
13
|
+
from airflow_api_client.log import logger
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import keyring
|
|
17
|
+
except ImportError:
|
|
18
|
+
keyring = None
|
|
19
|
+
|
|
20
|
+
DESCRIPTION = """
|
|
21
|
+
Manage configuration profiles. Profiles store connection details for Airflow instances
|
|
22
|
+
in the config file and optionally store passwords securely in the system keyring.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProfileCommand(Command):
|
|
27
|
+
"""Command to manage configuration profiles."""
|
|
28
|
+
|
|
29
|
+
name = "profile"
|
|
30
|
+
help = DESCRIPTION
|
|
31
|
+
no_client = True
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def add_arguments(cls, parser) -> None:
|
|
35
|
+
subparsers = parser.add_subparsers(dest="profile_action", required=True)
|
|
36
|
+
|
|
37
|
+
subparsers.add_parser("list", help="List all profiles")
|
|
38
|
+
|
|
39
|
+
delete_parser = subparsers.add_parser("delete", help="Delete an existing profile")
|
|
40
|
+
delete_parser.add_argument(
|
|
41
|
+
"profile_name",
|
|
42
|
+
help=("Name of the profile to delete."),
|
|
43
|
+
)
|
|
44
|
+
delete_parser.add_argument(
|
|
45
|
+
"--keep-keyring-password",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Don't delete password from system keyring",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
create_parser = subparsers.add_parser("create", help="Create a new minimal profile")
|
|
51
|
+
create_parser.add_argument(
|
|
52
|
+
"--profile-name",
|
|
53
|
+
help=("Name of the profile to create. (default: 'default')"),
|
|
54
|
+
default="default",
|
|
55
|
+
)
|
|
56
|
+
create_parser.add_argument("--endpoint", "-e", required=False, help="Airflow API endpoint")
|
|
57
|
+
create_parser.add_argument("--username", "-u", help="Airflow username")
|
|
58
|
+
create_parser.add_argument(
|
|
59
|
+
"--password",
|
|
60
|
+
"-p",
|
|
61
|
+
help=(
|
|
62
|
+
"Airflow password. If not provided, the password will be read from the"
|
|
63
|
+
" AIRFLOW_API_CLIENT_PASSWORD environment variable, or prompted for interactively."
|
|
64
|
+
" When keyring is available, the password is stored securely in the system keyring."
|
|
65
|
+
" Use --no-keyring-password to store in plaintext in the config file instead."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
create_parser.add_argument(
|
|
69
|
+
"--no-keyring-password",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help=(
|
|
72
|
+
"Don't store password in system keyring, even if available (not recommended)."
|
|
73
|
+
" Providing --no-keyring-password will store the password in plaintext in the"
|
|
74
|
+
" config file,"
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@cached_property
|
|
79
|
+
def _config_path(self) -> Path:
|
|
80
|
+
"""Determine the path to the config file based on environment variables and defaults"""
|
|
81
|
+
default_config_dir = os.getenv("XDG_CONFIG_HOME", str(Path.home() / ".config"))
|
|
82
|
+
return (
|
|
83
|
+
self.app.args.config_file
|
|
84
|
+
or Path(default_config_dir) / "airflow-api-client" / "config.ini"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def execute(self, **kwargs) -> None:
|
|
88
|
+
"""Execute profile subcommand"""
|
|
89
|
+
if self.app.args.profile_action == "list":
|
|
90
|
+
self._list()
|
|
91
|
+
elif self.app.args.profile_action == "create":
|
|
92
|
+
self._create()
|
|
93
|
+
elif self.app.args.profile_action == "delete":
|
|
94
|
+
self._delete()
|
|
95
|
+
|
|
96
|
+
def get_password(self, args, endpoint, username) -> str:
|
|
97
|
+
"""Get password from command line arguments or environment variable, or fall-back to
|
|
98
|
+
prompting the user"""
|
|
99
|
+
if args.password:
|
|
100
|
+
return args.password
|
|
101
|
+
|
|
102
|
+
env_password = os.getenv("AIRFLOW_API_CLIENT_PASSWORD")
|
|
103
|
+
if env_password:
|
|
104
|
+
return env_password
|
|
105
|
+
|
|
106
|
+
if sys.stdin.isatty():
|
|
107
|
+
return getpass(f"Enter Airflow password for {username} at {endpoint}: ")
|
|
108
|
+
|
|
109
|
+
return sys.stdin.readline().strip()
|
|
110
|
+
|
|
111
|
+
def _list(self) -> None:
|
|
112
|
+
"""List all profiles in the config file"""
|
|
113
|
+
if not self._config_path.is_file():
|
|
114
|
+
raise RuntimeError(f"No config file found at {self._config_path}")
|
|
115
|
+
|
|
116
|
+
config = configparser.RawConfigParser()
|
|
117
|
+
config.read(self._config_path)
|
|
118
|
+
|
|
119
|
+
sections = config.sections()
|
|
120
|
+
if not sections:
|
|
121
|
+
print("No profiles found.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
headers = {"profile": "Profile", "endpoint": "Endpoint", "username": "Username"}
|
|
125
|
+
data = []
|
|
126
|
+
|
|
127
|
+
for section in sections:
|
|
128
|
+
if keyring and not config.has_option(section, "password"):
|
|
129
|
+
password = "(stored in keyring)"
|
|
130
|
+
elif config.has_option(section, "password"):
|
|
131
|
+
password = "(stored in config)"
|
|
132
|
+
else:
|
|
133
|
+
password = "(not set)"
|
|
134
|
+
|
|
135
|
+
data.append(
|
|
136
|
+
{
|
|
137
|
+
"profile": section,
|
|
138
|
+
"endpoint": config.get(section, "endpoint", fallback="(not set)"),
|
|
139
|
+
"username": config.get(section, "username", fallback="(not set)"),
|
|
140
|
+
"verbose": config.get(section, "verbose", fallback="default"),
|
|
141
|
+
"password": password,
|
|
142
|
+
"auth_provider": config.get(section, "auth_provider", fallback="default"),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
output(data, self.app.args, headers=headers)
|
|
147
|
+
|
|
148
|
+
def _delete(self) -> None:
|
|
149
|
+
"""Delete an existing profile"""
|
|
150
|
+
if not self._config_path.is_file():
|
|
151
|
+
raise RuntimeError(f"No config file found at {self._config_path}")
|
|
152
|
+
|
|
153
|
+
config = configparser.RawConfigParser()
|
|
154
|
+
config.read(self._config_path)
|
|
155
|
+
profile = self.app.args.profile_name
|
|
156
|
+
|
|
157
|
+
if not config.has_section(profile):
|
|
158
|
+
raise RuntimeError(f"Profile '{profile}' not found in config file")
|
|
159
|
+
|
|
160
|
+
if keyring and not self.app.args.keep_keyring_password:
|
|
161
|
+
try:
|
|
162
|
+
keyring.delete_password(
|
|
163
|
+
f"airflow-api-client/{config.get(profile, 'endpoint')}",
|
|
164
|
+
config.get(profile, "username"),
|
|
165
|
+
)
|
|
166
|
+
logger.info("Password removed from system keyring")
|
|
167
|
+
except keyring.errors.PasswordDeleteError:
|
|
168
|
+
logger.debug("No keyring entry found for this profile")
|
|
169
|
+
|
|
170
|
+
config.remove_section(profile)
|
|
171
|
+
with self._config_path.open("w", encoding="utf-8") as f:
|
|
172
|
+
config.write(f)
|
|
173
|
+
|
|
174
|
+
logger.info(f"Profile '{profile}' deleted from {self._config_path}")
|
|
175
|
+
|
|
176
|
+
def _create(self) -> None:
|
|
177
|
+
"""Create a new profile"""
|
|
178
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
config = configparser.RawConfigParser()
|
|
181
|
+
if self._config_path.is_file():
|
|
182
|
+
config.read(self._config_path)
|
|
183
|
+
|
|
184
|
+
profile = self.app.args.profile_name
|
|
185
|
+
|
|
186
|
+
if config.has_section(profile):
|
|
187
|
+
logger.warning(f"Profile '{profile}' already exists, overwriting")
|
|
188
|
+
else:
|
|
189
|
+
config.add_section(profile)
|
|
190
|
+
|
|
191
|
+
if (not self.app.args.endpoint or not self.app.args.username) and not sys.stdin.isatty():
|
|
192
|
+
raise RuntimeError(
|
|
193
|
+
"Endpoint and username must be provided as arguments when running in"
|
|
194
|
+
" non-interactive mode. Use --endpoint and --username options or run the command in"
|
|
195
|
+
" an interactive terminal."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
endpoint = self.app.args.endpoint or input("Enter Airflow API endpoint: ")
|
|
199
|
+
username = self.app.args.username or input("Enter Airflow username: ")
|
|
200
|
+
|
|
201
|
+
endpoint = endpoint.strip().rstrip("/")
|
|
202
|
+
config.set(profile, "endpoint", endpoint)
|
|
203
|
+
config.set(profile, "username", username)
|
|
204
|
+
|
|
205
|
+
password = self.get_password(self.app.args, endpoint, username)
|
|
206
|
+
|
|
207
|
+
if keyring and not self.app.args.no_keyring_password:
|
|
208
|
+
keyring.set_password(f"airflow-api-client/{endpoint}", username, password)
|
|
209
|
+
logger.info("Password stored in system keyring")
|
|
210
|
+
else:
|
|
211
|
+
config.set(profile, "password", password)
|
|
212
|
+
|
|
213
|
+
with self._config_path.open("w", encoding="utf-8") as f:
|
|
214
|
+
config.write(f)
|
|
215
|
+
|
|
216
|
+
logger.info(f"Profile '{profile}' saved to {self._config_path}")
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Code related to the `raw` sub-command."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from airflow_api_client.commands import Command
|
|
7
|
+
from airflow_api_client.io import output
|
|
8
|
+
from airflow_api_client.log import logger
|
|
9
|
+
|
|
10
|
+
DESCRIPTION = """
|
|
11
|
+
Make a raw API request to Airflow. This command allows you to interact with the Airflow API
|
|
12
|
+
directly, bypassing any predefined commands or logic. You can specify the endpoint, method, and
|
|
13
|
+
payload for your request, giving you full control over the API interaction.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RawCommand(Command):
|
|
18
|
+
"""Command to make a raw API request."""
|
|
19
|
+
|
|
20
|
+
name = "raw"
|
|
21
|
+
help = DESCRIPTION
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def add_arguments(cls, parser) -> None:
|
|
25
|
+
"""Add arguments for the `raw` command."""
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"method",
|
|
28
|
+
type=str.upper,
|
|
29
|
+
choices=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
30
|
+
help="The HTTP method to use for the request",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"stub_url",
|
|
34
|
+
type=str,
|
|
35
|
+
help="The API endpoint to call (e.g., /dags/{dag_id}/pause)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--payload",
|
|
39
|
+
type=str,
|
|
40
|
+
default=None,
|
|
41
|
+
help="The JSON payload to send with the request (for POST and PUT methods)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--header",
|
|
45
|
+
action="append",
|
|
46
|
+
help=(
|
|
47
|
+
"Custom headers to include in the request (can be specified multiple times, e.g.,"
|
|
48
|
+
" --header 'Foo=Bar' --header 'Baz=Qux')"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def execute(self, **kwargs) -> None:
|
|
53
|
+
"""Run a raw API request to Airflow based on the provided arguments."""
|
|
54
|
+
|
|
55
|
+
logger.debug("Executing RawCommand")
|
|
56
|
+
|
|
57
|
+
endpoint = f"{self.app.args.endpoint}/{self.app.args.stub_url.lstrip('/')}"
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"Authorization": f"Bearer {self.app.client.auth.client_token.token}",
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
"Accept": "application/json",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
headers.update(dict([h.split("=", maxsplit=1) for h in self.app.args.header or []]))
|
|
66
|
+
try:
|
|
67
|
+
payload = json.loads(self.app.args.payload) if self.app.args.payload else None
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
logger.error(f"Invalid JSON payload: {e}")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
logger.debug(
|
|
73
|
+
f"Making {self.app.args.method} request to {endpoint}",
|
|
74
|
+
headers=headers,
|
|
75
|
+
payload=payload,
|
|
76
|
+
timeout=self.app.args.timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
response = self.app.client.rest_client.request(
|
|
80
|
+
self.app.args.method,
|
|
81
|
+
endpoint,
|
|
82
|
+
body=payload,
|
|
83
|
+
headers=headers,
|
|
84
|
+
_request_timeout=self.app.args.timeout,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if self.app.args.output_format not in ["json", "yaml"]:
|
|
88
|
+
# There's no pretty-printing arbitrary output for this command, so we default to JSON if
|
|
89
|
+
# the user specified an unsupported format
|
|
90
|
+
self.app.args.output_format = "json"
|
|
91
|
+
|
|
92
|
+
logger.info(f"Received response from Airflow API. Status code: {response.status}")
|
|
93
|
+
response_b = response.response.read()
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
output(json.loads(response_b), self.app.args)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
logger.debug("Response is not valid JSON, printing raw response")
|
|
99
|
+
output(response_b, self.app.args)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Code related to the `run` sub-command."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from airflow_api_client.commands import Command
|
|
9
|
+
from airflow_api_client.io import output
|
|
10
|
+
from airflow_api_client.log import logger
|
|
11
|
+
from airflow_api_client.models import DAG, TERMINAL_DAGRUN_STATES, DAGRun
|
|
12
|
+
|
|
13
|
+
DESCRIPTION = """
|
|
14
|
+
Command to trigger a DAG run with an optional payload. The response is printed to the console
|
|
15
|
+
in the specified output format (JSON, YAML, or human-readable). If a payload is provided, it is
|
|
16
|
+
used as the `conf` for the DAG run. If no payload is provided, the user is prompted to enter
|
|
17
|
+
values for each parameter defined in the DAG's parameters. After triggering the DAG, the command
|
|
18
|
+
polls for completion and retrieves any XCom entries associated with the DAG run.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunDagCommand(Command):
|
|
23
|
+
"""Command to trigger a DAG run with an optional payload."""
|
|
24
|
+
|
|
25
|
+
name = "run"
|
|
26
|
+
help = DESCRIPTION
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def add_arguments(cls, parser) -> None:
|
|
30
|
+
parser.add_argument("dag_id", help="DAG ID to run")
|
|
31
|
+
parser.add_argument("--conf", "-c", type=str, help="JSON conf or @file")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--run-id",
|
|
34
|
+
type=str,
|
|
35
|
+
help=(
|
|
36
|
+
"Optional run_id for the DAG run (default: autogenerated). If"
|
|
37
|
+
" provided, it must be unique across all DAG runs. "
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--no-wait",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Don't wait for DAG run to complete. Trigger and forget",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def execute(self, **kwargs) -> None:
|
|
47
|
+
"""Triggers a DAG with an optional payload. The response is printed to the console in the
|
|
48
|
+
specified output format (JSON, YAML, or human-readable). If a payload is provided, it is
|
|
49
|
+
used as the `conf` for the DAG run. If no payload is provided, the user is prompted to
|
|
50
|
+
enter values for each parameter defined in the DAG's parameters. After triggering the DAG,
|
|
51
|
+
the function polls for completion and retrieves any XCom entries associated with the
|
|
52
|
+
DAG run.
|
|
53
|
+
"""
|
|
54
|
+
logger.debug("Executing RunDagCommand")
|
|
55
|
+
|
|
56
|
+
conf = self.app.args.conf
|
|
57
|
+
|
|
58
|
+
dag = DAG(self.app, self.app.args.dag_id)
|
|
59
|
+
|
|
60
|
+
if conf is None:
|
|
61
|
+
conf = {"conf": dag.create_payload()}
|
|
62
|
+
else:
|
|
63
|
+
if conf.startswith("@"):
|
|
64
|
+
conf_path = Path(conf[1:]).absolute()
|
|
65
|
+
if not conf_path.is_file():
|
|
66
|
+
logger.error("Conf file not found", path=str(conf_path))
|
|
67
|
+
raise FileNotFoundError(f"Conf file not found: {conf_path}")
|
|
68
|
+
with conf_path.open("r", encoding="utf-8") as f:
|
|
69
|
+
conf = f.read()
|
|
70
|
+
conf = {"conf": json.loads(conf)}
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
conf = json.loads(conf) if isinstance(conf, str) else conf
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
logger.error("Invalid JSON provided for --conf", error=str(e))
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
dag_run: DAGRun = dag.run(conf=conf, run_id=self.app.args.run_id)
|
|
79
|
+
|
|
80
|
+
if self.app.args.no_wait:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
# Poll for completion. Prefer this over calling airflow_client's wait_dag_run_until_finished
|
|
84
|
+
# until that's no longer marked as experimental.
|
|
85
|
+
while (current_state := dag_run.state) not in TERMINAL_DAGRUN_STATES:
|
|
86
|
+
logger.debug("Polling for DAG run completion", dag=dag, state=current_state)
|
|
87
|
+
time.sleep(2)
|
|
88
|
+
|
|
89
|
+
logger.info("DAG run completed", dag=dag, final_state=dag_run.state)
|
|
90
|
+
|
|
91
|
+
if self.app.args.output_format in ("json", "yaml"):
|
|
92
|
+
result = dag_run.to_dict()
|
|
93
|
+
result["task_instances"] = {}
|
|
94
|
+
for task_instance in dag_run.task_instances:
|
|
95
|
+
result["task_instances"][task_instance.task_id] = {
|
|
96
|
+
"state": task_instance.state,
|
|
97
|
+
"logs": list(task_instance.get_log()),
|
|
98
|
+
"xcom": task_instance.get_xcom_entries().to_dict(),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
output(result, self.app.args)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
output([["Logs"]], self.app.args, headers=[])
|
|
105
|
+
for task_instance in dag_run.task_instances:
|
|
106
|
+
task_instance.get_log().output()
|
|
107
|
+
|
|
108
|
+
output("\n", self.app.args)
|
|
109
|
+
output([["XCom results"]], self.app.args, headers=[])
|
|
110
|
+
dag_run.xcoms.output()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Code related to the `unpause` sub-command."""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.commands import Command
|
|
5
|
+
from airflow_api_client.io import output
|
|
6
|
+
from airflow_api_client.log import logger
|
|
7
|
+
from airflow_api_client.models import DAG
|
|
8
|
+
|
|
9
|
+
DESCRIPTION = """
|
|
10
|
+
Command to unpause a DAG. This command allows you to unpause a DAG by its ID.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnpauseDagCommand(Command):
|
|
15
|
+
"""Command to unpause a DAG."""
|
|
16
|
+
|
|
17
|
+
name = "unpause"
|
|
18
|
+
help = DESCRIPTION
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def add_arguments(cls, parser) -> None:
|
|
22
|
+
parser.add_argument("dag_id", help="DAG ID to unpause")
|
|
23
|
+
|
|
24
|
+
def execute(self, **kwargs) -> None:
|
|
25
|
+
"""Unpause a DAG"""
|
|
26
|
+
logger.debug("Executing UnpauseDagCommand")
|
|
27
|
+
|
|
28
|
+
dag = DAG(self.app, self.app.args.dag_id)
|
|
29
|
+
response = dag.unpause()
|
|
30
|
+
logger.info(f"DAG '{self.app.args.dag_id}' has been unpaused.")
|
|
31
|
+
|
|
32
|
+
if self.app.args.output_format in ("json", "yaml"):
|
|
33
|
+
output(response.to_dict(), self.app.args)
|