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 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Authentication provider for Airflow API client that supports JWT tokens.
|
|
3
|
+
AirflowAPIClientAuthProvider is the default authentication provider for Airflow API client. Custom
|
|
4
|
+
providers can be implemented by subclassing AirflowAPIClientAuthProvider and overriding the
|
|
5
|
+
relevant methods. The provider can be configured via command-line arguments or environment
|
|
6
|
+
variables.
|
|
7
|
+
|
|
8
|
+
See --auth-provider argument in the CLI for more details on how to specify a custom authentication
|
|
9
|
+
provider.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from argparse import ArgumentParser
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from airflow_api_client.log import logger
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import keyring # noqa: F401, optional dependency for secure password storage
|
|
26
|
+
except ImportError:
|
|
27
|
+
keyring = None
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
# The type hint for `args` argument
|
|
31
|
+
from configparser import RawConfigParser
|
|
32
|
+
|
|
33
|
+
from airflow_client.client import ApiClient
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthToken(ABC): # pylint: disable=too-few-public-methods
|
|
37
|
+
"""Abstract base class for authentication tokens."""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def expires(self) -> int:
|
|
42
|
+
"""Returns the number of seconds until the token expires."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AirflowClientJWTToken(AuthToken): # pylint: disable=too-few-public-methods
|
|
46
|
+
"""Represents a JWT token used for authenticating with the Airflow API."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, token: str):
|
|
49
|
+
self.token = token
|
|
50
|
+
|
|
51
|
+
parts = token.split(".")
|
|
52
|
+
if len(parts) != 3:
|
|
53
|
+
raise ValueError("Invalid JWT format")
|
|
54
|
+
|
|
55
|
+
payload = parts[1]
|
|
56
|
+
|
|
57
|
+
# Add padding if needed (JWT uses base64url without padding)
|
|
58
|
+
padding = 4 - len(payload) % 4
|
|
59
|
+
if padding != 4:
|
|
60
|
+
payload += "=" * padding
|
|
61
|
+
|
|
62
|
+
# Decode base64
|
|
63
|
+
self.decoded = json.loads(base64.urlsafe_b64decode(payload))
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def expires(self) -> int:
|
|
67
|
+
# Returns the number of seconds until the token expires
|
|
68
|
+
return self.decoded.get("exp", 0) - int(time.time())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AirflowAPIClientAuthProvider:
|
|
72
|
+
"""Default authentication provider for Airflow API client that supports username/password auth
|
|
73
|
+
and JWT tokens."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, args):
|
|
76
|
+
self.args = args
|
|
77
|
+
self._token = None
|
|
78
|
+
self.timeout = 10
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def password(self) -> str:
|
|
82
|
+
"""Returns the password for authentication. Falls back to keyring if available."""
|
|
83
|
+
if self.args.password is not None:
|
|
84
|
+
return self.args.password
|
|
85
|
+
|
|
86
|
+
if keyring:
|
|
87
|
+
password = keyring.get_password(
|
|
88
|
+
f"airflow-api-client/{self.args.endpoint}", self.username
|
|
89
|
+
)
|
|
90
|
+
if password:
|
|
91
|
+
logger.debug("Using password from keyring")
|
|
92
|
+
return password
|
|
93
|
+
|
|
94
|
+
raise ValueError(
|
|
95
|
+
"Password is required. Provide via --password, AIRFLOW_API_CLIENT_PASSWORD,"
|
|
96
|
+
" config file, or install keyring support: pip install airflow-api-client[keyring]"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def username(self) -> str:
|
|
101
|
+
"""Returns the username for authentication. Raises an error if username is not provided."""
|
|
102
|
+
if self.args.username is None:
|
|
103
|
+
raise ValueError("Username is required for DefaultAuthProvider")
|
|
104
|
+
return self.args.username
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def client_token(self) -> AirflowClientJWTToken:
|
|
108
|
+
"""Returns a valid JWT token for authenticating with the Airflow API. If a valid token is
|
|
109
|
+
already cached, it returns the cached token. Otherwise, it fetches a new token using the
|
|
110
|
+
username and password."""
|
|
111
|
+
if self._token and self._token.expires > 60:
|
|
112
|
+
return self._token
|
|
113
|
+
|
|
114
|
+
env_token = os.getenv("AIRFLOW_API_CLIENT_TOKEN")
|
|
115
|
+
if env_token:
|
|
116
|
+
return AirflowClientJWTToken(env_token)
|
|
117
|
+
|
|
118
|
+
url = f"{self.args.endpoint}/auth/token"
|
|
119
|
+
response = requests.post(
|
|
120
|
+
url,
|
|
121
|
+
headers={"Content-Type": "application/json"},
|
|
122
|
+
json={
|
|
123
|
+
"username": self.username,
|
|
124
|
+
"password": self.password,
|
|
125
|
+
},
|
|
126
|
+
timeout=self.timeout,
|
|
127
|
+
)
|
|
128
|
+
logger.debug(f"Token response: {response.status_code} - {response.text}")
|
|
129
|
+
self._token = AirflowClientJWTToken(response.json()["access_token"])
|
|
130
|
+
return self._token
|
|
131
|
+
|
|
132
|
+
def set_client_configuration(self, client: "ApiClient") -> None:
|
|
133
|
+
"""Configures the Airflow API client with the appropriate authentication headers. This
|
|
134
|
+
method is called by the CLI when initializing the API client.
|
|
135
|
+
`client` is the instance of airflow_client.client.ApiClient that needs to be configured with
|
|
136
|
+
authentication headers.
|
|
137
|
+
"""
|
|
138
|
+
client.access_token = self.client_token.token
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def add_arguments(cls, profile: str, parser: "ArgumentParser", config: "RawConfigParser"):
|
|
142
|
+
"""Adds authentication-related command-line arguments to the parser. This method is called
|
|
143
|
+
by the CLI when setting up the argument parser for the specified profile. The arguments
|
|
144
|
+
added by this method will be available in the `args` object passed to the constructor of the
|
|
145
|
+
authentication provider.
|
|
146
|
+
"""
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
"--username",
|
|
149
|
+
"-u",
|
|
150
|
+
type=str,
|
|
151
|
+
default=os.getenv(
|
|
152
|
+
"AIRFLOW_API_CLIENT_USERNAME", config.get(profile, "username", fallback=None)
|
|
153
|
+
),
|
|
154
|
+
help="Airflow username (typically your SSO)",
|
|
155
|
+
)
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"--password",
|
|
158
|
+
"-p",
|
|
159
|
+
type=str,
|
|
160
|
+
default=os.getenv(
|
|
161
|
+
"AIRFLOW_API_CLIENT_PASSWORD", config.get(profile, "password", fallback=None)
|
|
162
|
+
),
|
|
163
|
+
help="Airflow password (if omitted, attempt to fetch from PWSafe",
|
|
164
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Base class for CLI commands."""
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from argparse import ArgumentParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Command(ABC):
|
|
9
|
+
"""Base class for CLI commands."""
|
|
10
|
+
|
|
11
|
+
no_client = False
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
# The app is set by the CLI in main() before executing the command.
|
|
15
|
+
# This allows for commands that don't or can't require a client (eg. profile creation) to
|
|
16
|
+
# still be executed.
|
|
17
|
+
self.app = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
"""Command name (e.g., 'xcom', 'run')."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def help(self) -> str:
|
|
27
|
+
"""Help text for the command."""
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def add_arguments(cls, parser: ArgumentParser):
|
|
32
|
+
"""Add command-specific arguments to the subparser."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def execute(self, **kwargs):
|
|
36
|
+
"""Execute the command."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Command to construct a payload for a DAG run by prompting for values for each parameter defined
|
|
3
|
+
in the DAG's parameters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
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
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CreatePayload(Command):
|
|
15
|
+
"""Command to construct a payload for a DAG run by prompting for values for each parameter
|
|
16
|
+
defined in the DAG's parameters."""
|
|
17
|
+
|
|
18
|
+
name = "create-payload"
|
|
19
|
+
help = (
|
|
20
|
+
"Construct a payload for a DAG run by prompting for values for each parameter defined"
|
|
21
|
+
" in the DAG's parameters."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def add_arguments(cls, parser) -> None:
|
|
26
|
+
"""Adds arguments for the command."""
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"dag_id",
|
|
29
|
+
metavar="DAG_ID",
|
|
30
|
+
help=(
|
|
31
|
+
"The ID of the DAG for which to create a payload. The DAG's parameters will be used"
|
|
32
|
+
" to prompt for values and construct the payload based on the DAGs `params`."
|
|
33
|
+
),
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--output-file",
|
|
37
|
+
"-o",
|
|
38
|
+
metavar="OUTPUT_FILE",
|
|
39
|
+
help=(
|
|
40
|
+
"Optional file path to save the constructed payload. If not provided, the payload"
|
|
41
|
+
" will be printed to the console."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def execute(self, **kwargs) -> None:
|
|
46
|
+
"""Returns the payload expected for a given DAG. Prompts for values for each parameter
|
|
47
|
+
defined in the DAG's parameters and constructs a payload dictionary.
|
|
48
|
+
"""
|
|
49
|
+
logger.debug("Executing CreatePayload with arguments", dag_id=self.app.args.dag_id)
|
|
50
|
+
dag = DAG(self.app, self.app.args.dag_id)
|
|
51
|
+
parameters = dag.params or {}
|
|
52
|
+
payload = {}
|
|
53
|
+
logger.debug(
|
|
54
|
+
"Constructing payload for DAG run", dag_id=self.app.args.dag_id, parameters=parameters
|
|
55
|
+
)
|
|
56
|
+
payload = dag.create_payload()
|
|
57
|
+
|
|
58
|
+
if self.app.args.output_file:
|
|
59
|
+
with open(self.app.args.output_file, "w", encoding="utf-8") as f:
|
|
60
|
+
json.dump(payload, f, indent=4, sort_keys=True, default=str)
|
|
61
|
+
logger.info(f"Payload saved to {self.app.args.output_file}")
|
|
62
|
+
else:
|
|
63
|
+
output(json.dumps(payload, indent=4, sort_keys=True, default=str), self.app.args)
|
|
64
|
+
return payload
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Get DAG details"""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.log import logger
|
|
5
|
+
from airflow_api_client.models import DAG
|
|
6
|
+
from airflow_api_client.typedefs import App
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GetDagCommand:
|
|
10
|
+
"""Helper class for getting DAG details"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def add_subparser(cls, subparsers):
|
|
14
|
+
"""Add subparser for dag"""
|
|
15
|
+
dag_parser = subparsers.add_parser("dag", help="Get DAG details")
|
|
16
|
+
dag_parser.add_argument("dag_id", help="DAG ID")
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def get_dag(app: App) -> dict:
|
|
20
|
+
"""Get DAG details and return as dict"""
|
|
21
|
+
dag = DAG(app, app.args.dag_id)
|
|
22
|
+
logger.info(f"Retrieved details for DAG '{app.args.dag_id}'")
|
|
23
|
+
return dag.to_dict()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Get DAG run details"""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.log import logger
|
|
5
|
+
from airflow_api_client.models import ResourceSpec
|
|
6
|
+
from airflow_api_client.typedefs import App
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GetDagRunCommand:
|
|
10
|
+
"""Helper class for getting DAG run details"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def add_subparser(cls, subparsers):
|
|
14
|
+
"""Add subparser for dag-run"""
|
|
15
|
+
run_parser = subparsers.add_parser("dag-run", help="Get DAG run details")
|
|
16
|
+
run_parser.add_argument(
|
|
17
|
+
"spec",
|
|
18
|
+
metavar="DAG_ID/RUN_ID",
|
|
19
|
+
help="DAG run specification in format: DAG_ID/RUN_ID",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def get_dag_run(app: App) -> dict:
|
|
24
|
+
"""Get DAG run details and return as dict"""
|
|
25
|
+
spec = ResourceSpec.from_string(app, app.args.spec)
|
|
26
|
+
logger.info(f"Retrieved details for DAG run '{spec.dag_run.id}'")
|
|
27
|
+
return spec.dag_run.to_dict()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Get task instance log"""
|
|
3
|
+
|
|
4
|
+
from airflow_client.client.exceptions import NotFoundException
|
|
5
|
+
|
|
6
|
+
from airflow_api_client.io import output
|
|
7
|
+
from airflow_api_client.log import logger
|
|
8
|
+
from airflow_api_client.models import ResourceSpec
|
|
9
|
+
from airflow_api_client.typedefs import App
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GetLogCommand:
|
|
13
|
+
"""Helper class for getting the log for a task instance"""
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def add_subparser(cls, subparsers):
|
|
17
|
+
"""Add subparser for task-instance"""
|
|
18
|
+
task_parser = subparsers.add_parser("log", help="Get task instance log")
|
|
19
|
+
task_parser.add_argument(
|
|
20
|
+
"spec",
|
|
21
|
+
metavar="DAG_ID/RUN_ID[/TASK_ID]",
|
|
22
|
+
help="Task instance specification in format: DAG_ID/RUN_ID/TASK_ID",
|
|
23
|
+
)
|
|
24
|
+
task_parser.add_argument(
|
|
25
|
+
"--try-number",
|
|
26
|
+
type=int,
|
|
27
|
+
default=1,
|
|
28
|
+
help="Try number of the task instance log to retrieve (default: 1)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
task_parser.add_argument(
|
|
32
|
+
"--time-format",
|
|
33
|
+
default="%Y-%m-%dT%H:%M:%S",
|
|
34
|
+
type=str,
|
|
35
|
+
help=(
|
|
36
|
+
"Time format for log timestamps (only applicable"
|
|
37
|
+
" for human output, default: '%%Y-%%m-%%dT%%H:%%M:%%S')."
|
|
38
|
+
" Supports strftime format codes. Only applies to human output, ignored for"
|
|
39
|
+
" JSON/YAML output."
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def get_log(app: App) -> dict:
|
|
45
|
+
"""Get task instance logs and return as dict"""
|
|
46
|
+
spec = ResourceSpec.from_string(app, app.args.spec)
|
|
47
|
+
|
|
48
|
+
task_instances = [spec.task_instance] if spec.task_instance else []
|
|
49
|
+
if not task_instances:
|
|
50
|
+
task_instances = spec.dag_run.task_instances
|
|
51
|
+
|
|
52
|
+
logger.debug(
|
|
53
|
+
f"Getting logs for task instances: {task_instances} in "
|
|
54
|
+
f" DAG: {spec.dag.id} and DAG Run: {spec.dag_run.id}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if app.args.output_format == "human":
|
|
58
|
+
identifier = f"{spec.dag.id}/{spec.dag_run.id}"
|
|
59
|
+
for task_instance in task_instances:
|
|
60
|
+
if task_instance.try_number < app.args.try_number:
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"Task instance {task_instance.id} has try number "
|
|
63
|
+
f"{task_instance.try_number} which is less than the requested "
|
|
64
|
+
f"try number {app.args.try_number}. Returning latest available log instead."
|
|
65
|
+
)
|
|
66
|
+
output(
|
|
67
|
+
[[f"Log for Task Instance: {identifier}/{task_instance.id}"]],
|
|
68
|
+
app.args,
|
|
69
|
+
headers=[],
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
task_instance.get_log(app.args.try_number).output(
|
|
73
|
+
time_format=app.args.time_format
|
|
74
|
+
)
|
|
75
|
+
except NotFoundException:
|
|
76
|
+
logger.error(
|
|
77
|
+
f"Failed to get log for task instance: {task_instance.id} (not found)"
|
|
78
|
+
" Upstream failed or skipped?"
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# json or yaml output
|
|
84
|
+
logs = {}
|
|
85
|
+
for task_instance in task_instances:
|
|
86
|
+
try:
|
|
87
|
+
log = task_instance.get_log(try_number=app.args.try_number)
|
|
88
|
+
logs[task_instance.id] = list(log)
|
|
89
|
+
except NotFoundException:
|
|
90
|
+
logger.error(
|
|
91
|
+
f"Failed to get log for task instance: {task_instance.id} (not found)"
|
|
92
|
+
" Upstream failed or skipped"
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
return logs
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Main get command with subcommand dispatch"""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from airflow_api_client.commands import Command
|
|
7
|
+
from airflow_api_client.commands.get.dag import GetDagCommand
|
|
8
|
+
from airflow_api_client.commands.get.dag_run import GetDagRunCommand
|
|
9
|
+
from airflow_api_client.commands.get.log import GetLogCommand
|
|
10
|
+
from airflow_api_client.commands.get.task_instance import GetTaskInstanceCommand
|
|
11
|
+
from airflow_api_client.io import output
|
|
12
|
+
from airflow_api_client.log import logger
|
|
13
|
+
|
|
14
|
+
DESCRIPTION = """
|
|
15
|
+
Command to get details about a specific Airflow resource. This command retrieves full details
|
|
16
|
+
about a DAG, DAG run, or task instance by its identifier. Unlike the list command which returns
|
|
17
|
+
multiple resources, get returns a single resource with all available details.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GetCommand(Command):
|
|
22
|
+
"""Command to get details about a specific resource."""
|
|
23
|
+
|
|
24
|
+
name = "get"
|
|
25
|
+
help = DESCRIPTION
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def add_arguments(cls, parser) -> None:
|
|
29
|
+
subparsers = parser.add_subparsers(dest="resource_type", required=True)
|
|
30
|
+
|
|
31
|
+
GetDagCommand.add_subparser(subparsers)
|
|
32
|
+
GetDagRunCommand.add_subparser(subparsers)
|
|
33
|
+
GetTaskInstanceCommand.add_subparser(subparsers)
|
|
34
|
+
GetLogCommand.add_subparser(subparsers)
|
|
35
|
+
|
|
36
|
+
def execute(self, **kwargs) -> None:
|
|
37
|
+
"""Get details about a specific resource"""
|
|
38
|
+
logger.debug(f"Executing GetCommand for resource type: {self.app.args.resource_type}")
|
|
39
|
+
|
|
40
|
+
result = None
|
|
41
|
+
if self.app.args.resource_type == "dag":
|
|
42
|
+
|
|
43
|
+
result = GetDagCommand.get_dag(self.app)
|
|
44
|
+
elif self.app.args.resource_type == "dag-run":
|
|
45
|
+
|
|
46
|
+
result = GetDagRunCommand.get_dag_run(self.app)
|
|
47
|
+
elif self.app.args.resource_type == "task-instance":
|
|
48
|
+
|
|
49
|
+
result = GetTaskInstanceCommand.get_task_instance(self.app)
|
|
50
|
+
elif self.app.args.resource_type == "log":
|
|
51
|
+
result = GetLogCommand.get_log(self.app)
|
|
52
|
+
|
|
53
|
+
if result:
|
|
54
|
+
# For human output, format as key-value table
|
|
55
|
+
if self.app.args.output_format == "human":
|
|
56
|
+
self._output_human(result)
|
|
57
|
+
else:
|
|
58
|
+
output(result, self.app.args)
|
|
59
|
+
|
|
60
|
+
def _output_human(self, data: dict) -> None:
|
|
61
|
+
"""Output data in human-readable key-value format"""
|
|
62
|
+
|
|
63
|
+
rows = []
|
|
64
|
+
for key, value in sorted(data.items()):
|
|
65
|
+
# Format complex values as JSON
|
|
66
|
+
if isinstance(value, (dict, list)):
|
|
67
|
+
value_str = json.dumps(value, indent=2, default=str)
|
|
68
|
+
else:
|
|
69
|
+
value_str = str(value)
|
|
70
|
+
rows.append([key, value_str])
|
|
71
|
+
|
|
72
|
+
output(rows, self.app.args, headers=["Field", "Value"])
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Get task instance details"""
|
|
3
|
+
|
|
4
|
+
from airflow_api_client.log import logger
|
|
5
|
+
from airflow_api_client.models import ResourceSpec
|
|
6
|
+
from airflow_api_client.typedefs import App
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GetTaskInstanceCommand:
|
|
10
|
+
"""Helper class for getting task instance details"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def add_subparser(cls, subparsers):
|
|
14
|
+
"""Add subparser for task-instance"""
|
|
15
|
+
task_parser = subparsers.add_parser("task-instance", help="Get task instance details")
|
|
16
|
+
task_parser.add_argument(
|
|
17
|
+
"spec",
|
|
18
|
+
metavar="DAG_ID/RUN_ID/TASK_ID",
|
|
19
|
+
help="Task instance specification in format: DAG_ID/RUN_ID/TASK_ID",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def get_task_instance(app: App) -> dict:
|
|
24
|
+
"""Get task instance details and return as dict"""
|
|
25
|
+
spec = ResourceSpec.from_string(app, app.args.spec)
|
|
26
|
+
if spec.task_instance is None:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"Invalid task instance specification: {app.args.spec}. "
|
|
29
|
+
"Expected format: DAG_ID/RUN_ID/TASK_ID"
|
|
30
|
+
)
|
|
31
|
+
logger.info(f"Retrieved details for task instance '{spec.task_instance.task_id}'")
|
|
32
|
+
return spec.task_instance.to_dict()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Base class for list commands"""
|
|
3
|
+
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from airflow_api_client.commands import Command
|
|
7
|
+
from airflow_api_client.filters import get_obj_value
|
|
8
|
+
from airflow_api_client.typedefs import App
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ListCommandBase(Command):
|
|
12
|
+
"""Base class for list commands with common functionality"""
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def add_common_arguments(cls, parser) -> None:
|
|
16
|
+
"""Add common arguments for all list commands"""
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--filter",
|
|
19
|
+
"-f",
|
|
20
|
+
action="append",
|
|
21
|
+
help="""
|
|
22
|
+
Filter results based on an object's value on the form: 'attr operator value', ie
|
|
23
|
+
'is_paused == true' or 'owners !^ "airflow"'. Supported operators:
|
|
24
|
+
" ==, !=, >, <, ^ (contains), !^ (not contains). Repeat for multiple filters.
|
|
25
|
+
Note that the value will be serialised using JSON, so use types accordingly. (eg. set `value` to
|
|
26
|
+
`null` in order to match attributes with a value of `None` and enclose strings in
|
|
27
|
+
quotation marks etc. Supports jmespath-notation to traverse objects.
|
|
28
|
+
Note that this functionality is generic and can be used to filter on any
|
|
29
|
+
attribute of any underlying object's JSON representation (see --output-format json) for obtaining
|
|
30
|
+
the full JSON representation of each object to determine which attributes to filter on.
|
|
31
|
+
See the airflow_client Python library docs for more info.
|
|
32
|
+
""",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--extra-attrs",
|
|
36
|
+
"-e",
|
|
37
|
+
type=str,
|
|
38
|
+
action="append",
|
|
39
|
+
help="""
|
|
40
|
+
Extra attribute to include in the output.
|
|
41
|
+
This will be included in the output alongside the default attributes for each resource. Output
|
|
42
|
+
may be modified to better suit tabular representation. Repeat for multiple extra attributes.
|
|
43
|
+
Related to notices in --filter. Richer objects such as lists and dictionaries are converted to
|
|
44
|
+
comma-separated strings for better readability in table output, for more complex or verbatim
|
|
45
|
+
output consider using the --output-format option with json or yaml and parsing the
|
|
46
|
+
output with jq/yq or similar. Supports jmespath-notation to select attributes
|
|
47
|
+
Example: list -e last_parse_duration -e owners -e default_args.has_on_failure_callback dags
|
|
48
|
+
Or see slow DAG Runs: list -f "duration > 10" dag-runs my_dag_id
|
|
49
|
+
""",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _add_extra_attrs(self, app: App, obj: Any, headers: List, attrs: List) -> None:
|
|
53
|
+
"""Helper method to add extra attributes to the output based on user input. Modifies headers
|
|
54
|
+
and attrs lists in place.
|
|
55
|
+
"""
|
|
56
|
+
for extra_attr in app.args.extra_attrs or []:
|
|
57
|
+
extra_attr_value = get_obj_value(obj, extra_attr)
|
|
58
|
+
# Make lists and dicts more readable in output by converting to comma-separated strings
|
|
59
|
+
if hasattr(extra_attr_value, "__iter__") and not isinstance(extra_attr_value, str):
|
|
60
|
+
extra_attr_value = ", ".join(str(v) for v in extra_attr_value)
|
|
61
|
+
|
|
62
|
+
headers.append(extra_attr.replace("_", " ").replace(".", " ").title())
|
|
63
|
+
attrs.append(extra_attr_value)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Copyright 2026 Rackspace Technology, Inc. SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""List DAG runs 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 DAG
|
|
7
|
+
from airflow_api_client.typedefs import App
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ListDagRunsCommand:
|
|
11
|
+
"""Helper class for listing DAG runs"""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def add_subparser(cls, subparsers):
|
|
15
|
+
"""Add subparser for dag-runs"""
|
|
16
|
+
runs_parser = subparsers.add_parser("dag-runs", help="List DAG runs")
|
|
17
|
+
runs_parser.add_argument("dag_id", help="DAG ID")
|
|
18
|
+
runs_parser.add_argument("--limit", type=int, default=10, help="Limit results")
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def list_dag_runs(app: App, add_extra_attrs_fn) -> None:
|
|
22
|
+
"""List DAG runs for a given DAG"""
|
|
23
|
+
|
|
24
|
+
dag = DAG(app, app.args.dag_id)
|
|
25
|
+
|
|
26
|
+
dag_runs = dag.get_dag_runs(app.args.limit)
|
|
27
|
+
|
|
28
|
+
if app.args.filter:
|
|
29
|
+
dag_runs = [d for d in dag_runs if filter_obj(d, app.args.filter)]
|
|
30
|
+
|
|
31
|
+
if app.args.output_format in ("json", "yaml"):
|
|
32
|
+
output({"dag_runs": [d.to_dict() for d in dag_runs]}, app.args)
|
|
33
|
+
else:
|
|
34
|
+
headers = [
|
|
35
|
+
"DAG ID",
|
|
36
|
+
"Run ID",
|
|
37
|
+
"State",
|
|
38
|
+
"Run Type",
|
|
39
|
+
"Triggered Via",
|
|
40
|
+
"Triggered By",
|
|
41
|
+
"Duration",
|
|
42
|
+
"Start Date",
|
|
43
|
+
"End Date",
|
|
44
|
+
]
|
|
45
|
+
data = []
|
|
46
|
+
for run in dag_runs:
|
|
47
|
+
attrs = [
|
|
48
|
+
run.dag_id,
|
|
49
|
+
run.id,
|
|
50
|
+
run.state.value,
|
|
51
|
+
run.run_type.value,
|
|
52
|
+
run.triggered_by.value,
|
|
53
|
+
run.triggering_user_name,
|
|
54
|
+
run.duration,
|
|
55
|
+
run.start_date,
|
|
56
|
+
run.end_date,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
add_extra_attrs_fn(app, run, headers, attrs)
|
|
60
|
+
|
|
61
|
+
data.append(attrs)
|
|
62
|
+
output(data, app.args, headers=headers)
|