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.
Files changed (35) hide show
  1. airflow_api_client/__init__.py +1 -0
  2. airflow_api_client/auth.py +164 -0
  3. airflow_api_client/commands/__init__.py +36 -0
  4. airflow_api_client/commands/create_payload.py +64 -0
  5. airflow_api_client/commands/get/__init__.py +6 -0
  6. airflow_api_client/commands/get/dag.py +23 -0
  7. airflow_api_client/commands/get/dag_run.py +27 -0
  8. airflow_api_client/commands/get/log.py +96 -0
  9. airflow_api_client/commands/get/main.py +72 -0
  10. airflow_api_client/commands/get/task_instance.py +32 -0
  11. airflow_api_client/commands/list/__init__.py +6 -0
  12. airflow_api_client/commands/list/base.py +63 -0
  13. airflow_api_client/commands/list/dag_runs.py +62 -0
  14. airflow_api_client/commands/list/dags.py +53 -0
  15. airflow_api_client/commands/list/main.py +39 -0
  16. airflow_api_client/commands/list/task_instances.py +56 -0
  17. airflow_api_client/commands/pause.py +35 -0
  18. airflow_api_client/commands/profile.py +216 -0
  19. airflow_api_client/commands/raw.py +99 -0
  20. airflow_api_client/commands/run.py +110 -0
  21. airflow_api_client/commands/unpause.py +33 -0
  22. airflow_api_client/commands/xcom.py +54 -0
  23. airflow_api_client/compat.py +66 -0
  24. airflow_api_client/filters.py +84 -0
  25. airflow_api_client/io.py +197 -0
  26. airflow_api_client/log.py +80 -0
  27. airflow_api_client/main.py +357 -0
  28. airflow_api_client/models/__init__.py +674 -0
  29. airflow_api_client/typedefs.py +10 -0
  30. airflow_api_client-0.1.0.dist-info/METADATA +509 -0
  31. airflow_api_client-0.1.0.dist-info/RECORD +35 -0
  32. airflow_api_client-0.1.0.dist-info/WHEEL +4 -0
  33. airflow_api_client-0.1.0.dist-info/entry_points.txt +5 -0
  34. airflow_api_client-0.1.0.dist-info/licenses/LICENSE +201 -0
  35. 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)