You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by bu...@apache.org on 2026/01/09 20:18:42 UTC
(airflow) branch sync-airflowctl-for-v0-1-1 created (now a2277fd72d9)
This is an automated email from the ASF dual-hosted git repository.
bugraoz pushed a change to branch sync-airflowctl-for-v0-1-1
in repository https://gitbox.apache.org/repos/asf/airflow.git
at a2277fd72d9 Sync airflowctl to v3-1-test
This branch includes the following new commits:
new a2277fd72d9 Sync airflowctl to v3-1-test
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(airflow) 01/01: Sync airflowctl to v3-1-test
Posted by bu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
bugraoz pushed a commit to branch sync-airflowctl-for-v0-1-1
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit a2277fd72d954efdf6f84241e9e5706979fa2c6a
Author: bugraoz93 <oz...@gmail.com>
AuthorDate: Fri Jan 9 21:17:52 2026 +0100
Sync airflowctl to v3-1-test
---
airflow-ctl-tests/pyproject.toml | 2 +-
.../tests/airflowctl_tests/conftest.py | 90 +--
.../airflowctl_tests/fixtures/pools_export.json | 38 +
.../fixtures/test_connections.json | 11 +
.../airflowctl_tests/fixtures/test_pools.json | 8 +
.../airflowctl_tests/fixtures/test_variables.json | 7 +
.../fixtures/variables_export.json | 8 +
.../airflowctl_tests/test_airflowctl_commands.py | 209 ++++--
airflow-ctl/.pre-commit-config.yaml | 10 +
airflow-ctl/NOTICE | 2 +-
airflow-ctl/RELEASE_NOTES.rst | 31 +
airflow-ctl/airflow-ctl/.gitignore | 1 +
.../{ => airflow-ctl}/.pre-commit-config.yaml | 2 +-
airflow-ctl/airflow-ctl/README.md | 69 ++
airflow-ctl/{ => airflow-ctl}/RELEASE_NOTES.rst | 4 +-
.../docs/changelog.rst} | 18 +-
.../airflow-ctl/docs/cli-and-env-variables-ref.rst | 62 ++
airflow-ctl/airflow-ctl/docs/conf.py | 297 ++++++++
airflow-ctl/airflow-ctl/docs/howto/index.rst | 222 ++++++
.../docs/images/command_hashes.txt | 0
...flowctl_api_network_architecture_diagram.md5sum | 1 +
...airflowctl_api_network_architecture_diagram.png | Bin 0 -> 63371 bytes
.../airflowctl_api_network_architecture_diagram.py | 86 +++
.../airflow-ctl/docs/images/output_assets.svg | 165 +++++
.../airflow-ctl/docs/images/output_auth.svg | 105 +++
.../airflow-ctl/docs/images/output_auth_login.svg | 122 +++
.../airflow-ctl/docs/images/output_backfill.svg | 125 ++++
.../airflow-ctl/docs/images/output_config.svg | 109 +++
.../docs/images/output_connections.svg | 0
.../airflow-ctl/docs/images/output_dagrun.svg | 101 +++
.../airflow-ctl/docs/images/output_dags.svg | 157 ++++
.../airflow-ctl/docs/images/output_jobs.svg | 97 +++
.../airflow-ctl/docs/images/output_main.svg | 157 ++++
.../airflow-ctl/docs/images/output_pools.svg | 121 +++
.../airflow-ctl/docs/images/output_providers.svg | 97 +++
.../docs/images/output_variables.svg | 0
.../airflow-ctl/docs/images/output_version.svg | 94 +++
.../docs/index.rst} | 26 +-
.../airflow-ctl/docs/installation/index.rst | 132 ++++
.../docs/installation/installing-from-pypi.rst | 63 ++
.../docs/installation/installing-from-sources.rst | 22 +-
.../docs/installation/prerequisites.rst | 0
.../__init__.py => airflow-ctl/docs/redirects.txt} | 13 +-
airflow-ctl/airflow-ctl/docs/security.rst | 30 +
airflow-ctl/airflow-ctl/docs/start.rst | 52 ++
.../airflow-ctl/docs/static/exampleinclude.css | 86 +++
.../airflow-ctl/docs/static/gh-jira-links.js | 36 +
airflow-ctl/airflow-ctl/docs/static/redirects.js | 29 +
airflow-ctl/airflow-ctl/docs/templates/footer.html | 29 +
.../newsfragments/config.toml} | 36 +-
airflow-ctl/{ => airflow-ctl}/pyproject.toml | 20 +-
.../{ => airflow-ctl}/src/airflowctl/__init__.py | 0
.../src/airflowctl/__main__.py} | 20 +-
.../src/airflowctl/api}/__init__.py | 6 -
.../airflow-ctl/src/airflowctl/api/client.py | 358 +++++++++
.../src/airflowctl/api/datamodels}/__init__.py | 6 -
.../airflowctl/api/datamodels/auth_generated.py | 47 ++
.../src/airflowctl/api/datamodels/generated.py | 69 +-
.../airflow-ctl/src/airflowctl/api/operations.py | 699 +++++++++++++++++
.../src/airflowctl/ctl}/__init__.py | 6 +-
.../src/airflowctl/ctl/cli_config.py | 0
.../airflow-ctl/src/airflowctl/ctl/cli_parser.py | 170 +++++
.../src/airflowctl/ctl/commands}/__init__.py | 6 -
.../src/airflowctl/ctl/commands/auth_command.py | 77 ++
.../src/airflowctl/ctl/commands/config_command.py | 825 +++++++++++++++++++++
.../airflowctl/ctl/commands/connection_command.py | 74 ++
.../src/airflowctl/ctl/commands/dag_command.py | 74 ++
.../src/airflowctl/ctl/commands/pool_command.py | 122 +++
.../airflowctl/ctl/commands/variable_command.py | 0
.../airflowctl/ctl/commands/version_command.py} | 18 +-
.../src/airflowctl/ctl/console_formatting.py | 146 ++++
.../src/airflowctl/ctl/utils}/__init__.py | 6 -
.../airflow-ctl/src/airflowctl/ctl/utils/yaml.py | 74 ++
.../src/airflowctl/exceptions.py} | 24 +-
.../src/airflowctl/utils}/__init__.py | 6 -
.../src/airflowctl/utils/helpers.py} | 14 +-
.../src/airflowctl/utils/module_loading.py} | 21 +-
.../tests/airflow_ctl}/__init__.py | 6 -
.../tests/airflow_ctl/api}/__init__.py | 6 -
.../tests/airflow_ctl/api/test_client.py | 2 +-
.../tests/airflow_ctl/api/test_operations.py | 9 +-
.../tests/airflow_ctl/ctl}/__init__.py | 6 -
.../tests/airflow_ctl/ctl/commands}/__init__.py | 6 -
.../airflow_ctl/ctl/commands/test_auth_command.py | 108 +++
.../ctl/commands/test_config_command.py | 363 +++++++++
.../ctl/commands/test_connections_command.py} | 102 +--
.../airflow_ctl/ctl/commands/test_dag_command.py | 0
.../airflow_ctl/ctl/commands/test_pool_command.py | 188 +++++
.../ctl/commands/test_variable_command.py | 0
.../ctl/commands/test_version_command.py | 63 ++
.../tests/airflow_ctl/ctl/test_cli_config.py | 357 +++++++++
airflow-ctl/airflow-ctl/tests/conftest.py | 79 ++
airflow-ctl/docs/images/command_hashes.txt | 4 +-
airflow-ctl/docs/images/output_connections.svg | 141 ++--
airflow-ctl/docs/images/output_variables.svg | 114 ++-
.../docs/installation/installing-from-sources.rst | 13 +-
airflow-ctl/docs/installation/prerequisites.rst | 25 +
airflow-ctl/newsfragments/59850.significant.rst | 30 +
airflow-ctl/pyproject.toml | 2 +-
airflow-ctl/src/airflowctl/__init__.py | 2 +-
.../src/airflowctl/api/datamodels/generated.py | 208 ++++--
airflow-ctl/src/airflowctl/ctl/cli_config.py | 30 +-
.../airflowctl/ctl/commands/variable_command.py | 22 -
airflow-ctl/tests/airflow_ctl/api/test_client.py | 2 +-
.../tests/airflow_ctl/api/test_operations.py | 9 +-
.../airflow_ctl/ctl/commands/test_dag_command.py | 8 +-
.../ctl/commands/test_variable_command.py | 20 -
.../ci/prek/check_airflowctl_command_coverage.py | 150 ++++
.../in_container/run_capture_airflowctl_help.py | 6 +-
109 files changed, 7571 insertions(+), 580 deletions(-)
diff --git a/airflow-ctl-tests/pyproject.toml b/airflow-ctl-tests/pyproject.toml
index 24c7f561ed1..8adbe1f6285 100644
--- a/airflow-ctl-tests/pyproject.toml
+++ b/airflow-ctl-tests/pyproject.toml
@@ -26,7 +26,7 @@ description = "Airflow CTL tests for Apache Airflow"
classifiers = [
"Private :: Do Not Upload",
]
-requires-python = ">=3.10,!=3.13"
+requires-python = ">=3.10,!=3.14"
authors = [
{ name = "Apache Software Foundation", email = "dev@airflow.apache.org" },
]
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
index 13dacf5d8c5..3ac692722f2 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py
@@ -20,7 +20,6 @@ import os
import subprocess
import sys
-import pytest
from python_on_whales import DockerClient, docker
from airflowctl_tests import console
@@ -30,13 +29,17 @@ from airflowctl_tests.constants import (
DOCKER_IMAGE,
)
-docker_client = None
+from tests_common.test_utils.fernet import generate_fernet_key_string
+
+
+class _CtlTestState:
+ docker_client: DockerClient | None = None
# Pytest hook to run at the start of the session
def pytest_sessionstart(session):
"""Install airflowctl at the very start of the pytest session."""
- airflow_ctl_version = os.environ.get("AIRFLOW_CTL_VERSION", "1.0.0")
+ airflow_ctl_version = os.environ.get("AIRFLOW_CTL_VERSION", "0.1.0")
console.print(f"[yellow]Installing apache-airflow-ctl=={airflow_ctl_version} via pytest_sessionstart...")
airflow_ctl_path = AIRFLOW_ROOT_PATH / "airflow-ctl"
@@ -143,8 +146,6 @@ def docker_compose_up(tmp_path_factory):
"""Fixture to spin up Docker Compose environment for the test session."""
from shutil import copyfile
- global docker_client
-
tmp_dir = tmp_path_factory.mktemp("airflow-ctl-test")
console.print(f"[yellow]Tests are run in {tmp_dir}")
@@ -165,16 +166,25 @@ def docker_compose_up(tmp_path_factory):
os.environ["AIRFLOW_IMAGE_NAME"] = DOCKER_IMAGE
os.environ["AIRFLOW_CTL_VERSION"] = os.environ.get("AIRFLOW_CTL_VERSION", "1.0.0")
os.environ["ENV_FILE_PATH"] = str(tmp_dir / ".env")
+ #
+ # Please Do not use this Fernet key in any deployments! Please generate your own key.
+ # This is specifically generated for integration tests and not as default.
+ #
+ os.environ["FERNET_KEY"] = generate_fernet_key_string()
# Initialize Docker client
- docker_client = DockerClient(compose_files=[str(tmp_docker_compose_file)])
+ _CtlTestState.docker_client = DockerClient(compose_files=[str(tmp_docker_compose_file)])
try:
console.print(f"[blue]Spinning up airflow environment using {DOCKER_IMAGE}")
- docker_client.compose.up(detach=True, wait=True)
+ _CtlTestState.docker_client.compose.up(detach=True, wait=True)
console.print("[green]Docker compose started for airflowctl test\n")
except Exception:
- print_diagnostics(docker_client.compose, docker_client.compose.version(), docker.version())
+ print_diagnostics(
+ _CtlTestState.docker_client.compose,
+ _CtlTestState.docker_client.compose.version(),
+ docker.version(),
+ )
debug_environment()
docker_compose_down()
raise
@@ -182,71 +192,11 @@ def docker_compose_up(tmp_path_factory):
def docker_compose_down():
"""Tear down Docker Compose environment."""
- if docker_client:
- docker_client.compose.down(remove_orphans=True, volumes=True, quiet=True)
+ if _CtlTestState.docker_client:
+ _CtlTestState.docker_client.compose.down(remove_orphans=True, volumes=True, quiet=True)
def pytest_sessionfinish(session, exitstatus):
"""Tear down test environment at the end of the pytest session."""
if not os.environ.get("SKIP_DOCKER_COMPOSE_DELETION"):
docker_compose_down()
-
-
-# Fixtures for tests
-@pytest.fixture
-def login_command():
- # Passing password via command line is insecure but acceptable for testing purposes
- # Please do not do this in production, it enables possibility of exposing your credentials
- return "auth login --username airflow --password airflow"
-
-
-@pytest.fixture
-def login_output():
- return "Login successful! Welcome to airflowctl!"
-
-
-@pytest.fixture
-def date_param():
- import random
- from datetime import datetime, timedelta
-
- from dateutil.relativedelta import relativedelta
-
- # original datetime string
- dt_str = "2025-10-25T00:02:00+00:00"
-
- # parse to datetime object
- dt = datetime.fromisoformat(dt_str)
-
- # boundaries
- start = dt - relativedelta(months=1)
- end = dt + relativedelta(months=1)
-
- # pick random time between start and end
- delta = end - start
- random_seconds = random.randint(0, int(delta.total_seconds()))
- random_dt = start + timedelta(seconds=random_seconds)
- return random_dt.isoformat()
-
-
-@pytest.fixture
-def test_commands(login_command, date_param):
- # Define test commands to run with actual running API server
- return [
- login_command,
- "backfill list",
- "config get --section core --option executor",
- "connections create --connection-id=test_con --conn-type=mysql --password=TEST_PASS -o json",
- "connections list",
- "connections list -o yaml",
- "connections list -o tabledags list",
- f"dagrun trigger --dag-id=example_bash_operator --logical-date={date_param} --run-after={date_param}",
- "dagrun list --dag-id example_bash_operator --state success --limit=1",
- "jobs list",
- "pools create --name=test_pool --slots=5",
- "pools list",
- "providers list",
- "variables create --key=test_key --value=test_value",
- "variables list",
- "version --remote",
- ]
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/fixtures/pools_export.json b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/pools_export.json
new file mode 100644
index 00000000000..4752ad7801f
--- /dev/null
+++ b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/pools_export.json
@@ -0,0 +1,38 @@
+[
+ {
+ "deferred_slots": 0,
+ "description": "Default pool",
+ "include_deferred": false,
+ "name": "default_pool",
+ "occupied_slots": 0,
+ "open_slots": 128,
+ "queued_slots": 0,
+ "running_slots": 0,
+ "scheduled_slots": 0,
+ "slots": 128
+ },
+ {
+ "deferred_slots": 0,
+ "description": null,
+ "include_deferred": false,
+ "name": "test_pool",
+ "occupied_slots": 0,
+ "open_slots": 10,
+ "queued_slots": 0,
+ "running_slots": 0,
+ "scheduled_slots": 0,
+ "slots": 10
+ },
+ {
+ "deferred_slots": 0,
+ "description": "Test pool for import",
+ "include_deferred": false,
+ "name": "test_import_pool",
+ "occupied_slots": 0,
+ "open_slots": 10,
+ "queued_slots": 0,
+ "running_slots": 0,
+ "scheduled_slots": 0,
+ "slots": 10
+ }
+]
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_connections.json b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_connections.json
new file mode 100644
index 00000000000..0141f399ec2
--- /dev/null
+++ b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_connections.json
@@ -0,0 +1,11 @@
+{
+ "test_import_conn": {
+ "conn_type": "postgres",
+ "host": "localhost",
+ "login": "testuser",
+ "password": "testpass",
+ "port": 5432,
+ "extra": "{}",
+ "description": "Test connection for import"
+ }
+}
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_pools.json b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_pools.json
new file mode 100644
index 00000000000..691e1d62d56
--- /dev/null
+++ b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_pools.json
@@ -0,0 +1,8 @@
+[
+ {
+ "name": "test_import_pool",
+ "slots": 10,
+ "description": "Test pool for import",
+ "include_deferred": false
+ }
+]
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_variables.json b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_variables.json
new file mode 100644
index 00000000000..05de7d2fc8b
--- /dev/null
+++ b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/test_variables.json
@@ -0,0 +1,7 @@
+{
+ "test_import_var": "import_value",
+ "test_import_var_with_desc": {
+ "value": "import_value_with_description",
+ "description": "Test variable with description"
+ }
+}
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/fixtures/variables_export.json b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/variables_export.json
new file mode 100644
index 00000000000..f8aed8ce379
--- /dev/null
+++ b/airflow-ctl-tests/tests/airflowctl_tests/fixtures/variables_export.json
@@ -0,0 +1,8 @@
+{
+ "test_import_var": "import_value",
+ "test_import_var_with_desc": {
+ "description": "Test variable with description",
+ "value": "import_value_with_description"
+ },
+ "test_key": "updated_value"
+}
diff --git a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
index d17e646cb06..9a504b73b82 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
@@ -19,59 +19,168 @@ from __future__ import annotations
import os
from subprocess import PIPE, STDOUT, Popen
+import pytest
+
from airflowctl_tests import console
-def test_airflowctl_commands(login_command, login_output, test_commands):
+def date_param():
+ import random
+ from datetime import datetime, timedelta
+
+ from dateutil.relativedelta import relativedelta
+
+ # original datetime string
+ dt_str = "2025-10-25T00:02:00+00:00"
+
+ # parse to datetime object
+ dt = datetime.fromisoformat(dt_str)
+
+ # boundaries
+ start = dt - relativedelta(months=1)
+ end = dt + relativedelta(months=1)
+
+ # pick random time between start and end
+ delta = end - start
+ random_seconds = random.randint(0, int(delta.total_seconds()))
+ random_dt = start + timedelta(seconds=random_seconds)
+ return random_dt.isoformat()
+
+
+LOGIN_COMMAND = "auth login --username airflow --password airflow"
+LOGIN_OUTPUT = "Login successful! Welcome to airflowctl!"
+ONE_DATE_PARAM = date_param()
+TEST_COMMANDS = [
+ # Passing password via command line is insecure but acceptable for testing purposes
+ # Please do not do this in production, it enables possibility of exposing your credentials
+ LOGIN_COMMAND,
+ # Assets commands
+ "assets list",
+ "assets get --asset-id=1",
+ "assets create-event --asset-id=1",
+ # Backfill commands
+ "backfill list",
+ # Config commands
+ "config get --section core --option executor",
+ "config list",
+ "config lint",
+ # Connections commands
+ "connections create --connection-id=test_con --conn-type=mysql --password=TEST_PASS -o json",
+ "connections list",
+ "connections list -o yaml",
+ "connections list -o table",
+ "connections get --conn-id=test_con",
+ "connections get --conn-id=test_con -o json",
+ "connections update --connection-id=test_con --conn-type=postgres",
+ "connections import tests/airflowctl_tests/fixtures/test_connections.json",
+ "connections delete --conn-id=test_con",
+ "connections delete --conn-id=test_import_conn",
+ # DAGs commands
+ "dags list",
+ "dags get --dag-id=example_bash_operator",
+ "dags get-details --dag-id=example_bash_operator",
+ "dags get-stats --dag-ids=example_bash_operator",
+ "dags get-version --dag-id=example_bash_operator --version-number=1",
+ "dags list-import-errors",
+ "dags list-version --dag-id=example_bash_operator",
+ "dags list-warning",
+ # Order of trigger and pause/unpause is important for test stability because state checked
+ f"dags trigger --dag-id=example_bash_operator --logical-date={ONE_DATE_PARAM} --run-after={ONE_DATE_PARAM}",
+ "dags pause example_bash_operator",
+ "dags unpause example_bash_operator",
+ # DAG Run commands
+ f'dagrun get --dag-id=example_bash_operator --dag-run-id="manual__{ONE_DATE_PARAM}"',
+ "dags update --dag-id=example_bash_operator --no-is-paused",
+ # DAG Run commands
+ "dagrun list --dag-id example_bash_operator --state success --limit=1",
+ # Jobs commands
+ "jobs list",
+ # Pools commands
+ "pools create --name=test_pool --slots=5",
+ "pools list",
+ "pools get --pool-name=test_pool",
+ "pools get --pool-name=test_pool -o yaml",
+ "pools update --pool=test_pool --slots=10",
+ "pools import tests/airflowctl_tests/fixtures/test_pools.json",
+ "pools delete --pool=test_pool",
+ "pools delete --pool=test_import_pool",
+ # Providers commands
+ "providers list",
+ # Variables commands
+ "variables create --key=test_key --value=test_value",
+ "variables list",
+ "variables get --variable-key=test_key",
+ "variables get --variable-key=test_key -o table",
+ "variables update --key=test_key --value=updated_value",
+ "variables import tests/airflowctl_tests/fixtures/test_variables.json",
+ "variables delete --variable-key=test_key",
+ "variables delete --variable-key=test_import_var",
+ "variables delete --variable-key=test_import_var_with_desc",
+ # Version command
+ "version --remote",
+]
+
+
+@pytest.mark.flaky(reruns=3, reruns_delay=1)
+@pytest.mark.parametrize(
+ "command", TEST_COMMANDS, ids=[" ".join(command.split(" ", 2)[:2]) for command in TEST_COMMANDS]
+)
+def test_airflowctl_commands(command: str):
"""Test airflowctl commands using docker-compose environment."""
host_envs = os.environ.copy()
host_envs["AIRFLOW_CLI_DEBUG_MODE"] = "true"
- # Testing commands of airflowctl
- for command in test_commands:
- command_from_config = f"airflowctl {command}"
- # We need to run auth login first for all commands except login itself
- if command != login_command:
- run_command = f"airflowctl {login_command} && {command_from_config}"
- else:
- run_command = command_from_config
- console.print(f"[yellow]Running command: {command}")
-
- # Give some time for the command to execute and output to be ready
- proc = Popen(run_command.encode(), stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True, env=host_envs)
- stdout_result, stderr_result = proc.communicate(timeout=60)
-
- # CLI command gave errors
- if stderr_result:
- console.print(
- f"[red]Errors while executing command '{command_from_config}':\n{stderr_result.decode()}"
- )
-
- # Decode the output
- stdout_result = stdout_result.decode()
- # We need to trim auth login output if the command is not login itself and clean backspaces
- if command != login_command:
- if login_output not in stdout_result:
- console.print(
- f"[red]❌ Login output not found before command output for '{command_from_config}'"
- )
- console.print(f"[red]Full output:\n{stdout_result}\n")
- raise AssertionError("Login output not found before command output")
- stdout_result = stdout_result.split(f"{login_output}\n")[1].strip()
- else:
- stdout_result = stdout_result.strip()
-
- # This is a common error message that is thrown by client when something is wrong
- # Please ensure it is aligning with airflowctl.api.client.get_json_error
- airflowctl_client_server_response_error = "Server error"
- airflowctl_command_error = "command error: argument GROUP_OR_COMMAND: invalid choice"
- if (
- airflowctl_client_server_response_error in stdout_result
- or airflowctl_command_error in stdout_result
- ):
- console.print(f"[red]❌ Output contained unexpected text for command '{command_from_config}'")
- console.print(f"[red]Did not expect to find:\n{airflowctl_client_server_response_error}\n")
- console.print(f"[red]But got:\n{stdout_result}\n")
- raise AssertionError(f"Output contained unexpected text\nOutput:\n{stdout_result}")
- console.print(f"[green]✅ Output did not contain unexpected text for command '{command_from_config}'")
- console.print(f"[cyan]Result:\n{stdout_result}\n")
- proc.kill()
+
+ command_from_config = f"airflowctl {command}"
+ # We need to run auth login first for all commands except login itself
+ if command != LOGIN_COMMAND:
+ run_command = f"airflowctl {LOGIN_COMMAND} && {command_from_config}"
+ else:
+ run_command = command_from_config
+ console.print(f"[yellow]Running command: {command}")
+
+ # Give some time for the command to execute and output to be ready
+ proc = Popen(run_command.encode(), stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True, env=host_envs)
+ stdout_bytes, stderr_result = proc.communicate(timeout=60)
+
+ # CLI command gave errors
+ assert not stderr_result, (
+ f"Errors while executing command '{command_from_config}':\n{stderr_result.decode()}"
+ )
+
+ # Decode the output
+ stdout_result = stdout_bytes.decode()
+ # We need to trim auth login output if the command is not login itself and clean backspaces
+ if command != LOGIN_COMMAND:
+ assert LOGIN_OUTPUT in stdout_result, (
+ f"❌ Login output not found before command output for '{command_from_config}'",
+ f"\nFull output:\n{stdout_result}",
+ )
+ stdout_result = stdout_result.split(f"{LOGIN_OUTPUT}\n")[1].strip()
+ else:
+ stdout_result = stdout_result.strip()
+
+ # Check for non-zero exit code
+ assert proc.returncode == 0, (
+ f"❌ Command '{command_from_config}' exited with code {proc.returncode}",
+ f"\nOutput:\n{stdout_result}",
+ )
+
+ # Error patterns to detect failures that might otherwise slip through
+ # Please ensure it is aligning with airflowctl.api.client.get_json_error
+ error_patterns = [
+ "Server error",
+ "command error",
+ "unrecognized arguments",
+ "invalid choice",
+ "Traceback (most recent call last):",
+ ]
+ matched_error = next((error for error in error_patterns if error in stdout_result), None)
+ assert not matched_error, (
+ f"❌ Output contained unexpected text for command '{command_from_config}'",
+ f"\nMatched error pattern: {matched_error}",
+ f"\nOutput:\n{stdout_result}",
+ )
+
+ console.print(f"[green]✅ Output did not contain unexpected text for command '{command_from_config}'")
+ console.print(f"[cyan]Result:\n{stdout_result}\n")
+ proc.kill()
diff --git a/airflow-ctl/.pre-commit-config.yaml b/airflow-ctl/.pre-commit-config.yaml
index 8926b9e7336..9fb5fda0170 100644
--- a/airflow-ctl/.pre-commit-config.yaml
+++ b/airflow-ctl/.pre-commit-config.yaml
@@ -53,3 +53,13 @@ repos:
^src/airflowctl/ctl/cli_config.py$|
^src/airflowctl/api/operations.py$|
^src/airflowctl/ctl/commands/.*\.py$
+ - id: check-airflowctl-command-coverage
+ name: Check airflowctl CLI command test coverage
+ entry: ../scripts/ci/prek/check_airflowctl_command_coverage.py
+ language: python
+ pass_filenames: false
+ files:
+ (?x)
+ ^src/airflowctl/api/operations.py$|
+ ^../airflow-ctl-tests/tests/airflowctl_tests/conftest.py$|
+ ^../scripts/ci/prek/check_airflowctl_command_coverage.py$
diff --git a/airflow-ctl/NOTICE b/airflow-ctl/NOTICE
index e02aab0589f..a51bd9390d0 100644
--- a/airflow-ctl/NOTICE
+++ b/airflow-ctl/NOTICE
@@ -1,5 +1,5 @@
Apache Airflow
-Copyright 2016-2025 The Apache Software Foundation
+Copyright 2016-2026 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
diff --git a/airflow-ctl/RELEASE_NOTES.rst b/airflow-ctl/RELEASE_NOTES.rst
index 70d19556a9f..86b4c69c78b 100644
--- a/airflow-ctl/RELEASE_NOTES.rst
+++ b/airflow-ctl/RELEASE_NOTES.rst
@@ -15,6 +15,37 @@
specific language governing permissions and limitations
under the License.
+airflowctl 0.1.1 (2026-01-09)
+-----------------------------
+
+Significant Changes
+^^^^^^^^^^^^^^^^^^^
+
+- Make pause/unpause commands positional for improved CLI consistency (#59936)
+- Remove deprecated export functionality from airflowctl (#59850)
+- Add ``team_name`` to connection commands (#59336)
+- Add ``team_id`` to variable commands (#57102)
+- Add pre-commit checks for airflowctl test coverage (#58856)
+- Display active DAG run count in header with auto-refresh support (#58332)
+
+Bug Fixes
+^^^^^^^^^
+
+- Simplify airflowctl exception handling in ``safe_call_command`` (#59808)
+- Fix ``backfill`` default behavior for ``run_on_latest_version`` (#59304)
+- Update ``BulkDeleteAction`` to use generic typing (#59207)
+- Bump minimum supported ``prek`` version to 0.2.0 (#58952)
+- Fix RST formatting to ensure blank lines before bullet lists (#58760)
+- Update Python compatibility requirements and airflowctl documentation (#58653)
+- Consistently exclude unsupported Python 3.14 (#58657)
+- Improve cross-distribution dependency management (#58430)
+- Synchronize documentation between official and convenience source installs (#58379)
+- Add retry multiplier support (#56866)
+- Fix documentation issues for installing from source distributions (#58366)
+- Update ``pyproject.toml`` files to support ``pytest>=9.0.0`` TOML syntax (#58182)
+
+
+
airflowctl 0.1.0 (2025-11-05)
-----------------------------
diff --git a/airflow-ctl/airflow-ctl/.gitignore b/airflow-ctl/airflow-ctl/.gitignore
new file mode 100644
index 00000000000..fa10cdcc25f
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/.gitignore
@@ -0,0 +1 @@
+./test_command.py
diff --git a/airflow-ctl/.pre-commit-config.yaml b/airflow-ctl/airflow-ctl/.pre-commit-config.yaml
similarity index 98%
copy from airflow-ctl/.pre-commit-config.yaml
copy to airflow-ctl/airflow-ctl/.pre-commit-config.yaml
index 8926b9e7336..00eada7f645 100644
--- a/airflow-ctl/.pre-commit-config.yaml
+++ b/airflow-ctl/airflow-ctl/.pre-commit-config.yaml
@@ -16,7 +16,7 @@
# under the License.
---
default_stages: [pre-commit, pre-push]
-minimum_prek_version: '0.2.0'
+minimum_prek_version: '0.0.28'
default_language_version:
python: python3
node: 22.19.0
diff --git a/airflow-ctl/airflow-ctl/README.md b/airflow-ctl/airflow-ctl/README.md
new file mode 100644
index 00000000000..28f1b473812
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/README.md
@@ -0,0 +1,69 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# airflowctl
+
+A command-line tool for interacting with Apache Airflow instances through the Airflow REST API. It offers a convenient interface for performing common operations remotely without direct access to the Airflow scheduler or webserver.
+
+## Features
+
+- Communicates with Airflow instances through the REST API
+- Supports authentication using Airflow API tokens
+- Executes commands against remote Airflow deployments
+- Provides intuitive command organization with group-based structure
+- Includes detailed help documentation for all commands
+
+## Requirements
+
+- Python 3.10 or later (compatible with Python >= 3.10 and < 3.13)
+- Network access to an Apache Airflow instance with REST API enabled
+- Keyring backend installed in operating system for secure token storage
+
+## Usage
+
+Access the tool from your terminal:
+
+### Command Line
+
+```bash
+airflowctl --help
+```
+
+## Contributing
+
+Want to help improve Apache Airflow? Check out our [contributing documentation](https://github.com/apache/airflow/blob/main/contributing-docs/README.rst).
+
+### Additional Contribution Guidelines
+
+- Please ensure API is running while doing development testing.
+- There are two ways to have a CLI command,
+ - Auto Generated Commands
+ - Implemented Commands
+
+#### Auto Generated Commands
+
+Auto generation of commands directly from operations methods under `airflow-ctl/src/airflowctl/api/operations.py`.
+Whenever operation is mapped with proper datamodel and response model, it will be automatically added to the command.
+
+You can check each command with `airflowctl <command> --help` to see the available options.
+
+#### Implemented Commands
+
+Implemented commands are the ones which are not auto generated and need to be implemented manually.
+You can check the implemented commands under `airflow-ctl/src/airflowctl/clt/commands/`.
diff --git a/airflow-ctl/RELEASE_NOTES.rst b/airflow-ctl/airflow-ctl/RELEASE_NOTES.rst
similarity index 90%
copy from airflow-ctl/RELEASE_NOTES.rst
copy to airflow-ctl/airflow-ctl/RELEASE_NOTES.rst
index 70d19556a9f..58f1ecbeacb 100644
--- a/airflow-ctl/RELEASE_NOTES.rst
+++ b/airflow-ctl/airflow-ctl/RELEASE_NOTES.rst
@@ -15,10 +15,10 @@
specific language governing permissions and limitations
under the License.
-airflowctl 0.1.0 (2025-11-05)
+airflowctl 0.1.0 (2025-11-03)
-----------------------------
-Release of airflowctl, a command-line tool. There are lots of great features to use from start.
+Beta Release of airflowctl, a command-line tool. There are lots of great features to use from start.
Please check the documentation for quick start and usage instructions.
Please visit quick start guide: :doc:`/start`
diff --git a/airflow-ctl/RELEASE_NOTES.rst b/airflow-ctl/airflow-ctl/docs/changelog.rst
similarity index 62%
copy from airflow-ctl/RELEASE_NOTES.rst
copy to airflow-ctl/airflow-ctl/docs/changelog.rst
index 70d19556a9f..2285ff8e6c3 100644
--- a/airflow-ctl/RELEASE_NOTES.rst
+++ b/airflow-ctl/airflow-ctl/docs/changelog.rst
@@ -15,13 +15,17 @@
specific language governing permissions and limitations
under the License.
-airflowctl 0.1.0 (2025-11-05)
------------------------------
-Release of airflowctl, a command-line tool. There are lots of great features to use from start.
-Please check the documentation for quick start and usage instructions.
+.. NOTE TO CONTRIBUTORS:
+ Please, only add notes to the Changelog just below the "Changelog" header when there are some breaking changes
+ and you want to add an explanation to the users on how they are supposed to deal with them.
+ The changelog is updated and maintained semi-automatically by release manager.
-Please visit quick start guide: :doc:`/start`
+``apache-airflow-ctl``
-A new way of using Apache Airflow using CLI. Enhanced security is provided by using the Apache Airflow API to provide similar functionality to the Apache Airflow CLI.
-Integrated with Keyring to enhance password security.
+Changelog
+---------
+
+0.1.0
+.....
+Initial version of the airflowctl.
diff --git a/airflow-ctl/airflow-ctl/docs/cli-and-env-variables-ref.rst b/airflow-ctl/airflow-ctl/docs/cli-and-env-variables-ref.rst
new file mode 100644
index 00000000000..2d63d47d5be
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/cli-and-env-variables-ref.rst
@@ -0,0 +1,62 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+Command Line Interface and Environment Variables Reference
+==========================================================
+
+CLI
+'''
+
+airflowctl has a very rich command line interface that allows for
+many types of operation on a Dag, starting services, and supporting
+development and testing.
+
+.. note::
+ For more information on usage CLI, see :doc:`cli-and-env-variables-ref`
+
+.. contents:: Content
+ :local:
+ :depth: 2
+
+.. argparse::
+ :module: airflowctl.ctl.cli_parser
+ :func: get_parser
+ :prog: airflowctl
+
+Environment Variables
+'''''''''''''''''''''
+
+.. envvar:: AIRFLOW_CLI_TOKEN
+
+ The token used to authenticate with the Airflow API. This is only
+ required if you are using the Airflow API and have not set up
+ authentication using a different method. If username and password hasn't been used.
+
+.. envvar:: AIRFLOW_CLI_ENVIRONMENT
+
+ Environment name to use for the CLI. This is used to determine
+ which environment to use when running the CLI. This is only
+ required if you have multiple environments set up and want to
+ specify which one to use. If not set, the default environment
+ will be used which is production.
+
+.. envvar:: AIRFLOW_CLI_DEBUG_MODE
+
+ This variable can be used to enable debug mode for the CLI.
+ It disables some features such as keyring integration and save credentials to file.
+ It is only meant to use if either you are developing airflowctl or running API integration tests.
+ Please do not use this variable unless you know what you are doing.
diff --git a/airflow-ctl/airflow-ctl/docs/conf.py b/airflow-ctl/airflow-ctl/docs/conf.py
new file mode 100644
index 00000000000..08f867ee43f
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/conf.py
@@ -0,0 +1,297 @@
+# Disable Flake8 because of all the sphinx imports
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Configuration of Providers docs building."""
+
+from __future__ import annotations
+
+import logging
+import os
+import pathlib
+import re
+from typing import Any
+
+from docs.utils.conf_constants import (
+ AIRFLOW_CTL_DOC_STATIC_PATH,
+ AIRFLOW_CTL_SRC_PATH,
+ AIRFLOW_FAVICON_PATH,
+ AUTOAPI_OPTIONS,
+ BASIC_AUTOAPI_IGNORE_PATTERNS,
+ BASIC_SPHINX_EXTENSIONS,
+ REDOC_SCRIPT_URL,
+ SMARTQUOTES_EXCLUDES,
+ SPELLING_WORDLIST_PATH,
+ SPHINX_DESIGN_STATIC_PATH,
+ SPHINX_REDOC_EXTENSIONS,
+ SUPPRESS_WARNINGS,
+ filter_autoapi_ignore_entries,
+ get_autodoc_mock_imports,
+ get_configs_and_deprecations,
+ get_google_intersphinx_mapping,
+ get_html_context,
+ get_html_sidebars,
+ get_html_theme_options,
+ get_intersphinx_mapping,
+ get_rst_epilogue,
+ get_rst_filepath_from_path,
+ skip_util_classes_extension,
+)
+from packaging.version import Version, parse as parse_version
+
+import airflowctl
+from airflow.configuration import retrieve_configuration_description
+
+PACKAGE_NAME = "apache-airflow-ctl"
+PACKAGE_VERSION = airflowctl.__version__
+SYSTEM_TESTS_DIR: pathlib.Path | None
+# SYSTEM_TESTS_DIR = AIRFLOW_REPO_ROOT_PATH / "airflow-ctl" / "tests" / "system" / "core"
+
+os.environ["AIRFLOW_PACKAGE_NAME"] = PACKAGE_NAME
+
+# Disable color output for documentation generation
+os.environ["NO_COLOR"] = "1"
+
+# Hack to allow changing for piece of the code to behave differently while
+# the docs are being built. The main objective was to alter the
+# behavior of the utils.apply_default that was hiding function headers
+os.environ["BUILDING_AIRFLOW_DOCS"] = "TRUE"
+
+# General information about the project.
+project = PACKAGE_NAME
+# # The version info for the project you're documenting
+version = PACKAGE_VERSION
+# The full version, including alpha/beta/rc tags.
+release = PACKAGE_VERSION
+
+rst_epilog = get_rst_epilogue(PACKAGE_VERSION, False)
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+smartquotes_excludes = SMARTQUOTES_EXCLUDES
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = BASIC_SPHINX_EXTENSIONS
+
+# -- Options for sphinxcontrib.redoc -------------------------------------------
+# See: https://sphinxcontrib-redoc.readthedocs.io/en/stable/
+
+extensions.extend(SPHINX_REDOC_EXTENSIONS)
+redoc_script_url = REDOC_SCRIPT_URL
+
+extensions.extend(
+ [
+ "autoapi.extension",
+ "sphinx_jinja",
+ "sphinx.ext.graphviz",
+ "sphinxcontrib.httpdomain",
+ "extra_files_with_substitutions",
+ ]
+)
+
+exclude_patterns = [
+ # We only link to selected subpackages.
+ "_api/airflowctl/index.rst",
+ "_api/airflowctl/api/*",
+ "_api/airflowctl/api/datamodels/*",
+ "_api/airflowctl/ctl/*",
+ "_api/airflowctl/exceptions/index.rst",
+ "_api/airflowctl/utils/*",
+ "README.rst",
+]
+
+# Exclude top-level packages
+# do not exclude these top-level modules from the doc build:
+ALLOWED_TOP_LEVEL_FILES = ("exceptions.py",)
+
+
+def add_airflow_ctl_exclude_patterns_to_sphinx(exclude_patterns: list[str]):
+ """
+ Add excluded files to Sphinx exclude patterns.
+
+ Excludes all files from autoapi except the ones we want to allow.
+
+ :param root: The root directory of the package.
+ :param allowed_top_level_files: Tuple of allowed top-level files.
+ :param browsable_packages: Set of browsable packages.
+ :param browsable_utils: Set of browsable utils.
+ :param models_included: Set of included models.
+ """
+ # first - excluded everything that is not allowed or browsable
+ root = AIRFLOW_CTL_SRC_PATH / "airflowctl"
+ for path in root.iterdir():
+ if path.is_file() and path.name not in ALLOWED_TOP_LEVEL_FILES:
+ exclude_patterns.append(get_rst_filepath_from_path(path, root.parent))
+ print(f"Excluding {path} from Sphinx docs")
+
+
+add_airflow_ctl_exclude_patterns_to_sphinx(exclude_patterns)
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ["templates"]
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+keep_warnings = True
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+html_theme = "sphinx_airflow_theme"
+
+html_title = "airflowctl Documentation"
+
+# A shorter title for the navigation bar. Default is the same as html_title.
+html_short_title = ""
+
+# given, this must be the name of an image file that is the favicon of the docs
+html_favicon = AIRFLOW_FAVICON_PATH.as_posix()
+
+# Custom static files (such as style sheets) here,
+html_static_path = [AIRFLOW_CTL_DOC_STATIC_PATH.as_posix(), SPHINX_DESIGN_STATIC_PATH.as_posix()]
+
+# A list of JavaScript filenames.
+html_js_files = ["gh-jira-links.js", "redirects.js"]
+
+# Substitute in links
+manual_substitutions_in_generated_html = [
+ "installation/installing-from-pypi.html",
+ "installation/installing-from-sources.html",
+ "installation/prerequisites.html",
+]
+
+html_css_files = ["custom.css"]
+
+html_sidebars = get_html_sidebars(PACKAGE_VERSION)
+
+# If false, no index is generated.
+html_use_index = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+html_show_copyright = False
+
+# html theme options
+html_theme_options: dict[str, Any] = get_html_theme_options()
+
+conf_py_path = "/airflow-ctl/docs/"
+# A dictionary of values to pass into the template engine's context for all pages.
+html_context = get_html_context(conf_py_path)
+
+# -- Options for sphinx_jinja ------------------------------------------
+# See: https://github.com/tardyp/sphinx-jinja
+airflowctl_version: Version = parse_version(
+ re.search( # type: ignore[union-attr,arg-type]
+ r"__version__ = \"([0-9.]*)(\.dev[0-9]*|b[0-9])?\"",
+ (AIRFLOW_CTL_SRC_PATH / "airflowctl" / "__init__.py").read_text(),
+ ).groups(0)[0]
+)
+
+
+config_descriptions = retrieve_configuration_description(include_providers=False)
+configs, deprecated_options = get_configs_and_deprecations(airflowctl_version, config_descriptions)
+
+jinja_contexts = {
+ "config_ctx": {"configs": configs, "deprecated_options": deprecated_options},
+ "quick_start_ctx": {"doc_root_url": f"https://airflow.apache.org/docs/apache-airflow/{PACKAGE_VERSION}/"},
+ "official_download_page": {
+ "base_url": f"https://downloads.apache.org/airflow/{PACKAGE_VERSION}",
+ "closer_lua_url": f"https://www.apache.org/dyn/closer.lua/airflow/{PACKAGE_VERSION}",
+ "airflow_version": PACKAGE_VERSION,
+ },
+}
+
+# -- Options for sphinx.ext.autodoc --------------------------------------------
+# See: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
+
+# This value contains a list of modules to be mocked up. This is useful when some external dependencies
+# are not met at build time and break the building process.
+autodoc_mock_imports = get_autodoc_mock_imports()
+# The default options for autodoc directives. They are applied to all autodoc directives automatically.
+autodoc_default_options = {"show-inheritance": True, "members": True}
+
+autodoc_typehints = "description"
+autodoc_typehints_description_target = "documented"
+autodoc_typehints_format = "short"
+
+
+# -- Options for sphinx.ext.intersphinx ----------------------------------------
+# See: https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html
+
+# This config value contains names of other projects that should
+# be linked to in this documentation.
+# Inventories are only downloaded once by exts/docs_build/fetch_inventories.py.
+intersphinx_mapping = get_intersphinx_mapping()
+intersphinx_mapping.update(get_google_intersphinx_mapping())
+
+# -- Options for sphinx.ext.viewcode -------------------------------------------
+# See: https://www.sphinx-doc.org/es/master/usage/extensions/viewcode.html
+
+# If this is True, viewcode extension will emit viewcode-follow-imported event to resolve the name of
+# the module by other extensions. The default is True.
+viewcode_follow_imported_members = True
+
+# -- Options for sphinx-autoapi ------------------------------------------------
+# See: https://sphinx-autoapi.readthedocs.io/en/latest/config.html
+
+# your API documentation from.
+autoapi_dirs = [AIRFLOW_CTL_SRC_PATH.as_posix()]
+
+# A directory that has user-defined templates to override our default templates.
+autoapi_template_dir = "autoapi_templates"
+
+# A list of patterns to ignore when finding files
+autoapi_ignore = BASIC_AUTOAPI_IGNORE_PATTERNS
+
+# filter logging
+autoapi_log = logging.getLogger("sphinx.autoapi.mappers.base")
+autoapi_log.addFilter(filter_autoapi_ignore_entries)
+
+# Keep the AutoAPI generated files on the filesystem after the run.
+# Useful for debugging.
+autoapi_keep_files = True
+
+# Relative path to output the AutoAPI files into. This can also be used to place the generated documentation
+# anywhere in your documentation hierarchy.
+autoapi_root = "_api"
+
+# Whether to insert the generated documentation into the TOC tree. If this is False, the default AutoAPI
+# index page is not generated and you will need to include the generated documentation in a
+# TOC tree entry yourself.
+autoapi_add_toctree_entry = False
+
+# By default autoapi will include private members -- we don't want that!
+autoapi_options = AUTOAPI_OPTIONS
+
+suppress_warnings = SUPPRESS_WARNINGS
+
+# -- Options for ext.exampleinclude --------------------------------------------
+exampleinclude_sourceroot = os.path.abspath("..")
+
+# -- Options for ext.redirects -------------------------------------------------
+redirects_file = "redirects.txt"
+
+# -- Options for sphinxcontrib-spelling ----------------------------------------
+spelling_word_list_filename = [SPELLING_WORDLIST_PATH.as_posix()]
+spelling_exclude_patterns = ["project.rst", "changelog.rst"]
+
+spelling_ignore_contributor_names = False
+spelling_ignore_importable_modules = True
+
+graphviz_output_format = "svg"
+
+
+def setup(sphinx):
+ sphinx.connect("autoapi-skip-member", skip_util_classes_extension)
diff --git a/airflow-ctl/airflow-ctl/docs/howto/index.rst b/airflow-ctl/airflow-ctl/docs/howto/index.rst
new file mode 100644
index 00000000000..12946aed449
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/howto/index.rst
@@ -0,0 +1,222 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+
+
+How-to Guides
+=============
+
+Setting up the sandbox in the :doc:`/start` section was easy;
+building a production-grade environment requires a bit more work!
+
+These how-to guides will step you through common tasks in using and
+configuring an airflowctl environment.
+
+
+How to use airflowctl
+---------------------
+
+**Important Note**
+''''''''''''''''''
+airflowctl needs the Airflow API running to be able to work. Please, see the login section below before use.
+Otherwise, you may get errors.
+
+Datetime Usage
+''''''''''''''
+For datetime parameters, date should be timezone aware and in ISO format.
+For example: ``2025-10-10T10:00:00+00:00``
+Let's take example of triggering a DAG run with a logical date, run after and a note.
+
+.. code-block:: bash
+
+ airflowctl dagrun trigger --dag-id="example_bash_operator" --logical-date="2025-09-06T00:00:00+00:00" --run-after="2025-09-06T00:00:00+00:00" --note="Triggered from airflowctl"
+
+
+Login
+'''''
+airflowctl needs to be able to connect to the Airflow API. You should pass API URL as a parameter to the command
+``--api-url``. The URL should be in the form of ``http(s)://<host>:<port>``.
+You can also set the environment variable ``AIRFLOW_CLI_TOKEN`` to the token to use for authentication.
+
+There are two ways to authenticate with the Airflow API:
+1. Using a token acquired from the Airflow API
+
+.. code-block:: bash
+
+ airflowctl auth login --api-url <api_url> --api-token <token> --env <env_name:production>
+
+2. Using a username and password
+
+
+.. code-block:: bash
+
+ airflowctl auth login --api-url <api_url> --username <username> --password <password> --env <env_name:production>
+
+3. (optional) Using a token acquired from the Airflow API and username and password
+
+.. code-block:: bash
+
+ export AIRFLOW_CLI_TOKEN=<token>
+ airflowctl auth login --api-url <api_url> --env <env_name>
+
+In both cases token is securely stored in the keyring backend. Only configuration persisted in ``~/.config/airflow`` file
+is the API URL and the environment name. The token is stored in the keyring backend and is not persisted in the
+configuration file. The keyring backend is used to securely store the token and is not accessible to the user.
+
+What is authentication for ``airflowctl``?
+``````````````````````````````````````````
+For ``airflowctl`` to be able to communicate with the Airflow API, it needs to authenticate itself and acquire token.
+This is done using either a token or a username and password.
+The token can be acquired from the Airflow API or generated using a username and password.
+
+.. image:: ../images/diagrams/airflowctl_api_network_architecture_diagram.png
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.png
+ :width: 100%
+ :alt: airflowctl Auth Login Help
+
+Parameter Details for airflowctl auth login
+```````````````````````````````````````````
+
+**--api-url**: This parameter is required. (e.g. ``http://localhost:8080``)
+The default value is ``http://localhost:8080``. Full URL of the Airflow API. Without any ``/api/*`` suffixes.
+If you are running the ``airflowctl`` in ``breeze`` container, it is optional.
+
+**--api-token**: This parameter is optional.
+If you are setting the token via the environment variable ``AIRFLOW_CLI_TOKEN``, you can skip using this parameter.
+
+**--username**: This parameter is optional.
+If you are not using ``--api-token`` or the environment variable ``AIRFLOW_CLI_TOKEN``, you must provide a username to authentication along with ``--password``.
+
+**--password**: This parameter is optional.
+If you provide a username via ``--username`` this is the required password to authenticate.
+
+**--env**: This parameter is optional.
+The name of the environment to create or update. The default value is ``production``.
+This parameter is useful when you want to manage multiple Airflow environments.
+
+More Usage and Help Pictures
+''''''''''''''''''''''''''''
+For more information use
+
+.. code-block:: bash
+
+ airflowctl auth login --help
+
+.. image:: ../images/output_auth_login.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_auth_login.svg
+ :width: 60%
+ :alt: airflowctl Auth Login Help
+
+You are ready to use airflowctl now.
+Please, also see :doc:`/cli-and-env-variables-ref` for the list of available commands and options.
+
+You can use the command ``airflowctl --help`` to see the list of available commands.
+
+.. image:: ../images/output_main.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_main.svg
+ :width: 60%
+ :alt: airflowctl Help
+
+All Available Group Command References
+--------------------------------------
+
+Below are the command reference diagrams for all available commands in airflowctl.
+These visual references show the full command syntax, options, and parameters for each command.
+
+**Assets**
+''''''''''
+.. image:: ../images/output_assets.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_assets.svg
+ :width: 60%
+ :alt: airflowctl Assets Command
+
+**Auth**
+''''''''
+.. image:: ../images/output_auth.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_auth.svg
+ :width: 60%
+ :alt: airflowctl Auth Command
+
+**Backfill**
+'''''''''''''
+.. image:: ../images/output_backfill.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_backfill.svg
+ :width: 60%
+ :alt: airflowctl Backfills Command
+
+**Config**
+''''''''''
+.. image:: ../images/output_config.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_config.svg
+ :width: 60%
+ :alt: airflowctl Config Command
+
+**Connections**
+'''''''''''''''
+.. image:: ../images/output_connections.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_connections.svg
+ :width: 60%
+ :alt: airflowctl Connections Command
+
+**Dags**
+''''''''
+.. image:: ../images/output_dags.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_dags.svg
+ :width: 60%
+ :alt: airflowctl Dag Command
+
+**Dag Runs**
+''''''''''''
+.. image:: ../images/output_dagrun.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_dagrun.svg
+ :width: 60%
+ :alt: airflowctl Dag Run Command
+
+**Jobs**
+''''''''
+.. image:: ../images/output_jobs.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_jobs.svg
+ :width: 60%
+ :alt: airflowctl Jobs Command
+
+**Pools**
+'''''''''
+.. image:: ../images/output_pools.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_pools.svg
+ :width: 60%
+ :alt: airflowctl Pools Command
+
+**Providers**
+'''''''''''''
+.. image:: ../images/output_providers.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_providers.svg
+ :width: 60%
+ :alt: airflowctl Providers Command
+
+**Variables**
+'''''''''''''
+.. image:: ../images/output_variables.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_variables.svg
+ :width: 60%
+ :alt: airflowctl Variables Command
+
+**Version**
+'''''''''''
+.. image:: ../images/output_version.svg
+ :target: https://raw.githubusercontent.com/apache/airflow/main/airflow-ctl/docs/images/output_version.svg
+ :width: 60%
+ :alt: airflowctl Version Command
diff --git a/airflow-ctl/docs/images/command_hashes.txt b/airflow-ctl/airflow-ctl/docs/images/command_hashes.txt
similarity index 100%
copy from airflow-ctl/docs/images/command_hashes.txt
copy to airflow-ctl/airflow-ctl/docs/images/command_hashes.txt
diff --git a/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.md5sum b/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.md5sum
new file mode 100644
index 00000000000..d3ecab853e6
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.md5sum
@@ -0,0 +1 @@
+ea755b0b93dd0524e7ed0ef42b83a888
diff --git a/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.png b/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.png
new file mode 100644
index 00000000000..b79bafcc618
Binary files /dev/null and b/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.png differ
diff --git a/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.py b/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.py
new file mode 100644
index 00000000000..0894fb955df
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/diagrams/airflowctl_api_network_architecture_diagram.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from pathlib import Path
+
+from diagrams import Cluster, Diagram, Edge
+from diagrams.onprem.client import User
+from diagrams.onprem.compute import Server
+from rich.console import Console
+
+MY_DIR = Path(__file__).parent
+MY_FILENAME = Path(__file__).with_suffix("").name
+
+console = Console(width=400, color_system="standard")
+
+graph_attr = {
+ "concentrate": "false",
+ "splines": "splines",
+}
+
+edge_attr = {
+ "minlen": "1",
+}
+
+
+def generate_airflowctl_api_network_diagram():
+ image_file = (MY_DIR / MY_FILENAME).with_suffix(".png")
+
+ console.print(f"[bright_blue]Generating network diagram {image_file}")
+ with Diagram(
+ name="airflowctl<->API Network Diagram",
+ show=False,
+ direction="LR",
+ filename=MY_FILENAME,
+ edge_attr=edge_attr,
+ graph_attr=graph_attr,
+ ):
+ # Machine network with client
+ with Cluster("Machine Network", graph_attr={"margin": "30", "width": "10"}):
+ client = User("Client\n(The machine/host has the airflowctl installed)")
+
+ # Airflow deployment network with API server
+ with Cluster("Apache Airflow Deployment Network", graph_attr={"margin": "30"}):
+ api_server = Server("Apache Airflow API Server\n(e.g. DNS: https://airflow.internal.api.com)")
+
+ # Edges representing the flows
+ (
+ client
+ >> Edge(
+ color="blue",
+ style="solid",
+ label="Login Request\n(if not manually used in --api-token or env var. Authentication done with username/password)",
+ )
+ >> api_server
+ )
+
+ (
+ api_server
+ >> Edge(
+ color="darkgreen",
+ style="solid",
+ label="Returns Token",
+ )
+ >> client
+ )
+
+ console.print(f"[green]Generated network diagram {image_file}")
+
+
+if __name__ == "__main__":
+ generate_airflowctl_api_network_diagram()
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_assets.svg b/airflow-ctl/airflow-ctl/docs/images/output_assets.svg
new file mode 100644
index 00000000000..0e2783bab8d
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_assets.svg
@@ -0,0 +1,165 @@
+<svg class="rich-terminal" viewBox="0 0 811 708.8" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-364338993-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-364338993-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-364338993-r1 { fill: #ff8700 }
+.terminal-364338993-r2 { fill: #c5c8c6 }
+.terminal-364338993-r3 { fill: #808080 }
+.terminal-364338993-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-364338993-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="657.8" />
+ </clipPath>
+ <clipPath id="terminal-364338993-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-12">
+ <rect x="0" y="294.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-13">
+ <rect x="0" y="318.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-14">
+ <rect x="0" y="343.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-15">
+ <rect x="0" y="367.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-16">
+ <rect x="0" y="391.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-17">
+ <rect x="0" y="416.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-18">
+ <rect x="0" y="440.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-19">
+ <rect x="0" y="465.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-20">
+ <rect x="0" y="489.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-21">
+ <rect x="0" y="513.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-22">
+ <rect x="0" y="538.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-23">
+ <rect x="0" y="562.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-24">
+ <rect x="0" y="587.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-364338993-line-25">
+ <rect x="0" y="611.5" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="706.8" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-364338993-clip-terminal)">
+
+ <g class="terminal-364338993-matrix">
+ <text class="terminal-364338993-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-364338993-line-0)">Usage:</text><text class="terminal-364338993-r3" x="85.4" y="20" textLength="207.4" clip-path="url(#terminal-364338993-line-0)">airflowctl assets</text><text class="terminal-364338993-r2" x="292.8" y="20" textLength="24.4" clip-path="url(#terminal-364338993-line-0)"> [</text><text class="terminal-364338993-r4" x="317.2" y="20" textLength="24.4" clip-path="url(#term [...]
+</text><text class="terminal-364338993-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-364338993-line-1)">
+</text><text class="terminal-364338993-r2" x="0" y="68.8" textLength="305" clip-path="url(#terminal-364338993-line-2)">Perform Assets operations</text><text class="terminal-364338993-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-364338993-line-2)">
+</text><text class="terminal-364338993-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-364338993-line-3)">
+</text><text class="terminal-364338993-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-364338993-line-4)">Positional Arguments:</text><text class="terminal-364338993-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-364338993-line-4)">
+</text><text class="terminal-364338993-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-364338993-line-5)">COMMAND</text><text class="terminal-364338993-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-364338993-line-5)">
+</text><text class="terminal-364338993-r4" x="48.8" y="166.4" textLength="146.4" clip-path="url(#terminal-364338993-line-6)">create-event</text><text class="terminal-364338993-r2" x="292.8" y="166.4" textLength="366" clip-path="url(#terminal-364338993-line-6)">Perform create_event operation</text><text class="terminal-364338993-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-364338993-line-6)">
+</text><text class="terminal-364338993-r4" x="48.8" y="190.8" textLength="292.8" clip-path="url(#terminal-364338993-line-7)">delete-dag-queued-events</text><text class="terminal-364338993-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-364338993-line-7)">
+</text><text class="terminal-364338993-r2" x="292.8" y="215.2" textLength="402.6" clip-path="url(#terminal-364338993-line-8)">Perform delete_dag_queued_events </text><text class="terminal-364338993-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-364338993-line-8)">
+</text><text class="terminal-364338993-r2" x="0" y="239.6" textLength="109.8" clip-path="url(#terminal-364338993-line-9)">operation</text><text class="terminal-364338993-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-364338993-line-9)">
+</text><text class="terminal-364338993-r4" x="48.8" y="264" textLength="231.8" clip-path="url(#terminal-364338993-line-10)">delete-queued-event</text><text class="terminal-364338993-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-364338993-line-10)">
+</text><text class="terminal-364338993-r2" x="292.8" y="288.4" textLength="451.4" clip-path="url(#terminal-364338993-line-11)">Perform delete_queued_event operation</text><text class="terminal-364338993-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-364338993-line-11)">
+</text><text class="terminal-364338993-r4" x="48.8" y="312.8" textLength="244" clip-path="url(#terminal-364338993-line-12)">delete-queued-events</text><text class="terminal-364338993-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-364338993-line-12)">
+</text><text class="terminal-364338993-r2" x="292.8" y="337.2" textLength="463.6" clip-path="url(#terminal-364338993-line-13)">Perform delete_queued_events operation</text><text class="terminal-364338993-r2" x="793" y="337.2" textLength="12.2" clip-path="url(#terminal-364338993-line-13)">
+</text><text class="terminal-364338993-r4" x="48.8" y="361.6" textLength="36.6" clip-path="url(#terminal-364338993-line-14)">get</text><text class="terminal-364338993-r2" x="292.8" y="361.6" textLength="256.2" clip-path="url(#terminal-364338993-line-14)">Perform get operation</text><text class="terminal-364338993-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-364338993-line-14)">
+</text><text class="terminal-364338993-r4" x="48.8" y="386" textLength="146.4" clip-path="url(#terminal-364338993-line-15)">get-by-alias</text><text class="terminal-364338993-r2" x="292.8" y="386" textLength="366" clip-path="url(#terminal-364338993-line-15)">Perform get_by_alias operation</text><text class="terminal-364338993-r2" x="793" y="386" textLength="12.2" clip-path="url(#terminal-364338993-line-15)">
+</text><text class="terminal-364338993-r4" x="48.8" y="410.4" textLength="244" clip-path="url(#terminal-364338993-line-16)">get-dag-queued-event</text><text class="terminal-364338993-r2" x="793" y="410.4" textLength="12.2" clip-path="url(#terminal-364338993-line-16)">
+</text><text class="terminal-364338993-r2" x="292.8" y="434.8" textLength="463.6" clip-path="url(#terminal-364338993-line-17)">Perform get_dag_queued_event operation</text><text class="terminal-364338993-r2" x="793" y="434.8" textLength="12.2" clip-path="url(#terminal-364338993-line-17)">
+</text><text class="terminal-364338993-r4" x="48.8" y="459.2" textLength="256.2" clip-path="url(#terminal-364338993-line-18)">get-dag-queued-events</text><text class="terminal-364338993-r2" x="793" y="459.2" textLength="12.2" clip-path="url(#terminal-364338993-line-18)">
+</text><text class="terminal-364338993-r2" x="292.8" y="483.6" textLength="475.8" clip-path="url(#terminal-364338993-line-19)">Perform get_dag_queued_events operation</text><text class="terminal-364338993-r2" x="793" y="483.6" textLength="12.2" clip-path="url(#terminal-364338993-line-19)">
+</text><text class="terminal-364338993-r4" x="48.8" y="508" textLength="207.4" clip-path="url(#terminal-364338993-line-20)">get-queued-events</text><text class="terminal-364338993-r2" x="292.8" y="508" textLength="427" clip-path="url(#terminal-364338993-line-20)">Perform get_queued_events operation</text><text class="terminal-364338993-r2" x="793" y="508" textLength="12.2" clip-path="url(#terminal-364338993-line-20)">
+</text><text class="terminal-364338993-r4" x="48.8" y="532.4" textLength="48.8" clip-path="url(#terminal-364338993-line-21)">list</text><text class="terminal-364338993-r2" x="292.8" y="532.4" textLength="268.4" clip-path="url(#terminal-364338993-line-21)">Perform list operation</text><text class="terminal-364338993-r2" x="793" y="532.4" textLength="12.2" clip-path="url(#terminal-364338993-line-21)">
+</text><text class="terminal-364338993-r4" x="48.8" y="556.8" textLength="158.6" clip-path="url(#terminal-364338993-line-22)">list-by-alias</text><text class="terminal-364338993-r2" x="292.8" y="556.8" textLength="378.2" clip-path="url(#terminal-364338993-line-22)">Perform list_by_alias operation</text><text class="terminal-364338993-r2" x="793" y="556.8" textLength="12.2" clip-path="url(#terminal-364338993-line-22)">
+</text><text class="terminal-364338993-r4" x="48.8" y="581.2" textLength="134.2" clip-path="url(#terminal-364338993-line-23)">materialize</text><text class="terminal-364338993-r2" x="292.8" y="581.2" textLength="353.8" clip-path="url(#terminal-364338993-line-23)">Perform materialize operation</text><text class="terminal-364338993-r2" x="793" y="581.2" textLength="12.2" clip-path="url(#terminal-364338993-line-23)">
+</text><text class="terminal-364338993-r2" x="793" y="605.6" textLength="12.2" clip-path="url(#terminal-364338993-line-24)">
+</text><text class="terminal-364338993-r1" x="0" y="630" textLength="97.6" clip-path="url(#terminal-364338993-line-25)">Options:</text><text class="terminal-364338993-r2" x="793" y="630" textLength="12.2" clip-path="url(#terminal-364338993-line-25)">
+</text><text class="terminal-364338993-r4" x="24.4" y="654.4" textLength="24.4" clip-path="url(#terminal-364338993-line-26)">-h</text><text class="terminal-364338993-r2" x="48.8" y="654.4" textLength="24.4" clip-path="url(#terminal-364338993-line-26)">, </text><text class="terminal-364338993-r4" x="73.2" y="654.4" textLength="73.2" clip-path="url(#terminal-364338993-line-26)">--help</text><text class="terminal-364338993-r2" x="292.8" y="654.4" textLength="378.2" clip-path="url(#term [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_auth.svg b/airflow-ctl/airflow-ctl/docs/images/output_auth.svg
new file mode 100644
index 00000000000..fc7f38acd3f
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_auth.svg
@@ -0,0 +1,105 @@
+<svg class="rich-terminal" viewBox="0 0 811 342.79999999999995" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-1537035387-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-1537035387-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-1537035387-r1 { fill: #ff8700 }
+.terminal-1537035387-r2 { fill: #c5c8c6 }
+.terminal-1537035387-r3 { fill: #808080 }
+.terminal-1537035387-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-1537035387-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="291.79999999999995" />
+ </clipPath>
+ <clipPath id="terminal-1537035387-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1537035387-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="340.8" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-1537035387-clip-terminal)">
+
+ <g class="terminal-1537035387-matrix">
+ <text class="terminal-1537035387-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-1537035387-line-0)">Usage:</text><text class="terminal-1537035387-r3" x="85.4" y="20" textLength="183" clip-path="url(#terminal-1537035387-line-0)">airflowctl auth</text><text class="terminal-1537035387-r2" x="268.4" y="20" textLength="24.4" clip-path="url(#terminal-1537035387-line-0)"> [</text><text class="terminal-1537035387-r4" x="292.8" y="20" textLength="24.4" clip-path="url(#t [...]
+</text><text class="terminal-1537035387-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-1537035387-line-1)">
+</text><text class="terminal-1537035387-r2" x="0" y="68.8" textLength="793" clip-path="url(#terminal-1537035387-line-2)">Manage authentication for CLI. Either pass token from environment</text><text class="terminal-1537035387-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-1537035387-line-2)">
+</text><text class="terminal-1537035387-r2" x="0" y="93.2" textLength="597.8" clip-path="url(#terminal-1537035387-line-3)">variable/parameter or pass username and password.</text><text class="terminal-1537035387-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-1537035387-line-3)">
+</text><text class="terminal-1537035387-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-1537035387-line-4)">
+</text><text class="terminal-1537035387-r1" x="0" y="142" textLength="256.2" clip-path="url(#terminal-1537035387-line-5)">Positional Arguments:</text><text class="terminal-1537035387-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-1537035387-line-5)">
+</text><text class="terminal-1537035387-r4" x="24.4" y="166.4" textLength="85.4" clip-path="url(#terminal-1537035387-line-6)">COMMAND</text><text class="terminal-1537035387-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-1537035387-line-6)">
+</text><text class="terminal-1537035387-r4" x="48.8" y="190.8" textLength="61" clip-path="url(#terminal-1537035387-line-7)">login</text><text class="terminal-1537035387-r2" x="170.8" y="190.8" textLength="622.2" clip-path="url(#terminal-1537035387-line-7)">Login to the metadata database for personal usage. </text><text class="terminal-1537035387-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-1537035387-line-7)">
+</text><text class="terminal-1537035387-r2" x="0" y="215.2" textLength="500.2" clip-path="url(#terminal-1537035387-line-8)">JWT Token must be provided via parameter.</text><text class="terminal-1537035387-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-1537035387-line-8)">
+</text><text class="terminal-1537035387-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-1537035387-line-9)">
+</text><text class="terminal-1537035387-r1" x="0" y="264" textLength="97.6" clip-path="url(#terminal-1537035387-line-10)">Options:</text><text class="terminal-1537035387-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-1537035387-line-10)">
+</text><text class="terminal-1537035387-r4" x="24.4" y="288.4" textLength="24.4" clip-path="url(#terminal-1537035387-line-11)">-h</text><text class="terminal-1537035387-r2" x="48.8" y="288.4" textLength="24.4" clip-path="url(#terminal-1537035387-line-11)">, </text><text class="terminal-1537035387-r4" x="73.2" y="288.4" textLength="73.2" clip-path="url(#terminal-1537035387-line-11)">--help</text><text class="terminal-1537035387-r2" x="170.8" y="288.4" textLength="378.2" clip-path="ur [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_auth_login.svg b/airflow-ctl/airflow-ctl/docs/images/output_auth_login.svg
new file mode 100644
index 00000000000..afa17cb9b56
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_auth_login.svg
@@ -0,0 +1,122 @@
+<svg class="rich-terminal" viewBox="0 0 811 440.4" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-2480777825-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-2480777825-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-2480777825-r1 { fill: #ff8700 }
+.terminal-2480777825-r2 { fill: #c5c8c6 }
+.terminal-2480777825-r3 { fill: #808080 }
+.terminal-2480777825-r4 { fill: #68a0b3 }
+.terminal-2480777825-r5 { fill: #00af87 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-2480777825-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="389.4" />
+ </clipPath>
+ <clipPath id="terminal-2480777825-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-12">
+ <rect x="0" y="294.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-13">
+ <rect x="0" y="318.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-2480777825-line-14">
+ <rect x="0" y="343.1" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="438.4" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-2480777825-clip-terminal)">
+
+ <g class="terminal-2480777825-matrix">
+ <text class="terminal-2480777825-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-2480777825-line-0)">Usage:</text><text class="terminal-2480777825-r3" x="85.4" y="20" textLength="256.2" clip-path="url(#terminal-2480777825-line-0)">airflowctl auth login</text><text class="terminal-2480777825-r2" x="341.6" y="20" textLength="24.4" clip-path="url(#terminal-2480777825-line-0)"> [</text><text class="terminal-2480777825-r4" x="366" y="20" textLength="24.4" clip-p [...]
+</text><text class="terminal-2480777825-r2" x="0" y="44.4" textLength="366" clip-path="url(#terminal-2480777825-line-1)">                             [</text><text class="terminal-2480777825-r4" x="366" y="44.4" textLength="109.8" clip-path="url(#terminal-2480777825-line-1)">--api-url</text><text class="terminal-2480777825-r5" x="488" y="44.4" t [...]
+</text><text class="terminal-2480777825-r2" x="0" y="68.8" textLength="366" clip-path="url(#terminal-2480777825-line-2)">                             [</text><text class="terminal-2480777825-r4" x="366" y="68.8" textLength="122" clip-path="url(#terminal-2480777825-line-2)">--password</text><text class="terminal-2480777825-r2" x="488" y="68.8" te [...]
+</text><text class="terminal-2480777825-r2" x="0" y="93.2" textLength="366" clip-path="url(#terminal-2480777825-line-3)">                             [</text><text class="terminal-2480777825-r4" x="366" y="93.2" textLength="122" clip-path="url(#terminal-2480777825-line-3)">--username</text><text class="terminal-2480777825-r5" x="500.2" y="93.2" [...]
+</text><text class="terminal-2480777825-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-2480777825-line-4)">
+</text><text class="terminal-2480777825-r2" x="0" y="142" textLength="366" clip-path="url(#terminal-2480777825-line-5)">Login to the metadata database</text><text class="terminal-2480777825-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-2480777825-line-5)">
+</text><text class="terminal-2480777825-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-2480777825-line-6)">
+</text><text class="terminal-2480777825-r1" x="0" y="190.8" textLength="97.6" clip-path="url(#terminal-2480777825-line-7)">Options:</text><text class="terminal-2480777825-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-2480777825-line-7)">
+</text><text class="terminal-2480777825-r4" x="24.4" y="215.2" textLength="24.4" clip-path="url(#terminal-2480777825-line-8)">-h</text><text class="terminal-2480777825-r2" x="48.8" y="215.2" textLength="24.4" clip-path="url(#terminal-2480777825-line-8)">, </text><text class="terminal-2480777825-r4" x="73.2" y="215.2" textLength="73.2" clip-path="url(#terminal-2480777825-line-8)">--help</text><text class="terminal-2480777825-r2" x="292.8" y="215.2" textLength="378.2" clip-path="url(# [...]
+</text><text class="terminal-2480777825-r4" x="24.4" y="239.6" textLength="134.2" clip-path="url(#terminal-2480777825-line-9)">--api-token</text><text class="terminal-2480777825-r5" x="170.8" y="239.6" textLength="109.8" clip-path="url(#terminal-2480777825-line-9)">API_TOKEN</text><text class="terminal-2480777825-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-2480777825-line-9)">
+</text><text class="terminal-2480777825-r2" x="292.8" y="264" textLength="427" clip-path="url(#terminal-2480777825-line-10)">The token to use for authentication</text><text class="terminal-2480777825-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-2480777825-line-10)">
+</text><text class="terminal-2480777825-r4" x="24.4" y="288.4" textLength="109.8" clip-path="url(#terminal-2480777825-line-11)">--api-url</text><text class="terminal-2480777825-r5" x="146.4" y="288.4" textLength="85.4" clip-path="url(#terminal-2480777825-line-11)">API_URL</text><text class="terminal-2480777825-r2" x="292.8" y="288.4" textLength="439.2" clip-path="url(#terminal-2480777825-line-11)">The URL of the metadata database API</text><text class="termi [...]
+</text><text class="terminal-2480777825-r4" x="24.4" y="312.8" textLength="24.4" clip-path="url(#terminal-2480777825-line-12)">-e</text><text class="terminal-2480777825-r2" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-2480777825-line-12)">, </text><text class="terminal-2480777825-r4" x="73.2" y="312.8" textLength="61" clip-path="url(#terminal-2480777825-line-12)">--env</text><text class="terminal-2480777825-r5" x="146.4" y="312.8" textLength="36.6" clip-path="url(#t [...]
+</text><text class="terminal-2480777825-r4" x="24.4" y="337.2" textLength="122" clip-path="url(#terminal-2480777825-line-13)">--password</text><text class="terminal-2480777825-r2" x="146.4" y="337.2" textLength="24.4" clip-path="url(#terminal-2480777825-line-13)"> [</text><text class="terminal-2480777825-r5" x="170.8" y="337.2" textLength="97.6" clip-path="url(#terminal-2480777825-line-13)">PASSWORD</text><text class="terminal-2480777825-r2" x="268.4" y="337.2" textLength="12.2" cli [...]
+</text><text class="terminal-2480777825-r2" x="292.8" y="361.6" textLength="463.6" clip-path="url(#terminal-2480777825-line-14)">The password to use for authentication</text><text class="terminal-2480777825-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-2480777825-line-14)">
+</text><text class="terminal-2480777825-r4" x="24.4" y="386" textLength="122" clip-path="url(#terminal-2480777825-line-15)">--username</text><text class="terminal-2480777825-r5" x="158.6" y="386" textLength="97.6" clip-path="url(#terminal-2480777825-line-15)">USERNAME</text><text class="terminal-2480777825-r2" x="292.8" y="386" textLength="463.6" clip-path="url(#terminal-2480777825-line-15)">The username to use for authentication</text><text class="terminal-24807 [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_backfill.svg b/airflow-ctl/airflow-ctl/docs/images/output_backfill.svg
new file mode 100644
index 00000000000..239a37ab8ea
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_backfill.svg
@@ -0,0 +1,125 @@
+<svg class="rich-terminal" viewBox="0 0 811 464.79999999999995" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-1327991341-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-1327991341-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-1327991341-r1 { fill: #ff8700 }
+.terminal-1327991341-r2 { fill: #c5c8c6 }
+.terminal-1327991341-r3 { fill: #808080 }
+.terminal-1327991341-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-1327991341-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="413.79999999999995" />
+ </clipPath>
+ <clipPath id="terminal-1327991341-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-12">
+ <rect x="0" y="294.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-13">
+ <rect x="0" y="318.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-14">
+ <rect x="0" y="343.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1327991341-line-15">
+ <rect x="0" y="367.5" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="462.8" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-1327991341-clip-terminal)">
+
+ <g class="terminal-1327991341-matrix">
+ <text class="terminal-1327991341-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-1327991341-line-0)">Usage:</text><text class="terminal-1327991341-r3" x="85.4" y="20" textLength="231.8" clip-path="url(#terminal-1327991341-line-0)">airflowctl backfill</text><text class="terminal-1327991341-r2" x="317.2" y="20" textLength="24.4" clip-path="url(#terminal-1327991341-line-0)"> [</text><text class="terminal-1327991341-r4" x="341.6" y="20" textLength="24.4" clip-path=" [...]
+</text><text class="terminal-1327991341-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-1327991341-line-1)">
+</text><text class="terminal-1327991341-r2" x="0" y="68.8" textLength="329.4" clip-path="url(#terminal-1327991341-line-2)">Perform Backfill operations</text><text class="terminal-1327991341-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-1327991341-line-2)">
+</text><text class="terminal-1327991341-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-1327991341-line-3)">
+</text><text class="terminal-1327991341-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-1327991341-line-4)">Positional Arguments:</text><text class="terminal-1327991341-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-1327991341-line-4)">
+</text><text class="terminal-1327991341-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-1327991341-line-5)">COMMAND</text><text class="terminal-1327991341-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-1327991341-line-5)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-1327991341-line-6)">cancel</text><text class="terminal-1327991341-r2" x="219.6" y="166.4" textLength="292.8" clip-path="url(#terminal-1327991341-line-6)">Perform cancel operation</text><text class="terminal-1327991341-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-1327991341-line-6)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="190.8" textLength="73.2" clip-path="url(#terminal-1327991341-line-7)">create</text><text class="terminal-1327991341-r2" x="219.6" y="190.8" textLength="292.8" clip-path="url(#terminal-1327991341-line-7)">Perform create operation</text><text class="terminal-1327991341-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-1327991341-line-7)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="215.2" textLength="170.8" clip-path="url(#terminal-1327991341-line-8)">create-dry-run</text><text class="terminal-1327991341-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-1327991341-line-8)">
+</text><text class="terminal-1327991341-r2" x="219.6" y="239.6" textLength="390.4" clip-path="url(#terminal-1327991341-line-9)">Perform create_dry_run operation</text><text class="terminal-1327991341-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-1327991341-line-9)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="264" textLength="36.6" clip-path="url(#terminal-1327991341-line-10)">get</text><text class="terminal-1327991341-r2" x="219.6" y="264" textLength="256.2" clip-path="url(#terminal-1327991341-line-10)">Perform get operation</text><text class="terminal-1327991341-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-1327991341-line-10)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="288.4" textLength="48.8" clip-path="url(#terminal-1327991341-line-11)">list</text><text class="terminal-1327991341-r2" x="219.6" y="288.4" textLength="268.4" clip-path="url(#terminal-1327991341-line-11)">Perform list operation</text><text class="terminal-1327991341-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-1327991341-line-11)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="312.8" textLength="61" clip-path="url(#terminal-1327991341-line-12)">pause</text><text class="terminal-1327991341-r2" x="219.6" y="312.8" textLength="280.6" clip-path="url(#terminal-1327991341-line-12)">Perform pause operation</text><text class="terminal-1327991341-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-1327991341-line-12)">
+</text><text class="terminal-1327991341-r4" x="48.8" y="337.2" textLength="85.4" clip-path="url(#terminal-1327991341-line-13)">unpause</text><text class="terminal-1327991341-r2" x="219.6" y="337.2" textLength="305" clip-path="url(#terminal-1327991341-line-13)">Perform unpause operation</text><text class="terminal-1327991341-r2" x="793" y="337.2" textLength="12.2" clip-path="url(#terminal-1327991341-line-13)">
+</text><text class="terminal-1327991341-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-1327991341-line-14)">
+</text><text class="terminal-1327991341-r1" x="0" y="386" textLength="97.6" clip-path="url(#terminal-1327991341-line-15)">Options:</text><text class="terminal-1327991341-r2" x="793" y="386" textLength="12.2" clip-path="url(#terminal-1327991341-line-15)">
+</text><text class="terminal-1327991341-r4" x="24.4" y="410.4" textLength="24.4" clip-path="url(#terminal-1327991341-line-16)">-h</text><text class="terminal-1327991341-r2" x="48.8" y="410.4" textLength="24.4" clip-path="url(#terminal-1327991341-line-16)">, </text><text class="terminal-1327991341-r4" x="73.2" y="410.4" textLength="73.2" clip-path="url(#terminal-1327991341-line-16)">--help</text><text class="terminal-1327991341-r2" x="219.6" y="410.4" textLength="378.2" clip-path="ur [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_config.svg b/airflow-ctl/airflow-ctl/docs/images/output_config.svg
new file mode 100644
index 00000000000..b2c917e8d3d
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_config.svg
@@ -0,0 +1,109 @@
+<svg class="rich-terminal" viewBox="0 0 811 367.2" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-3484718817-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-3484718817-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-3484718817-r1 { fill: #ff8700 }
+.terminal-3484718817-r2 { fill: #c5c8c6 }
+.terminal-3484718817-r3 { fill: #808080 }
+.terminal-3484718817-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-3484718817-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="316.2" />
+ </clipPath>
+ <clipPath id="terminal-3484718817-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3484718817-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="365.2" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-3484718817-clip-terminal)">
+
+ <g class="terminal-3484718817-matrix">
+ <text class="terminal-3484718817-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-3484718817-line-0)">Usage:</text><text class="terminal-3484718817-r3" x="85.4" y="20" textLength="207.4" clip-path="url(#terminal-3484718817-line-0)">airflowctl config</text><text class="terminal-3484718817-r2" x="292.8" y="20" textLength="24.4" clip-path="url(#terminal-3484718817-line-0)"> [</text><text class="terminal-3484718817-r4" x="317.2" y="20" textLength="24.4" clip-path="ur [...]
+</text><text class="terminal-3484718817-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-3484718817-line-1)">
+</text><text class="terminal-3484718817-r2" x="0" y="68.8" textLength="305" clip-path="url(#terminal-3484718817-line-2)">Perform Config operations</text><text class="terminal-3484718817-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-3484718817-line-2)">
+</text><text class="terminal-3484718817-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-3484718817-line-3)">
+</text><text class="terminal-3484718817-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-3484718817-line-4)">Positional Arguments:</text><text class="terminal-3484718817-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-3484718817-line-4)">
+</text><text class="terminal-3484718817-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-3484718817-line-5)">COMMAND</text><text class="terminal-3484718817-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-3484718817-line-5)">
+</text><text class="terminal-3484718817-r4" x="48.8" y="166.4" textLength="36.6" clip-path="url(#terminal-3484718817-line-6)">get</text><text class="terminal-3484718817-r2" x="170.8" y="166.4" textLength="256.2" clip-path="url(#terminal-3484718817-line-6)">Perform get operation</text><text class="terminal-3484718817-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-3484718817-line-6)">
+</text><text class="terminal-3484718817-r4" x="48.8" y="190.8" textLength="48.8" clip-path="url(#terminal-3484718817-line-7)">lint</text><text class="terminal-3484718817-r2" x="170.8" y="190.8" textLength="597.8" clip-path="url(#terminal-3484718817-line-7)">Lint options for the configuration changes while </text><text class="terminal-3484718817-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-3484718817-line-7)">
+</text><text class="terminal-3484718817-r2" x="0" y="215.2" textLength="451.4" clip-path="url(#terminal-3484718817-line-8)">migrating from Airflow 2 to Airflow 3</text><text class="terminal-3484718817-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-3484718817-line-8)">
+</text><text class="terminal-3484718817-r4" x="48.8" y="239.6" textLength="48.8" clip-path="url(#terminal-3484718817-line-9)">list</text><text class="terminal-3484718817-r2" x="170.8" y="239.6" textLength="268.4" clip-path="url(#terminal-3484718817-line-9)">Perform list operation</text><text class="terminal-3484718817-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-3484718817-line-9)">
+</text><text class="terminal-3484718817-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-3484718817-line-10)">
+</text><text class="terminal-3484718817-r1" x="0" y="288.4" textLength="97.6" clip-path="url(#terminal-3484718817-line-11)">Options:</text><text class="terminal-3484718817-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-3484718817-line-11)">
+</text><text class="terminal-3484718817-r4" x="24.4" y="312.8" textLength="24.4" clip-path="url(#terminal-3484718817-line-12)">-h</text><text class="terminal-3484718817-r2" x="48.8" y="312.8" textLength="24.4" clip-path="url(#terminal-3484718817-line-12)">, </text><text class="terminal-3484718817-r4" x="73.2" y="312.8" textLength="73.2" clip-path="url(#terminal-3484718817-line-12)">--help</text><text class="terminal-3484718817-r2" x="170.8" y="312.8" textLength="378.2" clip-path="ur [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/docs/images/output_connections.svg b/airflow-ctl/airflow-ctl/docs/images/output_connections.svg
similarity index 100%
copy from airflow-ctl/docs/images/output_connections.svg
copy to airflow-ctl/airflow-ctl/docs/images/output_connections.svg
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_dagrun.svg b/airflow-ctl/airflow-ctl/docs/images/output_dagrun.svg
new file mode 100644
index 00000000000..a56fba6d5e5
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_dagrun.svg
@@ -0,0 +1,101 @@
+<svg class="rich-terminal" viewBox="0 0 811 318.4" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-894902431-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-894902431-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-894902431-r1 { fill: #ff8700 }
+.terminal-894902431-r2 { fill: #c5c8c6 }
+.terminal-894902431-r3 { fill: #808080 }
+.terminal-894902431-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-894902431-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="267.4" />
+ </clipPath>
+ <clipPath id="terminal-894902431-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-894902431-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="316.4" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-894902431-clip-terminal)">
+
+ <g class="terminal-894902431-matrix">
+ <text class="terminal-894902431-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-894902431-line-0)">Usage:</text><text class="terminal-894902431-r3" x="85.4" y="20" textLength="207.4" clip-path="url(#terminal-894902431-line-0)">airflowctl dagrun</text><text class="terminal-894902431-r2" x="292.8" y="20" textLength="24.4" clip-path="url(#terminal-894902431-line-0)"> [</text><text class="terminal-894902431-r4" x="317.2" y="20" textLength="24.4" clip-path="url(#term [...]
+</text><text class="terminal-894902431-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-894902431-line-1)">
+</text><text class="terminal-894902431-r2" x="0" y="68.8" textLength="305" clip-path="url(#terminal-894902431-line-2)">Perform DagRun operations</text><text class="terminal-894902431-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-894902431-line-2)">
+</text><text class="terminal-894902431-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-894902431-line-3)">
+</text><text class="terminal-894902431-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-894902431-line-4)">Positional Arguments:</text><text class="terminal-894902431-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-894902431-line-4)">
+</text><text class="terminal-894902431-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-894902431-line-5)">COMMAND</text><text class="terminal-894902431-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-894902431-line-5)">
+</text><text class="terminal-894902431-r4" x="48.8" y="166.4" textLength="36.6" clip-path="url(#terminal-894902431-line-6)">get</text><text class="terminal-894902431-r2" x="170.8" y="166.4" textLength="256.2" clip-path="url(#terminal-894902431-line-6)">Perform get operation</text><text class="terminal-894902431-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-894902431-line-6)">
+</text><text class="terminal-894902431-r4" x="48.8" y="190.8" textLength="48.8" clip-path="url(#terminal-894902431-line-7)">list</text><text class="terminal-894902431-r2" x="170.8" y="190.8" textLength="268.4" clip-path="url(#terminal-894902431-line-7)">Perform list operation</text><text class="terminal-894902431-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-894902431-line-7)">
+</text><text class="terminal-894902431-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-894902431-line-8)">
+</text><text class="terminal-894902431-r1" x="0" y="239.6" textLength="97.6" clip-path="url(#terminal-894902431-line-9)">Options:</text><text class="terminal-894902431-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-894902431-line-9)">
+</text><text class="terminal-894902431-r4" x="24.4" y="264" textLength="24.4" clip-path="url(#terminal-894902431-line-10)">-h</text><text class="terminal-894902431-r2" x="48.8" y="264" textLength="24.4" clip-path="url(#terminal-894902431-line-10)">, </text><text class="terminal-894902431-r4" x="73.2" y="264" textLength="73.2" clip-path="url(#terminal-894902431-line-10)">--help</text><text class="terminal-894902431-r2" x="170.8" y="264" textLength="378.2" clip-path="url(#terminal-894 [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_dags.svg b/airflow-ctl/airflow-ctl/docs/images/output_dags.svg
new file mode 100644
index 00000000000..6863827b594
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_dags.svg
@@ -0,0 +1,157 @@
+<svg class="rich-terminal" viewBox="0 0 811 660.0" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-1075761825-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-1075761825-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-1075761825-r1 { fill: #ff8700 }
+.terminal-1075761825-r2 { fill: #c5c8c6 }
+.terminal-1075761825-r3 { fill: #808080 }
+.terminal-1075761825-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-1075761825-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="609.0" />
+ </clipPath>
+ <clipPath id="terminal-1075761825-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-12">
+ <rect x="0" y="294.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-13">
+ <rect x="0" y="318.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-14">
+ <rect x="0" y="343.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-15">
+ <rect x="0" y="367.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-16">
+ <rect x="0" y="391.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-17">
+ <rect x="0" y="416.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-18">
+ <rect x="0" y="440.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-19">
+ <rect x="0" y="465.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-20">
+ <rect x="0" y="489.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-21">
+ <rect x="0" y="513.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-22">
+ <rect x="0" y="538.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1075761825-line-23">
+ <rect x="0" y="562.7" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="658" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-1075761825-clip-terminal)">
+
+ <g class="terminal-1075761825-matrix">
+ <text class="terminal-1075761825-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-1075761825-line-0)">Usage:</text><text class="terminal-1075761825-r3" x="85.4" y="20" textLength="183" clip-path="url(#terminal-1075761825-line-0)">airflowctl dags</text><text class="terminal-1075761825-r2" x="268.4" y="20" textLength="24.4" clip-path="url(#terminal-1075761825-line-0)"> [</text><text class="terminal-1075761825-r4" x="292.8" y="20" textLength="24.4" clip-path="url(#t [...]
+</text><text class="terminal-1075761825-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-1075761825-line-1)">
+</text><text class="terminal-1075761825-r2" x="0" y="68.8" textLength="280.6" clip-path="url(#terminal-1075761825-line-2)">Perform Dags operations</text><text class="terminal-1075761825-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-1075761825-line-2)">
+</text><text class="terminal-1075761825-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-1075761825-line-3)">
+</text><text class="terminal-1075761825-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-1075761825-line-4)">Positional Arguments:</text><text class="terminal-1075761825-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-1075761825-line-4)">
+</text><text class="terminal-1075761825-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-1075761825-line-5)">COMMAND</text><text class="terminal-1075761825-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-1075761825-line-5)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-1075761825-line-6)">delete</text><text class="terminal-1075761825-r2" x="268.4" y="166.4" textLength="292.8" clip-path="url(#terminal-1075761825-line-6)">Perform delete operation</text><text class="terminal-1075761825-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-1075761825-line-6)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="190.8" textLength="36.6" clip-path="url(#terminal-1075761825-line-7)">get</text><text class="terminal-1075761825-r2" x="268.4" y="190.8" textLength="256.2" clip-path="url(#terminal-1075761825-line-7)">Perform get operation</text><text class="terminal-1075761825-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-1075761825-line-7)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="215.2" textLength="134.2" clip-path="url(#terminal-1075761825-line-8)">get-details</text><text class="terminal-1075761825-r2" x="268.4" y="215.2" textLength="353.8" clip-path="url(#terminal-1075761825-line-8)">Perform get_details operation</text><text class="terminal-1075761825-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-1075761825-line-8)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="239.6" textLength="195.2" clip-path="url(#terminal-1075761825-line-9)">get-import-error</text><text class="terminal-1075761825-r2" x="268.4" y="239.6" textLength="414.8" clip-path="url(#terminal-1075761825-line-9)">Perform get_import_error operation</text><text class="terminal-1075761825-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-1075761825-line-9)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="264" textLength="109.8" clip-path="url(#terminal-1075761825-line-10)">get-stats</text><text class="terminal-1075761825-r2" x="268.4" y="264" textLength="329.4" clip-path="url(#terminal-1075761825-line-10)">Perform get_stats operation</text><text class="terminal-1075761825-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-1075761825-line-10)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="288.4" textLength="97.6" clip-path="url(#terminal-1075761825-line-11)">get-tags</text><text class="terminal-1075761825-r2" x="268.4" y="288.4" textLength="317.2" clip-path="url(#terminal-1075761825-line-11)">Perform get_tags operation</text><text class="terminal-1075761825-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-1075761825-line-11)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="312.8" textLength="134.2" clip-path="url(#terminal-1075761825-line-12)">get-version</text><text class="terminal-1075761825-r2" x="268.4" y="312.8" textLength="353.8" clip-path="url(#terminal-1075761825-line-12)">Perform get_version operation</text><text class="terminal-1075761825-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-1075761825-line-12)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="337.2" textLength="48.8" clip-path="url(#terminal-1075761825-line-13)">list</text><text class="terminal-1075761825-r2" x="268.4" y="337.2" textLength="268.4" clip-path="url(#terminal-1075761825-line-13)">Perform list operation</text><text class="terminal-1075761825-r2" x="793" y="337.2" textLength="12.2" clip-path="url(#terminal-1075761825-line-13)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="361.6" textLength="219.6" clip-path="url(#terminal-1075761825-line-14)">list-import-errors</text><text class="terminal-1075761825-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-1075761825-line-14)">
+</text><text class="terminal-1075761825-r2" x="268.4" y="386" textLength="439.2" clip-path="url(#terminal-1075761825-line-15)">Perform list_import_errors operation</text><text class="terminal-1075761825-r2" x="793" y="386" textLength="12.2" clip-path="url(#terminal-1075761825-line-15)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="410.4" textLength="146.4" clip-path="url(#terminal-1075761825-line-16)">list-version</text><text class="terminal-1075761825-r2" x="268.4" y="410.4" textLength="366" clip-path="url(#terminal-1075761825-line-16)">Perform list_version operation</text><text class="terminal-1075761825-r2" x="793" y="410.4" textLength="12.2" clip-path="url(#terminal-1075761825-line-16)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="434.8" textLength="146.4" clip-path="url(#terminal-1075761825-line-17)">list-warning</text><text class="terminal-1075761825-r2" x="268.4" y="434.8" textLength="366" clip-path="url(#terminal-1075761825-line-17)">Perform list_warning operation</text><text class="terminal-1075761825-r2" x="793" y="434.8" textLength="12.2" clip-path="url(#terminal-1075761825-line-17)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="459.2" textLength="61" clip-path="url(#terminal-1075761825-line-18)">pause</text><text class="terminal-1075761825-r2" x="268.4" y="459.2" textLength="134.2" clip-path="url(#terminal-1075761825-line-18)">Pause a Dag</text><text class="terminal-1075761825-r2" x="793" y="459.2" textLength="12.2" clip-path="url(#terminal-1075761825-line-18)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="483.6" textLength="85.4" clip-path="url(#terminal-1075761825-line-19)">trigger</text><text class="terminal-1075761825-r2" x="268.4" y="483.6" textLength="305" clip-path="url(#terminal-1075761825-line-19)">Perform trigger operation</text><text class="terminal-1075761825-r2" x="793" y="483.6" textLength="12.2" clip-path="url(#terminal-1075761825-line-19)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="508" textLength="85.4" clip-path="url(#terminal-1075761825-line-20)">unpause</text><text class="terminal-1075761825-r2" x="268.4" y="508" textLength="158.6" clip-path="url(#terminal-1075761825-line-20)">Unpause a Dag</text><text class="terminal-1075761825-r2" x="793" y="508" textLength="12.2" clip-path="url(#terminal-1075761825-line-20)">
+</text><text class="terminal-1075761825-r4" x="48.8" y="532.4" textLength="73.2" clip-path="url(#terminal-1075761825-line-21)">update</text><text class="terminal-1075761825-r2" x="268.4" y="532.4" textLength="292.8" clip-path="url(#terminal-1075761825-line-21)">Perform update operation</text><text class="terminal-1075761825-r2" x="793" y="532.4" textLength="12.2" clip-path="url(#terminal-1075761825-line-21)">
+</text><text class="terminal-1075761825-r2" x="793" y="556.8" textLength="12.2" clip-path="url(#terminal-1075761825-line-22)">
+</text><text class="terminal-1075761825-r1" x="0" y="581.2" textLength="97.6" clip-path="url(#terminal-1075761825-line-23)">Options:</text><text class="terminal-1075761825-r2" x="793" y="581.2" textLength="12.2" clip-path="url(#terminal-1075761825-line-23)">
+</text><text class="terminal-1075761825-r4" x="24.4" y="605.6" textLength="24.4" clip-path="url(#terminal-1075761825-line-24)">-h</text><text class="terminal-1075761825-r2" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-1075761825-line-24)">, </text><text class="terminal-1075761825-r4" x="73.2" y="605.6" textLength="73.2" clip-path="url(#terminal-1075761825-line-24)">--help</text><text class="terminal-1075761825-r2" x="268.4" y="605.6" textLength="378.2" clip-path="ur [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_jobs.svg b/airflow-ctl/airflow-ctl/docs/images/output_jobs.svg
new file mode 100644
index 00000000000..a844eea5415
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_jobs.svg
@@ -0,0 +1,97 @@
+<svg class="rich-terminal" viewBox="0 0 811 294.0" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-663280545-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-663280545-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-663280545-r1 { fill: #ff8700 }
+.terminal-663280545-r2 { fill: #c5c8c6 }
+.terminal-663280545-r3 { fill: #808080 }
+.terminal-663280545-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-663280545-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="243.0" />
+ </clipPath>
+ <clipPath id="terminal-663280545-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-663280545-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="292" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-663280545-clip-terminal)">
+
+ <g class="terminal-663280545-matrix">
+ <text class="terminal-663280545-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-663280545-line-0)">Usage:</text><text class="terminal-663280545-r3" x="85.4" y="20" textLength="183" clip-path="url(#terminal-663280545-line-0)">airflowctl jobs</text><text class="terminal-663280545-r2" x="268.4" y="20" textLength="24.4" clip-path="url(#terminal-663280545-line-0)"> [</text><text class="terminal-663280545-r4" x="292.8" y="20" textLength="24.4" clip-path="url(#terminal [...]
+</text><text class="terminal-663280545-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-663280545-line-1)">
+</text><text class="terminal-663280545-r2" x="0" y="68.8" textLength="280.6" clip-path="url(#terminal-663280545-line-2)">Perform Jobs operations</text><text class="terminal-663280545-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-663280545-line-2)">
+</text><text class="terminal-663280545-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-663280545-line-3)">
+</text><text class="terminal-663280545-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-663280545-line-4)">Positional Arguments:</text><text class="terminal-663280545-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-663280545-line-4)">
+</text><text class="terminal-663280545-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-663280545-line-5)">COMMAND</text><text class="terminal-663280545-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-663280545-line-5)">
+</text><text class="terminal-663280545-r4" x="48.8" y="166.4" textLength="48.8" clip-path="url(#terminal-663280545-line-6)">list</text><text class="terminal-663280545-r2" x="170.8" y="166.4" textLength="268.4" clip-path="url(#terminal-663280545-line-6)">Perform list operation</text><text class="terminal-663280545-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-663280545-line-6)">
+</text><text class="terminal-663280545-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-663280545-line-7)">
+</text><text class="terminal-663280545-r1" x="0" y="215.2" textLength="97.6" clip-path="url(#terminal-663280545-line-8)">Options:</text><text class="terminal-663280545-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-663280545-line-8)">
+</text><text class="terminal-663280545-r4" x="24.4" y="239.6" textLength="24.4" clip-path="url(#terminal-663280545-line-9)">-h</text><text class="terminal-663280545-r2" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-663280545-line-9)">, </text><text class="terminal-663280545-r4" x="73.2" y="239.6" textLength="73.2" clip-path="url(#terminal-663280545-line-9)">--help</text><text class="terminal-663280545-r2" x="170.8" y="239.6" textLength="378.2" clip-path="url(#termina [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_main.svg b/airflow-ctl/airflow-ctl/docs/images/output_main.svg
new file mode 100644
index 00000000000..f6c7225a4eb
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_main.svg
@@ -0,0 +1,157 @@
+<svg class="rich-terminal" viewBox="0 0 811 660.0" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-3400494481-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-3400494481-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-3400494481-r1 { fill: #ff8700 }
+.terminal-3400494481-r2 { fill: #c5c8c6 }
+.terminal-3400494481-r3 { fill: #808080 }
+.terminal-3400494481-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-3400494481-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="609.0" />
+ </clipPath>
+ <clipPath id="terminal-3400494481-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-12">
+ <rect x="0" y="294.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-13">
+ <rect x="0" y="318.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-14">
+ <rect x="0" y="343.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-15">
+ <rect x="0" y="367.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-16">
+ <rect x="0" y="391.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-17">
+ <rect x="0" y="416.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-18">
+ <rect x="0" y="440.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-19">
+ <rect x="0" y="465.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-20">
+ <rect x="0" y="489.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-21">
+ <rect x="0" y="513.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-22">
+ <rect x="0" y="538.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3400494481-line-23">
+ <rect x="0" y="562.7" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="658" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-3400494481-clip-terminal)">
+
+ <g class="terminal-3400494481-matrix">
+ <text class="terminal-3400494481-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-3400494481-line-0)">Usage:</text><text class="terminal-3400494481-r3" x="85.4" y="20" textLength="122" clip-path="url(#terminal-3400494481-line-0)">airflowctl</text><text class="terminal-3400494481-r2" x="207.4" y="20" textLength="24.4" clip-path="url(#terminal-3400494481-line-0)"> [</text><text class="terminal-3400494481-r4" x="231.8" y="20" textLength="24.4" clip-path="url(#terminal-34 [...]
+</text><text class="terminal-3400494481-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-3400494481-line-1)">
+</text><text class="terminal-3400494481-r1" x="0" y="68.8" textLength="256.2" clip-path="url(#terminal-3400494481-line-2)">Positional Arguments:</text><text class="terminal-3400494481-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-3400494481-line-2)">
+</text><text class="terminal-3400494481-r4" x="24.4" y="93.2" textLength="195.2" clip-path="url(#terminal-3400494481-line-3)">GROUP_OR_COMMAND</text><text class="terminal-3400494481-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-3400494481-line-3)">
+</text><text class="terminal-3400494481-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-3400494481-line-4)">
+</text><text class="terminal-3400494481-r4" x="0" y="142" textLength="122" clip-path="url(#terminal-3400494481-line-5)">    Groups</text><text class="terminal-3400494481-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-3400494481-line-5)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="166.4" textLength="73.2" clip-path="url(#terminal-3400494481-line-6)">assets</text><text class="terminal-3400494481-r2" x="244" y="166.4" textLength="305" clip-path="url(#terminal-3400494481-line-6)">Perform Assets operations</text><text class="terminal-3400494481-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-3400494481-line-6)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="190.8" textLength="48.8" clip-path="url(#terminal-3400494481-line-7)">auth</text><text class="terminal-3400494481-r2" x="244" y="190.8" textLength="512.4" clip-path="url(#terminal-3400494481-line-7)">Manage authentication for CLI. Either pass</text><text class="terminal-3400494481-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-3400494481-line-7)">
+</text><text class="terminal-3400494481-r2" x="244" y="215.2" textLength="500.2" clip-path="url(#terminal-3400494481-line-8)">token from environment variable/parameter</text><text class="terminal-3400494481-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-3400494481-line-8)">
+</text><text class="terminal-3400494481-r2" x="244" y="239.6" textLength="366" clip-path="url(#terminal-3400494481-line-9)">or pass username and password.</text><text class="terminal-3400494481-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-3400494481-line-9)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="264" textLength="97.6" clip-path="url(#terminal-3400494481-line-10)">backfill</text><text class="terminal-3400494481-r2" x="244" y="264" textLength="329.4" clip-path="url(#terminal-3400494481-line-10)">Perform Backfill operations</text><text class="terminal-3400494481-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-3400494481-line-10)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="288.4" textLength="73.2" clip-path="url(#terminal-3400494481-line-11)">config</text><text class="terminal-3400494481-r2" x="244" y="288.4" textLength="305" clip-path="url(#terminal-3400494481-line-11)">Perform Config operations</text><text class="terminal-3400494481-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-3400494481-line-11)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="312.8" textLength="134.2" clip-path="url(#terminal-3400494481-line-12)">connections</text><text class="terminal-3400494481-r2" x="244" y="312.8" textLength="366" clip-path="url(#terminal-3400494481-line-12)">Perform Connections operations</text><text class="terminal-3400494481-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-3400494481-line-12)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="337.2" textLength="73.2" clip-path="url(#terminal-3400494481-line-13)">dagrun</text><text class="terminal-3400494481-r2" x="244" y="337.2" textLength="305" clip-path="url(#terminal-3400494481-line-13)">Perform DagRun operations</text><text class="terminal-3400494481-r2" x="793" y="337.2" textLength="12.2" clip-path="url(#terminal-3400494481-line-13)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="361.6" textLength="48.8" clip-path="url(#terminal-3400494481-line-14)">dags</text><text class="terminal-3400494481-r2" x="244" y="361.6" textLength="280.6" clip-path="url(#terminal-3400494481-line-14)">Perform Dags operations</text><text class="terminal-3400494481-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-3400494481-line-14)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="386" textLength="48.8" clip-path="url(#terminal-3400494481-line-15)">jobs</text><text class="terminal-3400494481-r2" x="244" y="386" textLength="280.6" clip-path="url(#terminal-3400494481-line-15)">Perform Jobs operations</text><text class="terminal-3400494481-r2" x="793" y="386" textLength="12.2" clip-path="url(#terminal-3400494481-line-15)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="410.4" textLength="61" clip-path="url(#terminal-3400494481-line-16)">pools</text><text class="terminal-3400494481-r2" x="244" y="410.4" textLength="292.8" clip-path="url(#terminal-3400494481-line-16)">Perform Pools operations</text><text class="terminal-3400494481-r2" x="793" y="410.4" textLength="12.2" clip-path="url(#terminal-3400494481-line-16)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="434.8" textLength="109.8" clip-path="url(#terminal-3400494481-line-17)">providers</text><text class="terminal-3400494481-r2" x="244" y="434.8" textLength="341.6" clip-path="url(#terminal-3400494481-line-17)">Perform Providers operations</text><text class="terminal-3400494481-r2" x="793" y="434.8" textLength="12.2" clip-path="url(#terminal-3400494481-line-17)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="459.2" textLength="109.8" clip-path="url(#terminal-3400494481-line-18)">variables</text><text class="terminal-3400494481-r2" x="244" y="459.2" textLength="341.6" clip-path="url(#terminal-3400494481-line-18)">Perform Variables operations</text><text class="terminal-3400494481-r2" x="793" y="459.2" textLength="12.2" clip-path="url(#terminal-3400494481-line-18)">
+</text><text class="terminal-3400494481-r2" x="793" y="483.6" textLength="12.2" clip-path="url(#terminal-3400494481-line-19)">
+</text><text class="terminal-3400494481-r4" x="0" y="508" textLength="158.6" clip-path="url(#terminal-3400494481-line-20)">    Commands:</text><text class="terminal-3400494481-r2" x="793" y="508" textLength="12.2" clip-path="url(#terminal-3400494481-line-20)">
+</text><text class="terminal-3400494481-r4" x="73.2" y="532.4" textLength="85.4" clip-path="url(#terminal-3400494481-line-21)">version</text><text class="terminal-3400494481-r2" x="244" y="532.4" textLength="292.8" clip-path="url(#terminal-3400494481-line-21)">Show version information</text><text class="terminal-3400494481-r2" x="793" y="532.4" textLength="12.2" clip-path="url(#terminal-3400494481-line-21)">
+</text><text class="terminal-3400494481-r2" x="793" y="556.8" textLength="12.2" clip-path="url(#terminal-3400494481-line-22)">
+</text><text class="terminal-3400494481-r1" x="0" y="581.2" textLength="97.6" clip-path="url(#terminal-3400494481-line-23)">Options:</text><text class="terminal-3400494481-r2" x="793" y="581.2" textLength="12.2" clip-path="url(#terminal-3400494481-line-23)">
+</text><text class="terminal-3400494481-r4" x="24.4" y="605.6" textLength="24.4" clip-path="url(#terminal-3400494481-line-24)">-h</text><text class="terminal-3400494481-r2" x="48.8" y="605.6" textLength="24.4" clip-path="url(#terminal-3400494481-line-24)">, </text><text class="terminal-3400494481-r4" x="73.2" y="605.6" textLength="73.2" clip-path="url(#terminal-3400494481-line-24)">--help</text><text class="terminal-3400494481-r2" x="244" y="605.6" textLength="378.2" clip-path="url( [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_pools.svg b/airflow-ctl/airflow-ctl/docs/images/output_pools.svg
new file mode 100644
index 00000000000..9423ec2d7c5
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_pools.svg
@@ -0,0 +1,121 @@
+<svg class="rich-terminal" viewBox="0 0 811 440.4" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-1870492668-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-1870492668-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-1870492668-r1 { fill: #ff8700 }
+.terminal-1870492668-r2 { fill: #c5c8c6 }
+.terminal-1870492668-r3 { fill: #808080 }
+.terminal-1870492668-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-1870492668-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="389.4" />
+ </clipPath>
+ <clipPath id="terminal-1870492668-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-9">
+ <rect x="0" y="221.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-10">
+ <rect x="0" y="245.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-11">
+ <rect x="0" y="269.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-12">
+ <rect x="0" y="294.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-13">
+ <rect x="0" y="318.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-1870492668-line-14">
+ <rect x="0" y="343.1" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="438.4" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-1870492668-clip-terminal)">
+
+ <g class="terminal-1870492668-matrix">
+ <text class="terminal-1870492668-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-1870492668-line-0)">Usage:</text><text class="terminal-1870492668-r3" x="85.4" y="20" textLength="195.2" clip-path="url(#terminal-1870492668-line-0)">airflowctl pools</text><text class="terminal-1870492668-r2" x="280.6" y="20" textLength="24.4" clip-path="url(#terminal-1870492668-line-0)"> [</text><text class="terminal-1870492668-r4" x="305" y="20" textLength="24.4" clip-path="url(# [...]
+</text><text class="terminal-1870492668-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-1870492668-line-1)">
+</text><text class="terminal-1870492668-r2" x="0" y="68.8" textLength="292.8" clip-path="url(#terminal-1870492668-line-2)">Perform Pools operations</text><text class="terminal-1870492668-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-1870492668-line-2)">
+</text><text class="terminal-1870492668-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-1870492668-line-3)">
+</text><text class="terminal-1870492668-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-1870492668-line-4)">Positional Arguments:</text><text class="terminal-1870492668-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-1870492668-line-4)">
+</text><text class="terminal-1870492668-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-1870492668-line-5)">COMMAND</text><text class="terminal-1870492668-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-1870492668-line-5)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-1870492668-line-6)">create</text><text class="terminal-1870492668-r2" x="170.8" y="166.4" textLength="292.8" clip-path="url(#terminal-1870492668-line-6)">Perform create operation</text><text class="terminal-1870492668-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-1870492668-line-6)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="190.8" textLength="73.2" clip-path="url(#terminal-1870492668-line-7)">delete</text><text class="terminal-1870492668-r2" x="170.8" y="190.8" textLength="292.8" clip-path="url(#terminal-1870492668-line-7)">Perform delete operation</text><text class="terminal-1870492668-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-1870492668-line-7)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="215.2" textLength="73.2" clip-path="url(#terminal-1870492668-line-8)">export</text><text class="terminal-1870492668-r2" x="170.8" y="215.2" textLength="195.2" clip-path="url(#terminal-1870492668-line-8)">Export all pools</text><text class="terminal-1870492668-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-1870492668-line-8)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="239.6" textLength="36.6" clip-path="url(#terminal-1870492668-line-9)">get</text><text class="terminal-1870492668-r2" x="170.8" y="239.6" textLength="256.2" clip-path="url(#terminal-1870492668-line-9)">Perform get operation</text><text class="terminal-1870492668-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-1870492668-line-9)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="264" textLength="73.2" clip-path="url(#terminal-1870492668-line-10)">import</text><text class="terminal-1870492668-r2" x="170.8" y="264" textLength="146.4" clip-path="url(#terminal-1870492668-line-10)">Import pools</text><text class="terminal-1870492668-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-1870492668-line-10)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="288.4" textLength="48.8" clip-path="url(#terminal-1870492668-line-11)">list</text><text class="terminal-1870492668-r2" x="170.8" y="288.4" textLength="268.4" clip-path="url(#terminal-1870492668-line-11)">Perform list operation</text><text class="terminal-1870492668-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-1870492668-line-11)">
+</text><text class="terminal-1870492668-r4" x="48.8" y="312.8" textLength="73.2" clip-path="url(#terminal-1870492668-line-12)">update</text><text class="terminal-1870492668-r2" x="170.8" y="312.8" textLength="292.8" clip-path="url(#terminal-1870492668-line-12)">Perform update operation</text><text class="terminal-1870492668-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-1870492668-line-12)">
+</text><text class="terminal-1870492668-r2" x="793" y="337.2" textLength="12.2" clip-path="url(#terminal-1870492668-line-13)">
+</text><text class="terminal-1870492668-r1" x="0" y="361.6" textLength="97.6" clip-path="url(#terminal-1870492668-line-14)">Options:</text><text class="terminal-1870492668-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-1870492668-line-14)">
+</text><text class="terminal-1870492668-r4" x="24.4" y="386" textLength="24.4" clip-path="url(#terminal-1870492668-line-15)">-h</text><text class="terminal-1870492668-r2" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-1870492668-line-15)">, </text><text class="terminal-1870492668-r4" x="73.2" y="386" textLength="73.2" clip-path="url(#terminal-1870492668-line-15)">--help</text><text class="terminal-1870492668-r2" x="170.8" y="386" textLength="378.2" clip-path="url(#termi [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_providers.svg b/airflow-ctl/airflow-ctl/docs/images/output_providers.svg
new file mode 100644
index 00000000000..ff4a2c8e4d2
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_providers.svg
@@ -0,0 +1,97 @@
+<svg class="rich-terminal" viewBox="0 0 811 294.0" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-4251246593-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-4251246593-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-4251246593-r1 { fill: #ff8700 }
+.terminal-4251246593-r2 { fill: #c5c8c6 }
+.terminal-4251246593-r3 { fill: #808080 }
+.terminal-4251246593-r4 { fill: #68a0b3 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-4251246593-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="243.0" />
+ </clipPath>
+ <clipPath id="terminal-4251246593-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-4251246593-line-8">
+ <rect x="0" y="196.7" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="292" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-4251246593-clip-terminal)">
+
+ <g class="terminal-4251246593-matrix">
+ <text class="terminal-4251246593-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-4251246593-line-0)">Usage:</text><text class="terminal-4251246593-r3" x="85.4" y="20" textLength="244" clip-path="url(#terminal-4251246593-line-0)">airflowctl providers</text><text class="terminal-4251246593-r2" x="329.4" y="20" textLength="24.4" clip-path="url(#terminal-4251246593-line-0)"> [</text><text class="terminal-4251246593-r4" x="353.8" y="20" textLength="24.4" clip-path="u [...]
+</text><text class="terminal-4251246593-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-4251246593-line-1)">
+</text><text class="terminal-4251246593-r2" x="0" y="68.8" textLength="341.6" clip-path="url(#terminal-4251246593-line-2)">Perform Providers operations</text><text class="terminal-4251246593-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-4251246593-line-2)">
+</text><text class="terminal-4251246593-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-4251246593-line-3)">
+</text><text class="terminal-4251246593-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-4251246593-line-4)">Positional Arguments:</text><text class="terminal-4251246593-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-4251246593-line-4)">
+</text><text class="terminal-4251246593-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-4251246593-line-5)">COMMAND</text><text class="terminal-4251246593-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-4251246593-line-5)">
+</text><text class="terminal-4251246593-r4" x="48.8" y="166.4" textLength="48.8" clip-path="url(#terminal-4251246593-line-6)">list</text><text class="terminal-4251246593-r2" x="170.8" y="166.4" textLength="268.4" clip-path="url(#terminal-4251246593-line-6)">Perform list operation</text><text class="terminal-4251246593-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-4251246593-line-6)">
+</text><text class="terminal-4251246593-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-4251246593-line-7)">
+</text><text class="terminal-4251246593-r1" x="0" y="215.2" textLength="97.6" clip-path="url(#terminal-4251246593-line-8)">Options:</text><text class="terminal-4251246593-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-4251246593-line-8)">
+</text><text class="terminal-4251246593-r4" x="24.4" y="239.6" textLength="24.4" clip-path="url(#terminal-4251246593-line-9)">-h</text><text class="terminal-4251246593-r2" x="48.8" y="239.6" textLength="24.4" clip-path="url(#terminal-4251246593-line-9)">, </text><text class="terminal-4251246593-r4" x="73.2" y="239.6" textLength="73.2" clip-path="url(#terminal-4251246593-line-9)">--help</text><text class="terminal-4251246593-r2" x="170.8" y="239.6" textLength="378.2" clip-path="url(# [...]
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/docs/images/output_variables.svg b/airflow-ctl/airflow-ctl/docs/images/output_variables.svg
similarity index 100%
copy from airflow-ctl/docs/images/output_variables.svg
copy to airflow-ctl/airflow-ctl/docs/images/output_variables.svg
diff --git a/airflow-ctl/airflow-ctl/docs/images/output_version.svg b/airflow-ctl/airflow-ctl/docs/images/output_version.svg
new file mode 100644
index 00000000000..a7ce436c8b8
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/images/output_version.svg
@@ -0,0 +1,94 @@
+<svg class="rich-terminal" viewBox="0 0 811 269.6" xmlns="http://www.w3.org/2000/svg">
+ <!-- Generated with Rich https://www.textualize.io -->
+ <style>
+
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Regular"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+ font-style: normal;
+ font-weight: 400;
+ }
+ @font-face {
+ font-family: "Fira Code";
+ src: local("FiraCode-Bold"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+ url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+ font-style: bold;
+ font-weight: 700;
+ }
+
+ .terminal-3399644434-matrix {
+ font-family: Fira Code, monospace;
+ font-size: 20px;
+ line-height: 24.4px;
+ font-variant-east-asian: full-width;
+ }
+
+ .terminal-3399644434-title {
+ font-size: 18px;
+ font-weight: bold;
+ font-family: arial;
+ }
+
+ .terminal-3399644434-r1 { fill: #ff8700 }
+.terminal-3399644434-r2 { fill: #c5c8c6 }
+.terminal-3399644434-r3 { fill: #808080 }
+.terminal-3399644434-r4 { fill: #68a0b3 }
+.terminal-3399644434-r5 { fill: #00af87 }
+ </style>
+
+ <defs>
+ <clipPath id="terminal-3399644434-clip-terminal">
+ <rect x="0" y="0" width="792.0" height="218.6" />
+ </clipPath>
+ <clipPath id="terminal-3399644434-line-0">
+ <rect x="0" y="1.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-1">
+ <rect x="0" y="25.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-2">
+ <rect x="0" y="50.3" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-3">
+ <rect x="0" y="74.7" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-4">
+ <rect x="0" y="99.1" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-5">
+ <rect x="0" y="123.5" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-6">
+ <rect x="0" y="147.9" width="793" height="24.65"/>
+ </clipPath>
+<clipPath id="terminal-3399644434-line-7">
+ <rect x="0" y="172.3" width="793" height="24.65"/>
+ </clipPath>
+ </defs>
+
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="267.6" rx="8"/>
+ <g transform="translate(26,22)">
+ <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+ <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+ <circle cx="44" cy="0" r="7" fill="#28c840"/>
+ </g>
+
+ <g transform="translate(9, 41)" clip-path="url(#terminal-3399644434-clip-terminal)">
+
+ <g class="terminal-3399644434-matrix">
+ <text class="terminal-3399644434-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-3399644434-line-0)">Usage:</text><text class="terminal-3399644434-r3" x="85.4" y="20" textLength="219.6" clip-path="url(#terminal-3399644434-line-0)">airflowctl version</text><text class="terminal-3399644434-r2" x="305" y="20" textLength="24.4" clip-path="url(#terminal-3399644434-line-0)"> [</text><text class="terminal-3399644434-r4" x="329.4" y="20" textLength="24.4" clip-path="url [...]
+</text><text class="terminal-3399644434-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-3399644434-line-1)">
+</text><text class="terminal-3399644434-r2" x="0" y="68.8" textLength="292.8" clip-path="url(#terminal-3399644434-line-2)">Show version information</text><text class="terminal-3399644434-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-3399644434-line-2)">
+</text><text class="terminal-3399644434-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-3399644434-line-3)">
+</text><text class="terminal-3399644434-r1" x="0" y="117.6" textLength="97.6" clip-path="url(#terminal-3399644434-line-4)">Options:</text><text class="terminal-3399644434-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-3399644434-line-4)">
+</text><text class="terminal-3399644434-r4" x="24.4" y="142" textLength="24.4" clip-path="url(#terminal-3399644434-line-5)">-h</text><text class="terminal-3399644434-r2" x="48.8" y="142" textLength="24.4" clip-path="url(#terminal-3399644434-line-5)">, </text><text class="terminal-3399644434-r4" x="73.2" y="142" textLength="73.2" clip-path="url(#terminal-3399644434-line-5)">--help</text><text class="terminal-3399644434-r2" x="256.2" y="142" textLength="378.2" clip-path="url(#terminal [...]
+</text><text class="terminal-3399644434-r4" x="24.4" y="166.4" textLength="24.4" clip-path="url(#terminal-3399644434-line-6)">-e</text><text class="terminal-3399644434-r2" x="48.8" y="166.4" textLength="24.4" clip-path="url(#terminal-3399644434-line-6)">, </text><text class="terminal-3399644434-r4" x="73.2" y="166.4" textLength="61" clip-path="url(#terminal-3399644434-line-6)">--env</text><text class="terminal-3399644434-r5" x="146.4" y="166.4" textLength="36.6" clip-path="url(#term [...]
+</text><text class="terminal-3399644434-r4" x="24.4" y="190.8" textLength="97.6" clip-path="url(#terminal-3399644434-line-7)">--remote</text><text class="terminal-3399644434-r2" x="256.2" y="190.8" textLength="536.8" clip-path="url(#terminal-3399644434-line-7)">Fetch the Airflow version in remote server, </text><text class="terminal-3399644434-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-3399644434-line-7)">
+</text><text class="terminal-3399644434-r2" x="0" y="215.2" textLength="597.8" clip-path="url(#terminal-3399644434-line-8)">otherwise only shows the local airflowctl version</text><text class="terminal-3399644434-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-3399644434-line-8)">
+</text>
+ </g>
+ </g>
+</svg>
diff --git a/airflow-ctl/RELEASE_NOTES.rst b/airflow-ctl/airflow-ctl/docs/index.rst
similarity index 59%
copy from airflow-ctl/RELEASE_NOTES.rst
copy to airflow-ctl/airflow-ctl/docs/index.rst
index 70d19556a9f..0ab584a160e 100644
--- a/airflow-ctl/RELEASE_NOTES.rst
+++ b/airflow-ctl/airflow-ctl/docs/index.rst
@@ -15,13 +15,25 @@
specific language governing permissions and limitations
under the License.
-airflowctl 0.1.0 (2025-11-05)
------------------------------
+What is airflowctl?
+===================
+airflowctl is a command line tool that helps you communicate with API and provide similar functionality with Apache Airflow CLI.
+It is designed to be easy to use and provides a simple interface for managing your Airflow API calls and daily operations.
-Release of airflowctl, a command-line tool. There are lots of great features to use from start.
-Please check the documentation for quick start and usage instructions.
+Please head over to :doc:`/start` to get started.
-Please visit quick start guide: :doc:`/start`
+.. toctree::
+ :hidden:
+ :caption: Content
-A new way of using Apache Airflow using CLI. Enhanced security is provided by using the Apache Airflow API to provide similar functionality to the Apache Airflow CLI.
-Integrated with Keyring to enhance password security.
+ installation/index
+ howto/index
+ security
+ changelog
+
+.. toctree::
+ :hidden:
+ :caption: Usage
+
+ start
+ References <cli-and-env-variables-ref>
diff --git a/airflow-ctl/airflow-ctl/docs/installation/index.rst b/airflow-ctl/airflow-ctl/docs/installation/index.rst
new file mode 100644
index 00000000000..d3f2062c297
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/installation/index.rst
@@ -0,0 +1,132 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+
+Installation of airflowctl
+----------------------------
+
+.. contents:: :local:
+
+.. toctree::
+ :maxdepth: 1
+ :caption: Installation
+ :hidden:
+
+ Prerequisites <prerequisites>
+ Installing from sources <installing-from-sources>
+ Installing from PyPI <installing-from-pypi>
+
+This page describes installations options that you might use when considering how to install Airflow®.
+Airflow consists of many components, often distributed among many physical or virtual machines, therefore
+installation of Airflow might be quite complex, depending on the options you choose.
+
+
+Using released sources
+''''''''''''''''''''''
+
+More details: :doc:`installing-from-sources`
+
+**When this option works best**
+
+* This option is best if you expect to build all your software from sources.
+* Apache Airflow is one of the projects that belong to the `Apache Software Foundation <https://www.apache.org/>`__.
+ It is a requirement for all ASF projects that they can be installed using official sources released via `Official Apache Downloads <https://dlcdn.apache.org/>`__.
+* This is the best choice if you have a strong need to `verify the integrity and provenance of the software <https://www.apache.org/dyn/closer.cgi#verify>`__
+
+**Intended users**
+
+* Users who are familiar with installing and building software from sources and are conscious about integrity and provenance
+ of the software they use down to the lowest level possible.
+
+**What are you expected to handle**
+
+* You are expected to build and install airflow and its components on your own.
+* You should develop and handle the deployment for all components of Airflow.
+* You are responsible for setting up database, creating and managing database schema with ``airflow db`` commands,
+ automated startup and recovery, maintenance, cleanup and upgrades of Airflow and the Airflow Providers.
+* You need to setup monitoring of your system allowing you to observe resources and react to problems.
+* You are expected to configure and manage appropriate resources for the installation (memory, CPU, etc) based
+ on the monitoring of your installation and feedback loop. See the notes about requirements.
+
+**What Apache Airflow Community provides for that method**
+
+* You have `instructions <https://github.com/apache/airflow/blob/main/INSTALL>`__ on how to build the software but due to various environments
+ and tools you might want to use, you might expect that there will be problems which are specific to your deployment and environment
+ you will have to diagnose and solve.
+
+**Where to ask for help**
+
+* The ``#user-troubleshooting`` channel on slack can be used for quick general troubleshooting questions. The
+ `GitHub discussions <https://github.com/apache/airflow/discussions>`__ if you look for longer discussion and have more information to share.
+
+* The ``#user-best-practices`` channel on slack can be used to ask for and share best practices on using and deploying airflow.
+
+* If you can provide description of a reproducible problem with Airflow software, you can open issue at `GitHub issues <https://github.com/apache/airflow/issues>`_
+
+* If you want to contribute back to Airflow, the ``#contributors`` slack channel for building the Airflow itself
+
+
+Using PyPI
+'''''''''''
+
+More details: :doc:`/installation/installing-from-pypi`
+
+**When this option works best**
+
+* This installation method is useful when you are not familiar with Containers and Docker and want to install
+ Apache Airflow on physical or virtual machines and you are used to installing and running software using custom
+ deployment mechanism.
+
+* The only officially supported mechanism of installation is via ``pip`` using constraint mechanisms. The constraint
+ files are managed by Apache Airflow release managers to make sure that you can repeatably install Airflow from PyPI with all Providers and
+ required dependencies.
+
+* In case of PyPI installation you could also verify integrity and provenance of the packages
+ downloaded from PyPI as described at the installation page, but software you download from PyPI is pre-built
+ for you so that you can install it without building, and you do not build the software from sources.
+
+**Intended users**
+
+* Users who are familiar with installing and configuring Python applications, managing Python environments,
+ dependencies and running software with their custom deployment mechanisms.
+
+**What are you expected to handle**
+
+* You are expected to install airflowctl.
+* You should run the Airflow API server.
+* You need to setup monitoring of your system allowing you to observe resources and react to problems.
+
+**What Apache Airflow Community provides for that method**
+
+* You have :doc:`/installation/installing-from-pypi`
+ on how to install the software but due to various environments and tools you might want to use, you might
+ expect that there will be problems which are specific to your deployment and environment you will have to
+ diagnose and solve.
+* You have :doc:`/start` where you can see an example of Quick Start with running Airflow
+ locally which you can use to start Airflow quickly for local testing and development.
+ However, this is just for inspiration. Do not expect :doc:`/start` is ready for production installation,
+ you need to build your own production-ready deployment if you follow this approach.
+
+**Where to ask for help**
+
+* The ``#user-troubleshooting`` channel on Airflow Slack for quick general
+ troubleshooting questions. The `GitHub discussions <https://github.com/apache/airflow/discussions>`__
+ if you look for longer discussion and have more information to share.
+* The ``#user-best-practices`` channel on slack can be used to ask for and share best
+ practices on using and deploying airflow.
+* If you can provide description of a reproducible problem with Airflow software, you can open
+ issue at `GitHub issues <https://github.com/apache/airflow/issues>`__
diff --git a/airflow-ctl/airflow-ctl/docs/installation/installing-from-pypi.rst b/airflow-ctl/airflow-ctl/docs/installation/installing-from-pypi.rst
new file mode 100644
index 00000000000..cecd66c354e
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/installation/installing-from-pypi.rst
@@ -0,0 +1,63 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+Installation from PyPI
+----------------------
+
+This page describes installations using the ``apache-airflow-ctl`` package `published in
+PyPI <https://pypi.org/project/apache-airflow-ctl/>`__.
+
+Installation tools
+''''''''''''''''''
+
+Only ``pip`` installation is currently officially supported.
+
+.. note::
+
+ While there are some successes with using other tools like `poetry <https://python-poetry.org/>`_ or
+ `pip-tools <https://pypi.org/project/pip-tools/>`_, they do not share the same workflow as
+ ``pip`` - especially when it comes to constraint vs. requirements management.
+ Installing via ``Poetry`` or ``pip-tools`` is not currently supported. If you wish to install airflow
+ using those tools you should use the constraints and convert them to appropriate
+ format and workflow that your tool requires.
+
+ There are known issues with ``bazel`` that might lead to circular dependencies when using it to install
+ Airflow. Please switch to ``pip`` if you encounter such problems. ``Bazel`` community works on fixing
+ the problem in `this PR <https://github.com/bazelbuild/rules_python/pull/1166>`_ so it might be that
+ newer versions of ``bazel`` will handle it.
+
+Typical command to install airflowctl from scratch in a reproducible way from PyPI looks like below:
+
+.. code-block:: bash
+
+ pip install "apache-airflow-ctl==|version|"
+
+Those are just examples, see further for more explanation why those are the best practices.
+
+.. note::
+
+ Generally speaking, Python community established practice is to perform application installation in a
+ virtualenv created with ``virtualenv`` or ``venv`` tools. You can also use ``uv`` or ``pipx`` to install
+ Airflow in application dedicated virtual environment created for you. There are also other tools that can be used
+ to manage your virtualenv installation and you are free to choose how you are managing the environments.
+ Airflow has no limitation regarding to the tool of your choice when it comes to virtual environment.
+
+ The only exception where you might consider not using virtualenv is when you are building a container
+ image with only Airflow installed - this is for example how Airflow is installed in the official Container
+ image.
+
+.. _installation:constraints:
diff --git a/airflow-ctl/docs/installation/installing-from-sources.rst b/airflow-ctl/airflow-ctl/docs/installation/installing-from-sources.rst
similarity index 69%
copy from airflow-ctl/docs/installation/installing-from-sources.rst
copy to airflow-ctl/airflow-ctl/docs/installation/installing-from-sources.rst
index 2d2f9a421f5..ad1e616873d 100644
--- a/airflow-ctl/docs/installation/installing-from-sources.rst
+++ b/airflow-ctl/airflow-ctl/docs/installation/installing-from-sources.rst
@@ -23,8 +23,9 @@ Released packages
.. jinja:: official_download_page
- This page describes downloading and verifying Airflow Ctl version ``|version|`` using officially released packages.
- You can also install ``airflowctl`` - as most Python packages - via :doc:`PyPI <installing-from-pypi>`.
+ This page describes downloading and verifying Airflow® version
+ ``{{ airflow_version }}`` using officially released packages.
+ You can also install ``Apache airflowctl`` - as most Python packages - via :doc:`PyPI <installing-from-pypi>`.
You can choose different version of Airflow by selecting a different version from the drop-down at
the top-left of the page.
@@ -33,13 +34,22 @@ can use if you want to verify the origin of the packages and want to verify chec
the packages. The packages are available via the
`Official Apache Software Foundations Downloads <https://dlcdn.apache.org/>`_
-The {{ airflowctl_version }} downloads of Airflow Ctl are available at:
+As of version 2.8 Airflow follows PEP 517/518 and uses ``pyproject.toml`` file to define build dependencies
+and build process and it requires relatively modern versions of packaging tools to get airflow built from
+local sources or ``sdist`` packages, as PEP 517 compliant build hooks are used to determine dynamic build
+dependencies. In case of ``pip`` it means that at least version 22.1.0 is needed (released at the beginning of
+2022) to build or install Airflow from sources. This does not affect the ability of installing Airflow from
+released wheel packages.
+
+The |version| downloads of airflowctl are available at:
.. jinja:: official_download_page
- * `Sources package for airflow-ctl: <{{ closer_lua_url }}/apache_airflow_ctl-{{ airflowctl_version }}-source.tar.gz>`__ (`asc <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-source.tar.gz.asc>`__, `sha512 <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-source.tar.gz.sha512>`__)
- * `Sdist package for airflow-ctl distributions <{{ closer_lua_url }}/apache_airflow_ctl-{{ airflowctl_version }}.tar.gz>`__ (`asc <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}.tar.gz.asc>`__, `sha512 <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}.tar.gz.sha512>`__)
- * `Whl package for airflow-ctl distribution <{{ closer_lua_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl>`__ (`asc <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl.asc>`__, `sha512 <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl.sha512>`__)
+ * `Sources package for airflow <{{ closer_lua_url }}/apache-airflow-ctl-{{ airflowctl_version }}-source.tar.gz>`__ (`asc <{{ base_url }}/apache-airflow-ctl-{{ airflowctl_version }}-source.tar.gz.asc>`__, `sha512 <{{ base_url }}/apache-airflow-ctl-{{ airflowctl_version }}-source.tar.gz.sha512>`__)
+ * `Sdist package for airflow meta package <{{ closer_lua_url }}/apache-airflow-ctl-{{ airflowctl_version }}.tar.gz>`__ (`asc <{{ base_url }}/apache-airflow-ctl-{{ airflowctl_version }}.tar.gz.asc>`__, `sha512 <{{ base_url }}/apache-airflow-ctl-{{ airflowctl_version }}.tar.gz.sha512>`__)
+ * `Whl package for airflow meta package <{{ closer_lua_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl>`__ (`asc <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl.asc>`__, `sha512 <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl.sha512>`__)
+ * `Sdist package for airflow core package <{{ closer_lua_url }}/apache-airflow_ctl-{{ airflowctl_version }}.tar.gz>`__ (`asc <{{ base_url }}/apache-airflow_ctl-{{ airflowctl_version }}.tar.gz.asc>`__, `sha512 <{{ base_url }}/apache-airflow_ctl-{{ airflowctl_version }}.tar.gz.sha512>`__)
+ * `Whl package for airflow core package <{{ closer_lua_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl>`__ (`asc <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl.asc>`__, `sha512 <{{ base_url }}/apache_airflow_ctl-{{ airflowctl_version }}-py3-none-any.whl.sha512>`__)
If you want to install from the source code, you can download from the sources link above, it will contain
a ``INSTALL`` file containing details on how you can build and install airflowctl.
diff --git a/airflow-ctl/docs/installation/prerequisites.rst b/airflow-ctl/airflow-ctl/docs/installation/prerequisites.rst
similarity index 100%
copy from airflow-ctl/docs/installation/prerequisites.rst
copy to airflow-ctl/airflow-ctl/docs/installation/prerequisites.rst
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/docs/redirects.txt
similarity index 82%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/docs/redirects.txt
index 32a64efabc5..93082af1472 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/docs/redirects.txt
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,14 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+# References
+cli-ref.rst cli-and-env-variables-ref.rst
+Quick Start start.rst
+changelog.rst changelog.rst
+
+# Installation.rst
+installation/index.rst
-__version__ = "0.1.0"
+# How to
+howto/index.rst
diff --git a/airflow-ctl/airflow-ctl/docs/security.rst b/airflow-ctl/airflow-ctl/docs/security.rst
new file mode 100644
index 00000000000..8ec5ea6d6f4
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/security.rst
@@ -0,0 +1,30 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+Security
+========
+airflowctl is leveraging Apache Airflow Public API security features and additional layers of security to ensure that your data is safe and secure.
+airflowctl facilitates the seamless deployment of CLI and API features together, reducing redundancy and simplifying maintenance. Transitioning from direct database access to an API-driven model will enhance the CLI's capabilities and improve security.
+
+- **Authentication**: airflowctl uses authentication to ensure that only authorized users can access the system. This is done using an API Token. See more on https://airflow.apache.org/docs/apache-airflow/stable/security/api.html
+
+- **Keyring**: airflowctl uses keyring to store the API Token securely. This ensures that the Token is not stored in plain text and is only accessible to authorized users.
+
+airflowctl API Token has its own expiration time. The default is 1 hour. You can change it in the Airflow configuration file (airflow.cfg) by setting the ``jwt_cli_expiration_time`` parameter under the ``[api_auth]`` section. The value is in seconds. This will impact all users using ``airflowctl``.
+
+For more information see `Setting Configuration
+Options <https://airflow.apache.org/docs/apache-airflow/stable/howto/set-config.html>`__.
diff --git a/airflow-ctl/airflow-ctl/docs/start.rst b/airflow-ctl/airflow-ctl/docs/start.rst
new file mode 100644
index 00000000000..fc879092c8c
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/start.rst
@@ -0,0 +1,52 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+Quick Start
+-----------
+
+Let's first install ``airflowctl`` if you haven't already:
+
+From PyPI: :doc:`/installation/installing-from-pypi`
+
+From source: :doc:`/installation/installing-from-sources`
+
+airflowctl is a command line tool that helps you manage your Airflow deployments.
+It is designed to be easy to use and provides a simple interface for managing your Airflow environment.
+
+To get started, you can use the following command to create a new airflowctl environment:
+
+.. code-block:: bash
+
+ airflowctl auth login --username <username> --password <password> --api-url <api_url> --env <env_name>
+
+To persist the environment, you can set ``AIRFLOW_CLI_ENVIRONMENT``.
+The environment variable should be the name of the environment you want to use.
+This will allow users to switch environments easily.
+
+OR
+
+.. code-block:: bash
+
+ export AIRFLOW_CLI_TOKEN=<token>
+ airflowctl auth login --api-url <api_url> --env <env_name>
+
+This command will create a new airflowctl environment with the specified username and password.
+You can then use the following command to start the airflowctl environment:
+
+.. code-block:: bash
+
+ airflowctl --help
diff --git a/airflow-ctl/airflow-ctl/docs/static/exampleinclude.css b/airflow-ctl/airflow-ctl/docs/static/exampleinclude.css
new file mode 100644
index 00000000000..b4f2a42dcc7
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/static/exampleinclude.css
@@ -0,0 +1,86 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.example-header {
+ position: relative;
+ background: #9aaa7a;
+ padding: 8px 16px;
+ margin-bottom: 0;
+}
+
+.example-header--with-button {
+ padding-right: 166px;
+}
+
+.example-header::after {
+ content: '';
+ display: table;
+ clear: both;
+}
+
+.example-title {
+ display: block;
+ padding: 4px;
+ margin-right: 16px;
+ color: #fff;
+ overflow-x: auto;
+}
+
+.example-header-button {
+ top: 8px;
+ right: 16px;
+ position: absolute;
+}
+
+.example-header + .highlight-python {
+ margin-top: 0 !important;
+}
+
+.viewcode-button {
+ display: inline-block;
+ padding: 8px 16px;
+ border: 0;
+ margin: 0;
+ outline: 0;
+ border-radius: 2px;
+ box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.3);
+ color: #404040;
+ background-color: #e7e7e7;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 1;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ text-transform: uppercase;
+ transition: background-color 0.2s;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.viewcode-button:visited {
+ color: #404040;
+}
+
+.viewcode-button:hover,
+.viewcode-button:focus {
+ color: #404040;
+ background-color: #d6d6d6;
+}
diff --git a/airflow-ctl/airflow-ctl/docs/static/gh-jira-links.js b/airflow-ctl/airflow-ctl/docs/static/gh-jira-links.js
new file mode 100644
index 00000000000..dfbfb15ad88
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/static/gh-jira-links.js
@@ -0,0 +1,36 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+document.addEventListener('DOMContentLoaded', function() {
+ var el = document.getElementById('release-notes');
+ if (el !== null ) {
+ // [AIRFLOW-...]
+ el.innerHTML = el.innerHTML.replace(
+ /\[(AIRFLOW-[\d]+)\]/g,
+ `<a href="https://issues.apache.org/jira/browse/$1">[$1]</a>`
+ );
+ // (#...)
+ el.innerHTML = el.innerHTML.replace(
+ /#([\d]+)/g,
+ (match, group1) => {
+ return `<a href="https://github.com/apache/airflow/pull/${group1}">#${group1}</a>`
+ }
+ );
+ };
+})
diff --git a/airflow-ctl/airflow-ctl/docs/static/redirects.js b/airflow-ctl/airflow-ctl/docs/static/redirects.js
new file mode 100644
index 00000000000..4b44336e440
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/static/redirects.js
@@ -0,0 +1,29 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+document.addEventListener("DOMContentLoaded", function () {
+ const redirects = {
+ "zombie-undead-tasks": "task-instance-heartbeat-timeout",
+ "zombie-tasks": "task-instance-heartbeat-timeout",
+ };
+ const fragment = window.location.hash.substring(1);
+ if (redirects[fragment]) {
+ window.location.hash = redirects[fragment];
+ }
+});
diff --git a/airflow-ctl/airflow-ctl/docs/templates/footer.html b/airflow-ctl/airflow-ctl/docs/templates/footer.html
new file mode 100644
index 00000000000..10c1500f3ee
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/docs/templates/footer.html
@@ -0,0 +1,29 @@
+{#
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+#}
+
+{% extends "!footer.html" %}
+
+{% block extrafooter %}
+ <div class="footer">This page uses <a href="https://analytics.google.com/">
+ Google Analytics</a> to collect statistics. You can disable it by blocking
+ the JavaScript coming from www.google-analytics.com. Check our
+ <a href="{{ pathto('privacy_notice') }}">Privacy Policy</a>
+ for more details.
+ </div>
+{% endblock %}
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/newsfragments/config.toml
similarity index 56%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/newsfragments/config.toml
index 32a64efabc5..c1f16b10572 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/newsfragments/config.toml
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,37 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
+[tool.towncrier]
+name = "Airflow CTL"
+filename = "RELEASE_NOTES.rst"
+underlines = ["-", '^']
+
+[[tool.towncrier.type]]
+directory = "significant"
+name = "Significant Changes"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "feature"
+name = "Features"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "improvement"
+name = "Improvements"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "bugfix"
+name = "Bug Fixes"
+showcontent = true
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+[[tool.towncrier.type]]
+directory = "doc"
+name = "Doc only Changes"
+showcontent = true
-__version__ = "0.1.0"
+[[tool.towncrier.type]]
+directory = "misc"
+name = "Misc"
+showcontent = true
diff --git a/airflow-ctl/pyproject.toml b/airflow-ctl/airflow-ctl/pyproject.toml
similarity index 87%
copy from airflow-ctl/pyproject.toml
copy to airflow-ctl/airflow-ctl/pyproject.toml
index 8c2fe12a7c5..bbfeffcd72d 100644
--- a/airflow-ctl/pyproject.toml
+++ b/airflow-ctl/airflow-ctl/pyproject.toml
@@ -20,8 +20,6 @@ name = "apache-airflow-ctl"
dynamic = ["version"]
description = "Apache Airflow command line tool for communicating with an Apache Airflow, using the API."
readme = { file = "README.md", content-type = "text/markdown" }
-license = "Apache-2.0"
-license-files = ["LICENSE", "NOTICE"]
# We do not want to upper-bind Python version, as we do not know if we will support Python 3.14+
# out-of-the box. Airflow-ctl is a small tool that does not have many dependencies and does not use
# sophisticated features of Python, so it should work with Python 3.14+ once all it's dependencies are
@@ -46,20 +44,6 @@ classifiers = [
"Framework :: Apache Airflow",
]
-[project.urls]
-"Bug Tracker" = "https://github.com/apache/airflow/issues"
-Documentation = "https://airflow.apache.org/docs/apache-airflow-ctl/stable/index.html"
-Downloads = "https://archive.apache.org/dist/airflow/airflow-ctl/"
-Homepage = "https://airflow.apache.org/"
-"Release Notes" = "https://airflow.apache.org/docs/apache-airflow-ctl/stable/changelog.html"
-"Slack Chat" = "https://s.apache.org/airflow-slack"
-"Source Code" = "https://github.com/apache/airflow"
-LinkedIn = "https://www.linkedin.com/company/apache-airflow/"
-Mastodon = "https://fosstodon.org/@airflow"
-Bluesky = "https://bsky.app/profile/apache-airflow.bsky.social"
-YouTube = "https://www.youtube.com/channel/UCSXwxpWZQ7XZ1WL3wqevChA/"
-
-
[project.optional-dependencies]
"dev" = [
"keyrings.alt>=5.0.2",
@@ -163,7 +147,7 @@ input = "../airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-ge
output = "src/airflowctl/api/datamodels/generated.py"
## pytest settings ##
-[tool.pytest]
+[tool.pytest.ini_options]
addopts = [
"--tb=short",
"-rasl",
@@ -196,7 +180,7 @@ testpaths = [
]
asyncio_default_fixture_loop_scope = "function"
-pythonpath = ["tests"]
+pythonpath = "tests"
# Keep temporary directories (created by `tmp_path`) for 2 recent runs only failed tests.
tmp_path_retention_count = "2"
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/__init__.py
similarity index 100%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/__init__.py
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/__main__.py
similarity index 67%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/__main__.py
index 32a64efabc5..c8af217f250 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/__main__.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env python
+# PYTHON_ARGCOMPLETE_OK
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
@@ -15,8 +17,22 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+"""Main executable module."""
+
from __future__ import annotations
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+import argcomplete
+
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.cli_config import safe_call_command
+
+
+def main():
+ parser = cli_parser.get_parser()
+ argcomplete.autocomplete(parser)
+ args = parser.parse_args()
+ safe_call_command(args.func, args=args)
+
-__version__ = "0.1.0"
+if __name__ == "__main__":
+ main()
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/api/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/api/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/api/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/api/client.py b/airflow-ctl/airflow-ctl/src/airflowctl/api/client.py
new file mode 100644
index 00000000000..9e138ffc87b
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/api/client.py
@@ -0,0 +1,358 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import contextlib
+import enum
+import json
+import os
+import sys
+from collections.abc import Callable
+from functools import wraps
+from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, cast
+
+import httpx
+import keyring
+import structlog
+from httpx import URL
+from keyring.errors import NoKeyringError
+from uuid6 import uuid7
+
+from airflowctl import __version__ as version
+from airflowctl.api.operations import (
+ AssetsOperations,
+ BackfillOperations,
+ ConfigOperations,
+ ConnectionsOperations,
+ DagRunOperations,
+ DagsOperations,
+ JobsOperations,
+ LoginOperations,
+ PoolsOperations,
+ ProvidersOperations,
+ ServerResponseError,
+ VariablesOperations,
+ VersionOperations,
+)
+from airflowctl.exceptions import (
+ AirflowCtlCredentialNotFoundException,
+ AirflowCtlException,
+ AirflowCtlNotFoundException,
+)
+
+if TYPE_CHECKING:
+ # # methodtools doesn't have typestubs, so give a stub
+ def lru_cache(maxsize: int | None = 128):
+ def wrapper(f):
+ return f
+
+ return wrapper
+else:
+ from methodtools import lru_cache
+
+log = structlog.get_logger(logger_name=__name__)
+
+__all__ = [
+ "Client",
+ "Credentials",
+ "provide_api_client",
+ "NEW_API_CLIENT",
+ "ClientKind",
+ "ServerResponseError",
+]
+
+PS = ParamSpec("PS")
+RT = TypeVar("RT")
+
+
+class ClientKind(enum.Enum):
+ """Client kind enum."""
+
+ CLI = "cli"
+ AUTH = "auth"
+
+
+def add_correlation_id(request: httpx.Request):
+ request.headers["correlation-id"] = str(uuid7())
+
+
+def get_json_error(response: httpx.Response):
+ """Raise a ServerResponseError if we can extract error info from the error."""
+ err = ServerResponseError.from_response(response)
+ if err:
+ # This part is used in integration tests to verify the error message
+ # If you are updating here don't forget to update the airflow-ctl-tests
+ log.warning("Server error ", extra=dict(err.response.json()))
+ raise err
+
+
+def raise_on_4xx_5xx(response: httpx.Response):
+ return get_json_error(response) or response.raise_for_status()
+
+
+# Credentials for the API
+class Credentials:
+ """Credentials for the API."""
+
+ api_url: str | None
+ api_token: str | None
+ api_environment: str
+
+ def __init__(
+ self,
+ api_url: str | None = None,
+ api_token: str | None = None,
+ client_kind: ClientKind | None = None,
+ api_environment: str = "production",
+ ):
+ self.api_url = api_url
+ self.api_token = api_token
+ self.api_environment = os.getenv("AIRFLOW_CLI_ENVIRONMENT") or api_environment
+ self.client_kind = client_kind
+
+ @property
+ def input_cli_config_file(self) -> str:
+ """Generate path for the CLI config file."""
+ return f"{self.api_environment}.json"
+
+ def save(self):
+ """Save the credentials to keyring and URL to disk as a file."""
+ default_config_dir = os.environ.get("AIRFLOW_HOME", os.path.expanduser("~/airflow"))
+ os.makedirs(default_config_dir, exist_ok=True)
+ with open(os.path.join(default_config_dir, self.input_cli_config_file), "w") as f:
+ json.dump({"api_url": self.api_url}, f)
+
+ try:
+ if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
+ with open(
+ os.path.join(default_config_dir, f"debug_creds_{self.input_cli_config_file}"), "w"
+ ) as f:
+ json.dump({f"api_token_{self.api_environment}": self.api_token}, f)
+ else:
+ keyring.set_password("airflowctl", f"api_token_{self.api_environment}", self.api_token)
+ except NoKeyringError as e:
+ log.error(e)
+ except TypeError as e:
+ # This happens when the token is None, which is not allowed by keyring
+ if self.api_token is None and self.client_kind == ClientKind.CLI:
+ raise AirflowCtlCredentialNotFoundException("No API token found. Please login first.") from e
+
+ def load(self) -> Credentials:
+ """Load the credentials from keyring and URL from disk file."""
+ default_config_dir = os.environ.get("AIRFLOW_HOME", os.path.expanduser("~/airflow"))
+ config_path = os.path.join(default_config_dir, self.input_cli_config_file)
+ try:
+ with open(config_path) as f:
+ credentials = json.load(f)
+ self.api_url = credentials["api_url"]
+ if os.getenv("AIRFLOW_CLI_DEBUG_MODE") == "true":
+ debug_creds_path = os.path.join(
+ default_config_dir, f"debug_creds_{self.input_cli_config_file}"
+ )
+ with open(debug_creds_path) as df:
+ debug_credentials = json.load(df)
+ self.api_token = debug_credentials.get(f"api_token_{self.api_environment}")
+ else:
+ self.api_token = keyring.get_password("airflowctl", f"api_token_{self.api_environment}")
+ except FileNotFoundError:
+ if self.client_kind == ClientKind.AUTH:
+ # Saving the URL set from the Auth Commands if Kind is AUTH
+ self.save()
+ elif self.client_kind == ClientKind.CLI:
+ raise AirflowCtlCredentialNotFoundException(
+ f"No credentials found in {default_config_dir} for environment {self.api_environment}."
+ )
+ else:
+ raise AirflowCtlException(f"Unknown client kind: {self.client_kind}")
+
+ return self
+
+
+class BearerAuth(httpx.Auth):
+ def __init__(self, token: str):
+ self.token: str = token
+
+ def auth_flow(self, request: httpx.Request):
+ if self.token:
+ request.headers["Authorization"] = "Bearer " + self.token
+ yield request
+
+
+class Client(httpx.Client):
+ """Client for the Airflow REST API."""
+
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ token: str,
+ kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI,
+ **kwargs: Any,
+ ) -> None:
+ auth = BearerAuth(token)
+ kwargs["base_url"] = self._get_base_url(base_url=base_url, kind=kind)
+ pyver = f"{'.'.join(map(str, sys.version_info[:3]))}"
+ super().__init__(
+ auth=auth,
+ headers={"user-agent": f"apache-airflow-ctl/{version} (Python/{pyver})"},
+ event_hooks={"response": [raise_on_4xx_5xx], "request": [add_correlation_id]},
+ **kwargs,
+ )
+
+ def refresh_base_url(
+ self, base_url: str, kind: Literal[ClientKind.AUTH, ClientKind.CLI] = ClientKind.CLI
+ ):
+ """Refresh the base URL of the client."""
+ self.base_url = URL(self._get_base_url(base_url=base_url, kind=kind))
+
+ @classmethod
+ def _get_base_url(
+ cls, base_url: str, kind: Literal[ClientKind.AUTH, ClientKind.CLI] = ClientKind.CLI
+ ) -> str:
+ """Get the base URL of the client."""
+ if kind == ClientKind.AUTH:
+ return f"{base_url}/auth"
+ return f"{base_url}/api/v2"
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def login(self):
+ """Operations related to authentication."""
+ return LoginOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def assets(self):
+ """Operations related to assets."""
+ return AssetsOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def backfills(self):
+ """Operations related to backfills."""
+ return BackfillOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def configs(self):
+ """Operations related to configs."""
+ return ConfigOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def connections(self):
+ """Operations related to connections."""
+ return ConnectionsOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def dags(self):
+ """Operations related to DAGs."""
+ return DagsOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def dag_runs(self):
+ """Operations related to DAG runs."""
+ return DagRunOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def jobs(self):
+ """Operations related to jobs."""
+ return JobsOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def pools(self):
+ """Operations related to pools."""
+ return PoolsOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def providers(self):
+ """Operations related to providers."""
+ return ProvidersOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def variables(self):
+ """Operations related to variables."""
+ return VariablesOperations(self)
+
+ @lru_cache() # type: ignore[prop-decorator]
+ @property
+ def version(self):
+ """Get the version of the server."""
+ return VersionOperations(self)
+
+
+# API Client Decorator for CLI Actions
+@contextlib.contextmanager
+def get_client(kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI):
+ """
+ Get CLI API client.
+
+ Don't call this method, please use @provide_api_client decorator instead.
+ """
+ api_client = None
+ try:
+ # API URL always loaded from the config file, please save with it if you are using other than ClientKind.CLI
+ credentials = Credentials(client_kind=kind).load()
+ api_client = Client(
+ base_url=credentials.api_url or "http://localhost:8080",
+ limits=httpx.Limits(max_keepalive_connections=1, max_connections=1),
+ token=credentials.api_token or str(os.getenv("AIRFLOW_CLI_TOKEN", "")),
+ kind=kind,
+ )
+ yield api_client
+ except AirflowCtlNotFoundException as e:
+ raise e
+ finally:
+ if api_client:
+ api_client.close()
+
+
+def provide_api_client(
+ kind: Literal[ClientKind.CLI, ClientKind.AUTH] = ClientKind.CLI,
+) -> Callable[[Callable[PS, RT]], Callable[PS, RT]]:
+ """
+ Provide a CLI API Client to the decorated function.
+
+ CLI API Client shouldn't be passed to the function when this wrapper is used
+ if the purpose is not mocking or testing.
+ If you want to reuse a CLI API Client or run the function as part of
+ API call, you pass it to the function, if not this wrapper
+ will create one and close it for you.
+ """
+
+ def decorator(func: Callable[PS, RT]) -> Callable[PS, RT]:
+ @wraps(func)
+ def wrapper(*args, **kwargs) -> RT:
+ if "api_client" not in kwargs:
+ with get_client(kind=kind) as api_client:
+ return func(*args, api_client=api_client, **kwargs)
+ # The CLI API Client should be only passed for Mocking and Testing
+ return func(*args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+NEW_API_CLIENT: Client = cast("Client", None)
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/auth_generated.py b/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/auth_generated.py
new file mode 100644
index 00000000000..0a100364599
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/auth_generated.py
@@ -0,0 +1,47 @@
+# generated by datamodel-codegen:
+# filename: v2-simple-auth-manager-generated.yaml
+# version: 0.33.0
+
+from __future__ import annotations
+
+from typing import Annotated, Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class HTTPExceptionResponse(BaseModel):
+ """
+ HTTPException Model used for error response.
+ """
+
+ detail: Annotated[str | dict[str, Any], Field(title="Detail")]
+
+
+class LoginBody(BaseModel):
+ """
+ Login serializer for post bodies.
+ """
+
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ username: Annotated[str, Field(title="Username")]
+ password: Annotated[str, Field(title="Password")]
+
+
+class LoginResponse(BaseModel):
+ """
+ Login serializer for responses.
+ """
+
+ access_token: Annotated[str, Field(title="Access Token")]
+
+
+class ValidationError(BaseModel):
+ loc: Annotated[list[str | int], Field(title="Location")]
+ msg: Annotated[str, Field(title="Message")]
+ type: Annotated[str, Field(title="Error Type")]
+
+
+class HTTPValidationError(BaseModel):
+ detail: Annotated[list[ValidationError] | None, Field(title="Detail")] = None
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/generated.py
similarity index 95%
copy from airflow-ctl/src/airflowctl/api/datamodels/generated.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index f546515584a..2bf729114b8 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -49,6 +49,16 @@ class AssetAliasResponse(BaseModel):
group: Annotated[str, Field(title="Group")]
+class AssetWatcherResponse(BaseModel):
+ """
+ Asset watcher serializer for responses.
+ """
+
+ name: Annotated[str, Field(title="Name")]
+ trigger_id: Annotated[int, Field(title="Trigger Id")]
+ created_date: Annotated[datetime, Field(title="Created Date")]
+
+
class BaseInfoResponse(BaseModel):
"""
Base info serializer for responses.
@@ -658,7 +668,6 @@ class ProviderResponse(BaseModel):
package_name: Annotated[str, Field(title="Package Name")]
description: Annotated[str, Field(title="Description")]
version: Annotated[str, Field(title="Version")]
- documentation_url: Annotated[str | None, Field(title="Documentation Url")] = None
class QueuedEventResponse(BaseModel):
@@ -1054,6 +1063,7 @@ class AssetResponse(BaseModel):
producing_tasks: Annotated[list[TaskOutletAssetReference], Field(title="Producing Tasks")]
consuming_tasks: Annotated[list[TaskInletAssetReference], Field(title="Consuming Tasks")]
aliases: Annotated[list[AssetAliasResponse], Field(title="Aliases")]
+ watchers: Annotated[list[AssetWatcherResponse], Field(title="Watchers")]
last_asset_event: LastAssetEventResponse | None = None
@@ -1072,7 +1082,6 @@ class BackfillPostBody(BaseModel):
dag_run_conf: Annotated[dict[str, Any] | None, Field(title="Dag Run Conf")] = {}
reprocess_behavior: ReprocessBehavior | None = "none"
max_active_runs: Annotated[int | None, Field(title="Max Active Runs")] = 10
- run_on_latest_version: Annotated[bool | None, Field(title="Run On Latest Version")] = True
class BackfillResponse(BaseModel):
@@ -1161,6 +1170,13 @@ class BulkUpdateActionBulkTaskInstanceBody(BaseModel):
entities: Annotated[
list[BulkTaskInstanceBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1174,6 +1190,13 @@ class BulkUpdateActionConnectionBody(BaseModel):
entities: Annotated[
list[ConnectionBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1187,6 +1210,13 @@ class BulkUpdateActionPoolBody(BaseModel):
entities: Annotated[
list[PoolBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1200,6 +1230,13 @@ class BulkUpdateActionVariableBody(BaseModel):
entities: Annotated[
list[VariableBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1390,6 +1427,11 @@ class DAGRunsBatchBody(BaseModel):
end_date_gt: Annotated[datetime | None, Field(title="End Date Gt")] = None
end_date_lte: Annotated[datetime | None, Field(title="End Date Lte")] = None
end_date_lt: Annotated[datetime | None, Field(title="End Date Lt")] = None
+ duration_gte: Annotated[float | None, Field(title="Duration Gte")] = None
+ duration_gt: Annotated[float | None, Field(title="Duration Gt")] = None
+ duration_lte: Annotated[float | None, Field(title="Duration Lte")] = None
+ duration_lt: Annotated[float | None, Field(title="Duration Lt")] = None
+ conf_contains: Annotated[str | None, Field(title="Conf Contains")] = None
class DAGVersionCollectionResponse(BaseModel):
@@ -1441,6 +1483,26 @@ class EventLogCollectionResponse(BaseModel):
total_entries: Annotated[int, Field(title="Total Entries")]
+class HITLDetailHistory(BaseModel):
+ """
+ Schema for Human-in-the-loop detail history.
+ """
+
+ options: Annotated[list[str], Field(min_length=1, title="Options")]
+ subject: Annotated[str, Field(title="Subject")]
+ body: Annotated[str | None, Field(title="Body")] = None
+ defaults: Annotated[list[str] | None, Field(title="Defaults")] = None
+ multiple: Annotated[bool | None, Field(title="Multiple")] = False
+ params: Annotated[dict[str, Any] | None, Field(title="Params")] = None
+ assigned_users: Annotated[list[HITLUser] | None, Field(title="Assigned Users")] = None
+ created_at: Annotated[datetime, Field(title="Created At")]
+ responded_by_user: HITLUser | None = None
+ responded_at: Annotated[datetime | None, Field(title="Responded At")] = None
+ chosen_options: Annotated[list[str] | None, Field(title="Chosen Options")] = None
+ params_input: Annotated[dict[str, Any] | None, Field(title="Params Input")] = None
+ response_received: Annotated[bool | None, Field(title="Response Received")] = False
+
+
class HITLDetailResponse(BaseModel):
"""
Response of updating a Human-in-the-loop detail.
@@ -1605,6 +1667,7 @@ class TaskInstanceHistoryResponse(BaseModel):
executor: Annotated[str | None, Field(title="Executor")] = None
executor_config: Annotated[str, Field(title="Executor Config")]
dag_version: DagVersionResponse | None = None
+ hitl_detail: HITLDetailHistory | None = None
class TaskInstanceResponse(BaseModel):
@@ -1839,7 +1902,6 @@ class HITLDetail(BaseModel):
Schema for Human-in-the-loop detail.
"""
- task_instance: TaskInstanceResponse
options: Annotated[list[str], Field(min_length=1, title="Options")]
subject: Annotated[str, Field(title="Subject")]
body: Annotated[str | None, Field(title="Body")] = None
@@ -1853,6 +1915,7 @@ class HITLDetail(BaseModel):
chosen_options: Annotated[list[str] | None, Field(title="Chosen Options")] = None
params_input: Annotated[dict[str, Any] | None, Field(title="Params Input")] = None
response_received: Annotated[bool | None, Field(title="Response Received")] = False
+ task_instance: TaskInstanceResponse
class HITLDetailCollection(BaseModel):
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/api/operations.py b/airflow-ctl/airflow-ctl/src/airflowctl/api/operations.py
new file mode 100644
index 00000000000..31e0298a19d
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/api/operations.py
@@ -0,0 +1,699 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import datetime
+from typing import TYPE_CHECKING, Any, TypeVar
+
+import httpx
+import structlog
+from pydantic import BaseModel
+
+from airflowctl.api.datamodels.auth_generated import LoginBody, LoginResponse
+from airflowctl.api.datamodels.generated import (
+ AssetAliasCollectionResponse,
+ AssetAliasResponse,
+ AssetCollectionResponse,
+ AssetEventResponse,
+ AssetResponse,
+ BackfillCollectionResponse,
+ BackfillPostBody,
+ BackfillResponse,
+ BulkBodyConnectionBody,
+ BulkBodyPoolBody,
+ BulkBodyVariableBody,
+ BulkResponse,
+ Config,
+ ConnectionBody,
+ ConnectionCollectionResponse,
+ ConnectionResponse,
+ ConnectionTestResponse,
+ CreateAssetEventsBody,
+ DAGCollectionResponse,
+ DAGDetailsResponse,
+ DAGPatchBody,
+ DAGResponse,
+ DAGRunCollectionResponse,
+ DAGRunResponse,
+ DagStatsCollectionResponse,
+ DAGTagCollectionResponse,
+ DAGVersionCollectionResponse,
+ DagVersionResponse,
+ DAGWarningCollectionResponse,
+ ImportErrorCollectionResponse,
+ ImportErrorResponse,
+ JobCollectionResponse,
+ PoolBody,
+ PoolCollectionResponse,
+ PoolPatchBody,
+ PoolResponse,
+ ProviderCollectionResponse,
+ QueuedEventCollectionResponse,
+ QueuedEventResponse,
+ TriggerDAGRunPostBody,
+ VariableBody,
+ VariableCollectionResponse,
+ VariableResponse,
+ VersionInfo,
+)
+from airflowctl.exceptions import AirflowCtlConnectionException
+
+if TYPE_CHECKING:
+ from airflowctl.api.client import Client
+
+log = structlog.get_logger(logger_name=__name__)
+
+T = TypeVar("T", bound=BaseModel)
+
+
+# Generic Server Response Error
+class ServerResponseError(httpx.HTTPStatusError):
+ """Server response error (Generic)."""
+
+ @classmethod
+ def from_response(cls, response: httpx.Response) -> ServerResponseError | None:
+ if response.status_code < 400:
+ return None
+
+ if response.headers.get("content-type") != "application/json":
+ return None
+
+ if 400 <= response.status_code < 500:
+ response.read()
+ return cls(
+ message=f"Client error message: {response.json()}",
+ request=response.request,
+ response=response,
+ )
+
+ msg = response.json()
+
+ self = cls(message=msg, request=response.request, response=response)
+ return self
+
+
+def _check_flag_and_exit_if_server_response_error(func):
+ """Return decorator to check for ServerResponseError and exit if the server is not running."""
+
+ def _exit_if_server_response_error(response: Any | ServerResponseError):
+ if isinstance(response, ServerResponseError):
+ raise response
+ return response
+
+ def wrapped(self, *args, **kwargs):
+ try:
+ if self.exit_in_error:
+ return _exit_if_server_response_error(response=func(self, *args, **kwargs))
+ return func(self, *args, **kwargs)
+ except httpx.ConnectError as e:
+ if "Connection refused" in str(e):
+ raise AirflowCtlConnectionException("Connection refused. Is the API server running?")
+ raise AirflowCtlConnectionException(f"Connection error: {e}")
+
+ return wrapped
+
+
+class BaseOperations:
+ """
+ Base class for operations.
+
+ This class is used to decorate all callable methods with a check for ServerResponseError.
+ Set exit_in_error false to not exit.
+ """
+
+ __slots__ = ("client", "response", "exit_in_error")
+
+ def __init__(self, client: Client, response=None, exit_in_error: bool = True):
+ self.client = client
+ self.response = response
+ self.exit_in_error = exit_in_error
+
+ def __init_subclass__(cls, **kwargs):
+ """Decorate all callable methods with a check for ServerResponseError and exit if the server is not running."""
+ super().__init_subclass__(**kwargs)
+ for attr, value in cls.__dict__.items():
+ if callable(value):
+ setattr(cls, attr, _check_flag_and_exit_if_server_response_error(value))
+
+ def execute_list(
+ self,
+ *,
+ path: str,
+ data_model: type[T],
+ offset: int = 0,
+ limit: int = 50,
+ params: dict | None = None,
+ ) -> T | ServerResponseError:
+ shared_params = {**(params or {})}
+ self.response = self.client.get(path, params=shared_params)
+ first_pass = data_model.model_validate_json(self.response.content)
+ total_entries = first_pass.total_entries # type: ignore[attr-defined]
+ if total_entries < limit:
+ return first_pass
+ for key, value in first_pass.model_dump().items():
+ if key != "total_entries" and isinstance(value, list):
+ break
+ entry_list = getattr(first_pass, key)
+ offset = offset + limit
+ while offset < total_entries:
+ self.response = self.client.get(path, params={**shared_params, "offset": offset})
+ entry = data_model.model_validate_json(self.response.content)
+ offset = offset + limit
+ entry_list.extend(getattr(entry, key))
+ obj = data_model(**{key: entry_list, "total_entries": total_entries})
+ return data_model.model_validate(obj.model_dump())
+
+
+# Login operations
+class LoginOperations:
+ """Login operations."""
+
+ def __init__(self, client: Client):
+ self.client = client
+
+ def login_with_username_and_password(self, login: LoginBody) -> LoginResponse | ServerResponseError:
+ """Login to the API server."""
+ try:
+ return LoginResponse.model_validate_json(
+ self.client.post("/token/cli", json=login.model_dump(mode="json")).content
+ )
+ except ServerResponseError as e:
+ raise e
+
+
+# Operations
+class AssetsOperations(BaseOperations):
+ """Assets operations."""
+
+ def get(self, asset_id: str) -> AssetResponse | ServerResponseError:
+ """Get an asset from the API server."""
+ try:
+ self.response = self.client.get(f"assets/{asset_id}")
+ return AssetResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_by_alias(self, alias: str) -> AssetAliasResponse | ServerResponseError:
+ """Get an asset by alias from the API server."""
+ try:
+ self.response = self.client.get(f"assets/aliases/{alias}")
+ return AssetAliasResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(self) -> AssetCollectionResponse | ServerResponseError:
+ """List all assets from the API server."""
+ return super().execute_list(path="assets", data_model=AssetCollectionResponse)
+
+ def list_by_alias(self) -> AssetAliasCollectionResponse | ServerResponseError:
+ """List all assets by alias from the API server."""
+ return super().execute_list(path="/assets/aliases", data_model=AssetAliasCollectionResponse)
+
+ def create_event(
+ self, asset_event_body: CreateAssetEventsBody
+ ) -> AssetEventResponse | ServerResponseError:
+ """Create an asset event."""
+ try:
+ # Ensure extra is initialised before sent to API
+ if asset_event_body.extra is None:
+ asset_event_body.extra = {}
+ self.response = self.client.post("assets/events", json=asset_event_body.model_dump(mode="json"))
+ return AssetEventResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def materialize(self, asset_id: str) -> DAGRunResponse | ServerResponseError:
+ """Materialize an asset."""
+ try:
+ self.response = self.client.post(f"assets/{asset_id}/materialize")
+ return DAGRunResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_queued_events(self, asset_id: str) -> QueuedEventCollectionResponse | ServerResponseError:
+ """Get queued events for an asset."""
+ try:
+ self.response = self.client.get(f"assets/{asset_id}/queuedEvents")
+ return QueuedEventCollectionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_dag_queued_events(
+ self, dag_id: str, before: str
+ ) -> QueuedEventCollectionResponse | ServerResponseError:
+ """Get queued events for a dag."""
+ try:
+ self.response = self.client.get(f"dags/{dag_id}/assets/queuedEvents", params={"before": before})
+ return QueuedEventCollectionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_dag_queued_event(self, dag_id: str, asset_id: str) -> QueuedEventResponse | ServerResponseError:
+ """Get a queued event for a dag."""
+ try:
+ self.response = self.client.get(f"dags/{dag_id}/assets/{asset_id}/queuedEvents")
+ return QueuedEventResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def delete_queued_events(self, asset_id: str) -> str | ServerResponseError:
+ """Delete a queued event for an asset."""
+ try:
+ self.client.delete(f"assets/{asset_id}/queuedEvents/")
+ return asset_id
+ except ServerResponseError as e:
+ raise e
+
+ def delete_dag_queued_events(self, dag_id: str, before: str) -> str | ServerResponseError:
+ """Delete a queued event for a dag."""
+ try:
+ self.client.delete(f"assets/dags/{dag_id}/queuedEvents", params={"before": before})
+ return dag_id
+ except ServerResponseError as e:
+ raise e
+
+ def delete_queued_event(self, dag_id: str, asset_id: str) -> str | ServerResponseError:
+ """Delete a queued event for a dag."""
+ try:
+ self.client.delete(f"assets/dags/{dag_id}/assets/{asset_id}/queuedEvents/")
+ return asset_id
+ except ServerResponseError as e:
+ raise e
+
+
+class BackfillOperations(BaseOperations):
+ """Backfill operations."""
+
+ def create(self, backfill: BackfillPostBody) -> BackfillResponse | ServerResponseError:
+ """Create a backfill."""
+ try:
+ self.response = self.client.post("backfills", data=backfill.model_dump(mode="json"))
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def create_dry_run(self, backfill: BackfillPostBody) -> BackfillResponse | ServerResponseError:
+ """Create a dry run backfill."""
+ try:
+ self.response = self.client.post("backfills/dry_run", data=backfill.model_dump(mode="json"))
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
+ """Get a backfill."""
+ try:
+ self.response = self.client.get(f"backfills/{backfill_id}")
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(self, dag_id: str) -> BackfillCollectionResponse | ServerResponseError:
+ """List all backfills."""
+ params = {"dag_id": dag_id}
+ return super().execute_list(path="backfills", data_model=BackfillCollectionResponse, params=params)
+
+ def pause(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
+ """Pause a backfill."""
+ try:
+ self.response = self.client.post(f"backfills/{backfill_id}/pause")
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def unpause(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
+ """Unpause a backfill."""
+ try:
+ self.response = self.client.post(f"backfills/{backfill_id}/unpause")
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def cancel(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
+ """Cancel a backfill."""
+ try:
+ self.response = self.client.post(f"backfills/{backfill_id}/cancel")
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+
+class ConfigOperations(BaseOperations):
+ """Config operations."""
+
+ def get(self, section: str, option: str) -> Config | ServerResponseError:
+ """Get a config from the API server."""
+ try:
+ self.response = self.client.get(f"/config/section/{section}/option/{option}")
+ return Config.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(self) -> Config | ServerResponseError:
+ """List all configs from the API server."""
+ try:
+ self.response = self.client.get("/config")
+ return Config.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+
+class ConnectionsOperations(BaseOperations):
+ """Connection operations."""
+
+ def get(self, conn_id: str) -> ConnectionResponse | ServerResponseError:
+ """Get a connection from the API server."""
+ try:
+ self.response = self.client.get(f"connections/{conn_id}")
+ return ConnectionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(self) -> ConnectionCollectionResponse | ServerResponseError:
+ """List all connections from the API server."""
+ return super().execute_list(path="connections", data_model=ConnectionCollectionResponse)
+
+ def create(
+ self,
+ connection: ConnectionBody,
+ ) -> ConnectionResponse | ServerResponseError:
+ """Create a connection."""
+ try:
+ self.response = self.client.post("connections", json=connection.model_dump(mode="json"))
+ return ConnectionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def bulk(self, connections: BulkBodyConnectionBody) -> BulkResponse | ServerResponseError:
+ """CRUD multiple connections."""
+ try:
+ self.response = self.client.patch("connections", json=connections.model_dump(mode="json"))
+ return BulkResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def create_defaults(self) -> None | ServerResponseError:
+ """Create default connections."""
+ try:
+ self.response = self.client.post("connections/defaults")
+ return None
+ except ServerResponseError as e:
+ raise e
+
+ def delete(self, conn_id: str) -> str | ServerResponseError:
+ """Delete a connection."""
+ try:
+ self.client.delete(f"connections/{conn_id}")
+ return conn_id
+ except ServerResponseError as e:
+ raise e
+
+ def update(
+ self,
+ connection: ConnectionBody,
+ ) -> ConnectionResponse | ServerResponseError:
+ """Update a connection."""
+ try:
+ self.response = self.client.patch(
+ f"connections/{connection.connection_id}", json=connection.model_dump(mode="json")
+ )
+ return ConnectionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def test(
+ self,
+ connection: ConnectionBody,
+ ) -> ConnectionTestResponse | ServerResponseError:
+ """Test a connection."""
+ try:
+ self.response = self.client.post("connections/test", json=connection.model_dump(mode="json"))
+ return ConnectionTestResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+
+class DagsOperations(BaseOperations):
+ """Dags operations."""
+
+ def get(self, dag_id: str) -> DAGResponse | ServerResponseError:
+ """Get a DAG."""
+ try:
+ self.response = self.client.get(f"dags/{dag_id}")
+ return DAGResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_details(self, dag_id: str) -> DAGDetailsResponse | ServerResponseError:
+ """Get a DAG details."""
+ try:
+ self.response = self.client.get(f"dags/{dag_id}/details")
+ return DAGDetailsResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_tags(self) -> DAGTagCollectionResponse | ServerResponseError:
+ """Get all DAG tags."""
+ return super().execute_list(path="dagTags", data_model=DAGTagCollectionResponse)
+
+ def list(self) -> DAGCollectionResponse | ServerResponseError:
+ """List DAGs."""
+ return super().execute_list(path="dags", data_model=DAGCollectionResponse)
+
+ def update(self, dag_id: str, dag_body: DAGPatchBody) -> DAGResponse | ServerResponseError:
+ try:
+ self.response = self.client.patch(f"dags/{dag_id}", json=dag_body.model_dump(mode="json"))
+ return DAGResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def delete(self, dag_id: str) -> str | ServerResponseError:
+ try:
+ self.client.delete(f"dags/{dag_id}")
+ return dag_id
+ except ServerResponseError as e:
+ raise e
+
+ def get_import_error(self, import_error_id: str) -> ImportErrorResponse | ServerResponseError:
+ try:
+ self.response = self.client.get(f"importErrors/{import_error_id}")
+ return ImportErrorResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list_import_errors(self) -> ImportErrorCollectionResponse | ServerResponseError:
+ return super().execute_list(path="importErrors", data_model=ImportErrorCollectionResponse)
+
+ def get_stats(self, dag_ids: list) -> DagStatsCollectionResponse | ServerResponseError: # type: ignore
+ try:
+ self.response = self.client.get("dagStats", params={"dag_ids": dag_ids})
+ return DagStatsCollectionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def get_version(self, dag_id: str, version_number: int) -> DagVersionResponse | ServerResponseError:
+ try:
+ self.response = self.client.get(f"dags/{dag_id}/dagVersions/{version_number}")
+ return DagVersionResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list_version(self, dag_id: str) -> DAGVersionCollectionResponse | ServerResponseError:
+ return super().execute_list(
+ path=f"dags/{dag_id}/dagVersions", data_model=DAGVersionCollectionResponse
+ )
+
+ def list_warning(self) -> DAGWarningCollectionResponse | ServerResponseError:
+ return super().execute_list(path="dagWarnings", data_model=DAGWarningCollectionResponse)
+
+ def trigger(
+ self, dag_id: str, trigger_dag_run: TriggerDAGRunPostBody
+ ) -> DAGRunResponse | ServerResponseError:
+ """Create a dag run."""
+ if trigger_dag_run.conf is None:
+ trigger_dag_run.conf = {}
+ try:
+ self.response = self.client.post(
+ f"dags/{dag_id}/dagRuns", json=trigger_dag_run.model_dump(mode="json")
+ )
+ return DAGRunResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+
+class DagRunOperations(BaseOperations):
+ """Dag run operations."""
+
+ def get(self, dag_id: str, dag_run_id: str) -> DAGRunResponse | ServerResponseError:
+ """Get a dag run."""
+ try:
+ self.response = self.client.get(f"/dags/{dag_id}/dagRuns/{dag_run_id}")
+ return DAGRunResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(
+ self,
+ dag_id: str,
+ start_date: datetime.datetime,
+ end_date: datetime.datetime,
+ state: str,
+ limit: int,
+ ) -> DAGRunCollectionResponse | ServerResponseError:
+ """List all dag runs."""
+ params = {
+ "start_date": start_date,
+ "end_date": end_date,
+ "state": state,
+ "limit": limit,
+ "dag_id": dag_id,
+ }
+ return super().execute_list(
+ path=f"/dags/{dag_id}/dagRuns", data_model=DAGRunCollectionResponse, params=params
+ )
+
+
+class JobsOperations(BaseOperations):
+ """Job operations."""
+
+ def list(
+ self, job_type: str, hostname: str, is_alive: bool
+ ) -> JobCollectionResponse | ServerResponseError:
+ """List all jobs."""
+ params = {"job_type": job_type, "hostname": hostname, "is_alive": is_alive}
+ return super().execute_list(path="jobs", data_model=JobCollectionResponse, params=params)
+
+
+class PoolsOperations(BaseOperations):
+ """Pool operations."""
+
+ def get(self, pool_name: str) -> PoolResponse | ServerResponseError:
+ """Get a pool."""
+ try:
+ self.response = self.client.get(f"pools/{pool_name}")
+ return PoolResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(self) -> PoolCollectionResponse | ServerResponseError:
+ """List all pools."""
+ return super().execute_list(path="pools", data_model=PoolCollectionResponse)
+
+ def create(self, pool: PoolBody) -> PoolResponse | ServerResponseError:
+ """Create a pool."""
+ try:
+ self.response = self.client.post("pools", json=pool.model_dump(mode="json"))
+ return PoolResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def bulk(self, pools: BulkBodyPoolBody) -> BulkResponse | ServerResponseError:
+ """CRUD multiple pools."""
+ try:
+ self.response = self.client.patch("pools", json=pools.model_dump(mode="json"))
+ return BulkResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def delete(self, pool: str) -> str | ServerResponseError:
+ """Delete a pool."""
+ try:
+ self.client.delete(f"pools/{pool}")
+ return pool
+ except ServerResponseError as e:
+ raise e
+
+ def update(self, pool_body: PoolPatchBody) -> PoolResponse | ServerResponseError:
+ """Update a pool."""
+ try:
+ self.response = self.client.patch(
+ f"pools/{pool_body.pool}", json=pool_body.model_dump(mode="json")
+ )
+ return PoolResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+
+class ProvidersOperations(BaseOperations):
+ """Provider operations."""
+
+ def list(self) -> ProviderCollectionResponse | ServerResponseError:
+ """List all providers."""
+ return super().execute_list(path="providers", data_model=ProviderCollectionResponse)
+
+
+class VariablesOperations(BaseOperations):
+ """Variable operations."""
+
+ def get(self, variable_key: str) -> VariableResponse | ServerResponseError:
+ """Get a variable."""
+ try:
+ self.response = self.client.get(f"variables/{variable_key}")
+ return VariableResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def list(self) -> VariableCollectionResponse | ServerResponseError:
+ """List all variables."""
+ return super().execute_list(path="variables", data_model=VariableCollectionResponse)
+
+ def create(self, variable: VariableBody) -> VariableResponse | ServerResponseError:
+ """Create a variable."""
+ try:
+ self.response = self.client.post("variables", json=variable.model_dump(mode="json"))
+ return VariableResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def bulk(self, variables: BulkBodyVariableBody) -> BulkResponse | ServerResponseError:
+ """CRUD multiple variables."""
+ try:
+ self.response = self.client.patch("variables", json=variables.model_dump(mode="json"))
+ return BulkResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+ def delete(self, variable_key: str) -> str | ServerResponseError:
+ """Delete a variable."""
+ try:
+ self.client.delete(f"variables/{variable_key}")
+ return variable_key
+ except ServerResponseError as e:
+ raise e
+
+ def update(self, variable: VariableBody) -> VariableResponse | ServerResponseError:
+ """Update a variable."""
+ try:
+ self.response = self.client.patch(
+ f"variables/{variable.key}", json=variable.model_dump(mode="json")
+ )
+ return VariableResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+
+
+class VersionOperations(BaseOperations):
+ """Version operations."""
+
+ def get(self) -> VersionInfo | ServerResponseError:
+ """Get the version."""
+ try:
+ self.response = self.client.get("version")
+ return VersionInfo.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/__init__.py
similarity index 83%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/ctl/__init__.py
index 32a64efabc5..12ea14168d2 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,9 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
+# Pycharm needs to see this line. VSCode/pyright doesn't care about it, but this file needs to exist
+# https://github.com/microsoft/pyright/issues/9439#issuecomment-2468990559
from __future__ import annotations
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/cli_config.py
similarity index 100%
copy from airflow-ctl/src/airflowctl/ctl/cli_config.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/ctl/cli_config.py
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/cli_parser.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/cli_parser.py
new file mode 100644
index 00000000000..d48c5688bdc
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/cli_parser.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Produce a CLI parser object from Airflow CLI command configuration.
+
+.. seealso:: :mod:`airflowctl.ctl.cli_config`
+"""
+
+from __future__ import annotations
+
+import argparse
+import logging
+from argparse import Action
+from collections.abc import Iterable
+from functools import cache
+from typing import TYPE_CHECKING
+
+import lazy_object_proxy
+from rich_argparse import HelpPreviewAction, RawTextRichHelpFormatter, RichHelpFormatter
+
+from airflowctl.ctl.cli_config import (
+ ActionCommand,
+ DefaultHelpParser,
+ GroupCommand,
+ GroupCommandParser,
+ core_commands,
+)
+from airflowctl.exceptions import AirflowCtlException
+from airflowctl.utils.helpers import partition
+
+if TYPE_CHECKING:
+ from airflowctl.ctl.cli_config import (
+ Arg,
+ CLICommand,
+ )
+
+airflow_commands = core_commands.copy() # make a copy to prevent bad interactions in tests
+
+log = logging.getLogger(__name__)
+
+
+ALL_COMMANDS_DICT: dict[str, CLICommand] = {
+ sp.name: GroupCommandParser.from_group_command(sp) if isinstance(sp, GroupCommand) else sp
+ for sp in airflow_commands
+}
+
+
+class AirflowHelpFormatter(RichHelpFormatter):
+ """
+ Custom help formatter to display help message.
+
+ It displays simple commands and groups of commands in separate sections.
+ """
+
+ def _iter_indented_subactions(self, action: Action):
+ if isinstance(action, argparse._SubParsersAction):
+ self._indent()
+ subactions = action._get_subactions()
+ action_subcommands, group_subcommands = partition(
+ lambda d: isinstance(ALL_COMMANDS_DICT[d.dest], GroupCommandParser), subactions
+ )
+ yield Action([], f"\n{' ':{self._current_indent}}Groups", nargs=0)
+ self._indent()
+ yield from group_subcommands
+ self._dedent()
+
+ yield Action([], f"\n{' ':{self._current_indent}}Commands:", nargs=0)
+ self._indent()
+ yield from action_subcommands
+ self._dedent()
+ self._dedent()
+ else:
+ yield from super()._iter_indented_subactions(action)
+
+
+class LazyRichHelpFormatter(RawTextRichHelpFormatter):
+ """
+ Custom help formatter to display help message.
+
+ It resolves lazy help string before printing it using rich.
+ """
+
+ def add_argument(self, action: Action) -> None:
+ if isinstance(action.help, lazy_object_proxy.Proxy):
+ action.help = str(action.help)
+ return super().add_argument(action)
+
+
+def add_preview_action(parser: argparse.ArgumentParser) -> None:
+ """Add preview action to parser."""
+ parser.add_argument(
+ "--preview",
+ action=HelpPreviewAction,
+ )
+
+
+@cache
+def get_parser() -> argparse.ArgumentParser:
+ """Create and returns command line argument parser."""
+ parser = DefaultHelpParser(prog="airflowctl", formatter_class=AirflowHelpFormatter)
+ add_preview_action(parser)
+ subparsers = parser.add_subparsers(dest="subcommand", metavar="GROUP_OR_COMMAND")
+ subparsers.required = True
+
+ for _, sub in sorted(ALL_COMMANDS_DICT.items()):
+ _add_command(
+ subparsers, GroupCommandParser.from_group_command(sub) if isinstance(sub, GroupCommand) else sub
+ )
+
+ return parser
+
+
+def _sort_args(args: Iterable[Arg]) -> Iterable[Arg]:
+ """Sort subcommand optional args, keep positional args."""
+
+ def get_long_option(arg: Arg):
+ """Get long option from Arg.flags."""
+ return arg.flags[0] if len(arg.flags) == 1 else arg.flags[1]
+
+ positional, optional = partition(lambda x: x.flags[0].startswith("-"), args)
+ yield from positional
+ yield from sorted(optional, key=lambda x: get_long_option(x).lower())
+
+
+def _add_command(subparsers: argparse._SubParsersAction, sub: CLICommand) -> None:
+ if isinstance(sub, ActionCommand) and sub.hide:
+ sub_proc = subparsers.add_parser(sub.name, epilog=sub.epilog)
+ else:
+ sub_proc = subparsers.add_parser(
+ sub.name, help=sub.help, description=sub.description or sub.help, epilog=sub.epilog
+ )
+ add_preview_action(sub_proc)
+ sub_proc.formatter_class = LazyRichHelpFormatter
+
+ if isinstance(sub, GroupCommandParser):
+ _add_group_command(sub, sub_proc)
+ elif isinstance(sub, ActionCommand):
+ _add_action_command(sub, sub_proc)
+ else:
+ raise AirflowCtlException("Invalid command definition.")
+
+
+def _add_action_command(sub: ActionCommand, sub_proc: argparse.ArgumentParser) -> None:
+ for arg in _sort_args(sub.args):
+ arg.add_to_parser(sub_proc)
+ sub_proc.set_defaults(func=sub.func)
+
+
+def _add_group_command(sub: GroupCommandParser, sub_proc: argparse.ArgumentParser) -> None:
+ subcommands = sub.subcommands
+ sub_subparsers = sub_proc.add_subparsers(dest="subcommand", metavar="COMMAND")
+ sub_subparsers.required = True
+ for command in sorted(subcommands, key=lambda x: x.name):
+ _add_command(sub_subparsers, command)
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
new file mode 100644
index 00000000000..e66a1d835d4
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/auth_command.py
@@ -0,0 +1,77 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import os
+import sys
+
+import rich
+
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, Credentials, provide_api_client
+from airflowctl.api.datamodels.auth_generated import LoginBody
+
+
+@provide_api_client(kind=ClientKind.AUTH)
+def login(args, api_client=NEW_API_CLIENT) -> None:
+ """Login to a provider."""
+ success_message = "[green]Login successful! Welcome to airflowctl![/green]"
+ # Check is username and password are passed
+ if args.username and args.password:
+ # Generate empty credentials with the api_url and env
+ credentials = Credentials(
+ api_url=args.api_url,
+ api_token="",
+ api_environment=args.env,
+ client_kind=ClientKind.AUTH,
+ )
+ # After logging in, the token will be saved in the credentials file
+ try:
+ credentials.save()
+ api_client.refresh_base_url(base_url=args.api_url, kind=ClientKind.AUTH)
+ login_response = api_client.login.login_with_username_and_password(
+ LoginBody(
+ username=args.username,
+ password=args.password,
+ )
+ )
+ credentials.api_token = login_response.access_token
+ credentials.save()
+ rich.print(success_message)
+ return
+ except Exception as e:
+ rich.print(f"[red]Login failed: {e}")
+ sys.exit(1)
+
+ # Check if token is passed or environment variable is set
+ if not (token := args.api_token or os.environ.get("AIRFLOW_CLI_TOKEN")):
+ # Exit
+ rich.print("[red]No token found.")
+ rich.print(
+ "[green]Please pass:[/green] [blue]--api-token[/blue] or set "
+ "[blue]AIRFLOW_CLI_TOKEN[/blue] environment variable to login."
+ "[blue] Alternatively, you can use --username and --password to login.[/blue]"
+ )
+ sys.exit(1)
+
+ Credentials(
+ api_url=args.api_url,
+ api_token=token,
+ api_environment=args.env,
+ ).save()
+ rich.print(success_message)
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/config_command.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/config_command.py
new file mode 100644
index 00000000000..b4cb9725ca8
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/config_command.py
@@ -0,0 +1,825 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import sys
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, NamedTuple
+
+import rich
+
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, provide_api_client
+
+if TYPE_CHECKING:
+ from airflowctl.api.datamodels.generated import Config
+
+
+class ConfigParameter(NamedTuple):
+ """Represents a configuration parameter."""
+
+ section: str
+ option: str
+
+
+@dataclass
+class ConfigChange:
+ """
+ Class representing the configuration changes in Airflow 3.0.
+
+ :param config: The configuration parameter being changed.
+ :param default_change: If the change is a default value change.
+ :param old_default: The old default value (valid only if default_change is True).
+ :param new_default: The new default value for the configuration parameter.
+ :param suggestion: A suggestion for replacing or handling the removed configuration.
+ :param renamed_to: The new section and option if the configuration is renamed.
+ :param was_deprecated: If the config is removed, whether the old config was deprecated.
+ :param was_removed: If the config is removed.
+ :param is_invalid_if: If the current config value is invalid in the future.
+ :param breaking: Mark if this change is known to be breaking and causing errors/ warnings / deprecations.
+ :param remove_if_equals: For removal rules, remove the option only if its current value equals this value.
+ """
+
+ config: ConfigParameter
+ default_change: bool = False
+ old_default: str | bool | int | float | None = None
+ new_default: str | bool | int | float | None = None
+ suggestion: str = ""
+ renamed_to: ConfigParameter | None = None
+ was_deprecated: bool = True
+ was_removed: bool = True
+ is_invalid_if: Any = None
+ breaking: bool = False
+ remove_if_equals: str | bool | int | float | None = None
+
+ def message(self, api_client=NEW_API_CLIENT) -> str | None:
+ """Generate a message for this configuration change."""
+ if self.default_change:
+ value = self._get_option_value(api_client.configs.list())
+ if value != self.new_default:
+ return (
+ f"Changed default value of `{self.config.option}` in `{self.config.section}` "
+ f"from `{self.old_default}` to `{self.new_default}`."
+ )
+ if self.renamed_to:
+ if self.config.section != self.renamed_to.section:
+ return (
+ f"`{self.config.option}` configuration parameter moved from `{self.config.section}` section to "
+ f"`{self.renamed_to.section}` section as `{self.renamed_to.option}`."
+ )
+ return (
+ f"`{self.config.option}` configuration parameter renamed to `{self.renamed_to.option}` "
+ f"in the `{self.config.section}` section."
+ )
+ if self.was_removed and not self.remove_if_equals:
+ return (
+ f"Removed{' deprecated' if self.was_deprecated else ''} `{self.config.option}` configuration parameter "
+ f"from `{self.config.section}` section."
+ f"{self.suggestion}"
+ )
+ if self.is_invalid_if is not None:
+ value = self._get_option_value(api_client.configs.list())
+ if value == self.is_invalid_if:
+ return (
+ f"Invalid value `{self.is_invalid_if}` set for `{self.config.option}` configuration parameter "
+ f"in `{self.config.section}` section. {self.suggestion}"
+ )
+ return None
+
+ def _get_option_value(self, config_resp: Config) -> str | None:
+ for section in config_resp.sections:
+ if section.name == self.config.section:
+ for option in section.options:
+ if option.key == self.config.option:
+ return option.value if isinstance(option.value, str) else str(option.value)
+ return None
+
+
+CONFIGS_CHANGES = [
+ # admin
+ ConfigChange(
+ config=ConfigParameter("admin", "hide_sensitive_variable_fields"),
+ renamed_to=ConfigParameter("core", "hide_sensitive_var_conn_fields"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("admin", "sensitive_variable_fields"),
+ renamed_to=ConfigParameter("core", "sensitive_var_conn_names"),
+ ),
+ # core
+ ConfigChange(
+ config=ConfigParameter("core", "executor"),
+ default_change=True,
+ old_default="SequentialExecutor",
+ new_default="LocalExecutor",
+ was_removed=False,
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "hostname"),
+ was_removed=True,
+ remove_if_equals=":",
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "check_slas"),
+ suggestion="The SLA feature is removed in Airflow 3.0, to be replaced with Airflow Alerts in future",
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "strict_dataset_uri_validation"),
+ suggestion="Dataset URI with a defined scheme will now always be validated strictly, "
+ "raising a hard error on validation failure.",
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "dag_default_view"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "dag_orientation"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "dataset_manager_class"),
+ renamed_to=ConfigParameter("core", "asset_manager_class"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "dataset_manager_kwargs"),
+ renamed_to=ConfigParameter("core", "asset_manager_kwargs"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "worker_precheck"),
+ renamed_to=ConfigParameter("celery", "worker_precheck"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "non_pooled_task_slot_count"),
+ renamed_to=ConfigParameter("core", "default_pool_task_slot_count"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "dag_concurrency"),
+ renamed_to=ConfigParameter("core", "max_active_tasks_per_dag"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_conn"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_conn"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_engine_encoding"),
+ renamed_to=ConfigParameter("database", "sql_engine_encoding"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_engine_collation_for_ids"),
+ renamed_to=ConfigParameter("database", "sql_engine_collation_for_ids"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_pool_enabled"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_pool_enabled"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_pool_size"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_pool_size"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_max_overflow"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_max_overflow"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_pool_recycle"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_pool_recycle"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_pool_pre_ping"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_pool_pre_ping"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_schema"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_schema"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "sql_alchemy_connect_args"),
+ renamed_to=ConfigParameter("database", "sql_alchemy_connect_args"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "load_default_connections"),
+ renamed_to=ConfigParameter("database", "load_default_connections"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "max_db_retries"),
+ renamed_to=ConfigParameter("database", "max_db_retries"),
+ ),
+ ConfigChange(config=ConfigParameter("core", "task_runner")),
+ ConfigChange(config=ConfigParameter("core", "enable_xcom_pickling")),
+ ConfigChange(
+ config=ConfigParameter("core", "dag_file_processor_timeout"),
+ renamed_to=ConfigParameter("dag_processor", "dag_file_processor_timeout"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "dag_processor_manager_log_location"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "log_processor_filename_template"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("core", "parallelism"),
+ was_removed=False,
+ is_invalid_if="0",
+ suggestion="Please set the `parallelism` configuration parameter to a value greater than 0.",
+ ),
+ # api
+ ConfigChange(
+ config=ConfigParameter("api", "access_control_allow_origin"),
+ renamed_to=ConfigParameter("api", "access_control_allow_origins"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("api", "auth_backend"),
+ renamed_to=ConfigParameter("fab", "auth_backends"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("api", "auth_backends"),
+ renamed_to=ConfigParameter("fab", "auth_backends"),
+ ),
+ # logging
+ ConfigChange(
+ config=ConfigParameter("logging", "enable_task_context_logger"),
+ suggestion="Remove TaskContextLogger: Replaced by the Log table for better handling of task log "
+ "messages outside the execution context.",
+ ),
+ ConfigChange(
+ config=ConfigParameter("logging", "dag_processor_manager_log_location"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("logging", "dag_processor_manager_log_stdout"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("logging", "log_processor_filename_template"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("logging", "log_filename_template"),
+ was_removed=True,
+ remove_if_equals="{{ ti.dag_id }}/{{ ti.task_id }}/{{ ts }}/{{ try_number }}.log",
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("logging", "log_filename_template"),
+ was_removed=True,
+ remove_if_equals="dag_id={{ ti.dag_id }}/run_id={{ ti.run_id }}/task_id={{ ti.task_id }}/{% if ti.map_index >= 0 %}map_index={{ ti.map_index }}/{% endif %}attempt={{ try_number }}.log",
+ breaking=True,
+ ),
+ # metrics
+ ConfigChange(
+ config=ConfigParameter("metrics", "metrics_use_pattern_match"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("metrics", "timer_unit_consistency"),
+ suggestion="In Airflow 3.0, the `timer_unit_consistency` setting in the `metrics` section is "
+ "removed as it is now the default behaviour. This is done to standardize all timer and "
+ "timing metrics to milliseconds across all metric loggers",
+ ),
+ ConfigChange(
+ config=ConfigParameter("metrics", "statsd_allow_list"),
+ renamed_to=ConfigParameter("metrics", "metrics_allow_list"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("metrics", "statsd_block_list"),
+ renamed_to=ConfigParameter("metrics", "metrics_block_list"),
+ ),
+ # traces
+ ConfigChange(
+ config=ConfigParameter("traces", "otel_task_log_event"),
+ ),
+ # operators
+ ConfigChange(
+ config=ConfigParameter("operators", "allow_illegal_arguments"),
+ ),
+ # webserver
+ ConfigChange(
+ config=ConfigParameter("webserver", "allow_raw_html_descriptions"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "cookie_samesite"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "update_fab_perms"),
+ renamed_to=ConfigParameter("fab", "update_fab_perms"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "auth_rate_limited"),
+ renamed_to=ConfigParameter("fab", "auth_rate_limited"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", option="auth_rate_limit"),
+ renamed_to=ConfigParameter("fab", "auth_rate_limit"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "config_file"),
+ renamed_to=ConfigParameter("fab", "config_file"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "session_backend"),
+ renamed_to=ConfigParameter("fab", "session_backend"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "session_lifetime_days"),
+ renamed_to=ConfigParameter("fab", "session_lifetime_minutes"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "force_log_out_after"),
+ renamed_to=ConfigParameter("fab", "session_lifetime_minutes"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "session_lifetime_minutes"),
+ renamed_to=ConfigParameter("fab", "session_lifetime_minutes"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "base_url"),
+ renamed_to=ConfigParameter("api", "base_url"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "web_server_host"),
+ renamed_to=ConfigParameter("api", "host"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "web_server_port"),
+ renamed_to=ConfigParameter("api", "port"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "workers"),
+ renamed_to=ConfigParameter("api", "workers"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "web_server_worker_timeout"),
+ renamed_to=ConfigParameter("api", "worker_timeout"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "web_server_ssl_cert"),
+ renamed_to=ConfigParameter("api", "ssl_cert"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "web_server_ssl_key"),
+ renamed_to=ConfigParameter("api", "ssl_key"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "access_logfile"),
+ renamed_to=ConfigParameter("api", "access_logfile"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "error_logfile"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "access_logformat"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "web_server_master_timeout"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "worker_refresh_batch_size"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "worker_refresh_interval"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "reload_on_plugin_change"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "worker_class"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "expose_stacktrace"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "log_fetch_delay_sec"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "log_auto_tailing_offset"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "log_animation_speed"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "default_dag_run_display_number"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "enable_proxy_fix"),
+ renamed_to=ConfigParameter("fab", "enable_proxy_fix"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "proxy_fix_x_for"),
+ renamed_to=ConfigParameter("fab", "proxy_fix_x_for"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "proxy_fix_x_proto"),
+ renamed_to=ConfigParameter("fab", "proxy_fix_x_proto"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "proxy_fix_x_host"),
+ renamed_to=ConfigParameter("fab", "proxy_fix_x_host"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "proxy_fix_x_port"),
+ renamed_to=ConfigParameter("fab", "proxy_fix_x_port"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "proxy_fix_x_prefix"),
+ renamed_to=ConfigParameter("fab", "proxy_fix_x_prefix"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "expose_config"),
+ renamed_to=ConfigParameter("api", "expose_config"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "cookie_secure"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "analytics_tool"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "analytics_id"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "analytics_url"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "show_recent_stats_for_completed_runs"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "run_internal_api"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "caching_hash_method"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "show_trigger_form_if_no_params"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "num_recent_configurations_for_trigger"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "allowed_payload_size"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "max_form_memory_size"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "max_form_parts"),
+ was_deprecated=False,
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "default_ui_timezone"),
+ was_deprecated=False,
+ ),
+ # policy
+ ConfigChange(
+ config=ConfigParameter("policy", "airflow_local_settings"),
+ renamed_to=ConfigParameter("policy", "task_policy"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("webserver", "navbar_logo_text_color"),
+ was_deprecated=False,
+ ),
+ # scheduler
+ ConfigChange(
+ config=ConfigParameter("scheduler", "dependency_detector"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "allow_trigger_in_future"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "catchup_by_default"),
+ default_change=True,
+ old_default="True",
+ was_removed=False,
+ new_default="False",
+ suggestion="In Airflow 3.0 the default value for `catchup_by_default` is set to `False`. "
+ "This means that DAGs without explicit definition of the `catchup` parameter will not "
+ "catchup by default. "
+ "If your DAGs rely on catchup behavior, not explicitly defined in the DAG definition, "
+ "set this configuration parameter to `True` in the `scheduler` section of your `airflow.cfg` "
+ "to enable the behavior from Airflow 2.x.",
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "create_cron_data_intervals"),
+ default_change=True,
+ old_default="True",
+ new_default="False",
+ was_removed=False,
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "create_delta_data_intervals"),
+ default_change=True,
+ old_default="True",
+ new_default="False",
+ was_removed=False,
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "processor_poll_interval"),
+ renamed_to=ConfigParameter("scheduler", "scheduler_idle_sleep_time"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "deactivate_stale_dags_interval"),
+ renamed_to=ConfigParameter("scheduler", "parsing_cleanup_interval"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_on"), renamed_to=ConfigParameter("metrics", "statsd_on")
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "max_threads"),
+ renamed_to=ConfigParameter("dag_processor", "parsing_processes"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_host"),
+ renamed_to=ConfigParameter("metrics", "statsd_host"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_port"),
+ renamed_to=ConfigParameter("metrics", "statsd_port"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_prefix"),
+ renamed_to=ConfigParameter("metrics", "statsd_prefix"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_allow_list"),
+ renamed_to=ConfigParameter("metrics", "statsd_allow_list"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "stat_name_handler"),
+ renamed_to=ConfigParameter("metrics", "stat_name_handler"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_datadog_enabled"),
+ renamed_to=ConfigParameter("metrics", "statsd_datadog_enabled"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_datadog_tags"),
+ renamed_to=ConfigParameter("metrics", "statsd_datadog_tags"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_datadog_metrics_tags"),
+ renamed_to=ConfigParameter("metrics", "statsd_datadog_metrics_tags"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "statsd_custom_client_path"),
+ renamed_to=ConfigParameter("metrics", "statsd_custom_client_path"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "parsing_processes"),
+ renamed_to=ConfigParameter("dag_processor", "parsing_processes"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "file_parsing_sort_mode"),
+ renamed_to=ConfigParameter("dag_processor", "file_parsing_sort_mode"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "max_callbacks_per_loop"),
+ renamed_to=ConfigParameter("dag_processor", "max_callbacks_per_loop"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "min_file_process_interval"),
+ renamed_to=ConfigParameter("dag_processor", "min_file_process_interval"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "stale_dag_threshold"),
+ renamed_to=ConfigParameter("dag_processor", "stale_dag_threshold"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "print_stats_interval"),
+ renamed_to=ConfigParameter("dag_processor", "print_stats_interval"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "dag_dir_list_interval"),
+ renamed_to=ConfigParameter("dag_processor", "refresh_interval"),
+ breaking=True,
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "local_task_job_heartbeat_sec"),
+ renamed_to=ConfigParameter("scheduler", "task_instance_heartbeat_sec"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "scheduler_zombie_task_threshold"),
+ renamed_to=ConfigParameter("scheduler", "task_instance_heartbeat_timeout"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "zombie_detection_interval"),
+ renamed_to=ConfigParameter("scheduler", "task_instance_heartbeat_timeout_detection_interval"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("scheduler", "child_process_log_directory"),
+ renamed_to=ConfigParameter("logging", "dag_processor_child_process_log_directory"),
+ ),
+ # celery
+ ConfigChange(
+ config=ConfigParameter("celery", "stalled_task_timeout"),
+ renamed_to=ConfigParameter("scheduler", "task_queued_timeout"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("celery", "default_queue"),
+ renamed_to=ConfigParameter("operators", "default_queue"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("celery", "task_adoption_timeout"),
+ renamed_to=ConfigParameter("scheduler", "task_queued_timeout"),
+ ),
+ # kubernetes_executor
+ ConfigChange(
+ config=ConfigParameter("kubernetes_executor", "worker_pods_pending_timeout"),
+ renamed_to=ConfigParameter("scheduler", "task_queued_timeout"),
+ ),
+ ConfigChange(
+ config=ConfigParameter("kubernetes_executor", "worker_pods_pending_timeout_check_interval"),
+ renamed_to=ConfigParameter("scheduler", "task_queued_timeout_check_interval"),
+ ),
+ # smtp
+ ConfigChange(
+ config=ConfigParameter("smtp", "smtp_user"),
+ suggestion="Please use the SMTP connection (`smtp_default`).",
+ ),
+ ConfigChange(
+ config=ConfigParameter("smtp", "smtp_password"),
+ suggestion="Please use the SMTP connection (`smtp_default`).",
+ ),
+ # database
+ ConfigChange(
+ config=ConfigParameter("database", "load_default_connections"),
+ ),
+ # triggerer
+ ConfigChange(
+ config=ConfigParameter("triggerer", "default_capacity"),
+ renamed_to=ConfigParameter("triggerer", "capacity"),
+ breaking=True,
+ ),
+ # email
+ ConfigChange(
+ config=ConfigParameter("email", "email_backend"),
+ was_removed=True,
+ remove_if_equals="airflow.contrib.utils.sendgrid.send_email",
+ ),
+ # elasticsearch
+ ConfigChange(
+ config=ConfigParameter("elasticsearch", "log_id_template"),
+ was_removed=True,
+ remove_if_equals="{dag_id}-{task_id}-{logical_date}-{try_number}",
+ breaking=True,
+ ),
+]
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def lint(args, api_client=NEW_API_CLIENT) -> None:
+ """
+ Lint the airflow.cfg file for removed, or renamed configurations.
+
+ This function scans the Airflow configuration file for parameters that are removed or renamed in
+ Airflow 3.0. It provides suggestions for alternative parameters or settings where applicable.
+ CLI Arguments:
+ --section: str (optional)
+ The specific section of the configuration to lint.
+ Example: --section core
+
+ --option: str (optional)
+ The specific option within a section to lint.
+ Example: --option check_slas
+
+ --ignore-section: str (optional)
+ A section to ignore during linting.
+ Example: --ignore-section webserver
+
+ --ignore-option: str (optional)
+ An option to ignore during linting.
+ Example: --ignore-option smtp_user
+
+ --verbose: flag (optional)
+ Enables detailed output, including the list of ignored sections and options.
+ Example: --verbose
+
+ Examples:
+ 1. Lint all sections and options:
+ airflowctl config lint
+
+ 2. Lint a specific section:
+ airflowctl config lint --section core,webserver
+
+ 3. Lint specific sections and options:
+ airflowctl config lint --section smtp --option smtp_user
+
+ 4. Ignore a section:
+ airflowctl config lint --ignore-section webserver,api
+
+ 5. Ignore an options:
+ airflowctl config lint --ignore-option smtp_user,session_lifetime_days
+
+ 6. Enable verbose output:
+ airflowctl config lint --verbose
+
+ :param args: The CLI arguments for linting configurations.
+ """
+ lint_issues = []
+
+ section_to_check_if_provided = args.section or []
+ option_to_check_if_provided = args.option or []
+
+ ignore_sections = args.ignore_section or []
+ ignore_options = args.ignore_option or []
+
+ try:
+ all_configs = api_client.configs.list()
+ for configuration in CONFIGS_CHANGES:
+ if (
+ section_to_check_if_provided
+ and configuration.config.section not in section_to_check_if_provided
+ ):
+ continue
+
+ if option_to_check_if_provided and configuration.config.option not in option_to_check_if_provided:
+ continue
+
+ if (
+ configuration.config.section in ignore_sections
+ or configuration.config.option in ignore_options
+ ):
+ continue
+
+ target_section = next(
+ (section for section in all_configs.sections if section.name == configuration.config.section),
+ None,
+ )
+ if target_section:
+ target_option = next(
+ (
+ option
+ for option in target_section.options
+ if option.key == configuration.config.option
+ ),
+ None,
+ )
+ if target_option:
+ if configuration.message(api_client=api_client) is not None:
+ lint_issues.append(configuration.message(api_client=api_client))
+
+ if lint_issues:
+ rich.print("[red]Found issues in your airflow.cfg:[/red]")
+ for issue in lint_issues:
+ rich.print(f" - [yellow]{issue}[/yellow]")
+ if args.verbose:
+ rich.print("\n[blue]Detailed Information:[/blue]")
+ rich.print(f"Ignored sections: [green]{', '.join(ignore_sections)}[/green]")
+ rich.print(f"Ignored options: [green]{', '.join(ignore_options)}[/green]")
+ rich.print("\n[red]Please update your configuration file accordingly.[/red]")
+ else:
+ rich.print("[green]No issues found in your airflow.cfg. It is ready for Airflow 3![/green]")
+
+ except Exception as e:
+ rich.print(f"[red]Lint configs failed: {e}")
+ sys.exit(1)
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
new file mode 100644
index 00000000000..8a894202206
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/connection_command.py
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import rich
+
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, provide_api_client
+from airflowctl.api.datamodels.generated import (
+ BulkActionOnExistence,
+ BulkBodyConnectionBody,
+ BulkCreateActionConnectionBody,
+ ConnectionBody,
+)
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def import_(args, api_client=NEW_API_CLIENT) -> None:
+ """Import connections from file."""
+ filepath = Path(args.file)
+ current_path = Path.cwd()
+ filepath = current_path / filepath if not filepath.is_absolute() else filepath
+ if not filepath.exists():
+ raise SystemExit(f"Missing connections file {args.file}")
+
+ with open(filepath) as file:
+ try:
+ connections_json = json.loads(file.read())
+ except Exception as e:
+ raise SystemExit(f"Error reading connections file {args.file}: {e}")
+ try:
+ connections_data = {
+ k: ConnectionBody(
+ connection_id=k,
+ conn_type=v.get("conn_type"),
+ host=v.get("host"),
+ login=v.get("login"),
+ password=v.get("password"),
+ port=v.get("port"),
+ extra=v.get("extra", {}),
+ description=v.get("description", ""),
+ )
+ for k, v in connections_json.items()
+ }
+ connection_create_action = BulkCreateActionConnectionBody(
+ action="create",
+ entities=list(connections_data.values()),
+ action_on_existence=BulkActionOnExistence("fail"),
+ )
+ response = api_client.connections.bulk(BulkBodyConnectionBody(actions=[connection_create_action]))
+ if response.create.errors:
+ rich.print(f"[red]Failed to import connections: {response.create.errors}[/red]")
+ raise SystemExit
+ rich.print(f"[green]Successfully imported {response.create.success} connection(s)[/green]")
+ except Exception as e:
+ rich.print(f"[red]Failed to import connections: {e}[/red]")
+ raise SystemExit
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py
new file mode 100644
index 00000000000..9b43be47eb2
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import sys
+from typing import Literal
+
+import rich
+
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, ServerResponseError, provide_api_client
+from airflowctl.api.datamodels.generated import DAGPatchBody
+from airflowctl.ctl.console_formatting import AirflowConsole
+
+
+def update_dag_state(
+ dag_id: str,
+ operation: Literal["pause", "unpause"],
+ api_client,
+ output: str,
+):
+ """Update DAG state (pause/unpause)."""
+ try:
+ response = api_client.dags.update(
+ dag_id=dag_id, dag_body=DAGPatchBody(is_paused=operation == "pause")
+ )
+ except ServerResponseError as e:
+ rich.print(f"[red]Error while trying to {operation} Dag {dag_id}: {e}[/red]")
+ sys.exit(1)
+
+ response_dict = response.model_dump()
+ rich.print(f"[green]Dag {operation} successful {dag_id}[/green]")
+ rich.print("[green]Further Dag details:[/green]")
+ AirflowConsole().print_as(
+ data=[response_dict],
+ output=output,
+ )
+ return response_dict
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def pause(args, api_client=NEW_API_CLIENT) -> None:
+ """Pause a DAG."""
+ return update_dag_state(
+ dag_id=args.dag_id,
+ operation="pause",
+ api_client=api_client,
+ output=args.output,
+ )
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def unpause(args, api_client=NEW_API_CLIENT) -> None:
+ """Unpause a DAG."""
+ return update_dag_state(
+ dag_id=args.dag_id,
+ operation="unpause",
+ api_client=api_client,
+ output=args.output,
+ )
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
new file mode 100644
index 00000000000..bc0009e3391
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/pool_command.py
@@ -0,0 +1,122 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Command functions for managing Airflow pools."""
+
+from __future__ import annotations
+
+import json
+from json import JSONDecodeError
+from pathlib import Path
+
+import rich
+
+from airflowctl.api.client import NEW_API_CLIENT, Client, ClientKind, provide_api_client
+from airflowctl.api.datamodels.generated import (
+ BulkActionOnExistence,
+ BulkBodyPoolBody,
+ BulkCreateActionPoolBody,
+ PoolBody,
+)
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def import_(args, api_client: Client = NEW_API_CLIENT) -> None:
+ """Import pools from file."""
+ filepath = Path(args.file)
+ if not filepath.exists():
+ raise SystemExit(f"Missing pools file {args.file}")
+
+ success, errors = _import_helper(api_client, filepath)
+ if errors:
+ raise SystemExit(f"Failed to update pool(s): {errors}")
+ rich.print(success)
+
+
+@provide_api_client(kind=ClientKind.CLI)
+def export(args, api_client: Client = NEW_API_CLIENT) -> None:
+ """
+ Export all pools.
+
+ If output is json, write to file. Otherwise, print to console.
+ """
+ try:
+ pools_response = api_client.pools.list()
+ pools_list = [
+ {
+ "name": pool.name,
+ "slots": pool.slots,
+ "description": pool.description,
+ "include_deferred": pool.include_deferred,
+ "occupied_slots": pool.occupied_slots,
+ "running_slots": pool.running_slots,
+ "queued_slots": pool.queued_slots,
+ "scheduled_slots": pool.scheduled_slots,
+ "open_slots": pool.open_slots,
+ "deferred_slots": pool.deferred_slots,
+ }
+ for pool in pools_response.pools
+ ]
+
+ if args.output == "json":
+ file_path = Path(args.file)
+ with open(file_path, "w") as f:
+ json.dump(pools_list, f, indent=4, sort_keys=True)
+ rich.print(f"Exported {pools_response.total_entries} pool(s) to {args.file}")
+ else:
+ # For non-json formats, print the pools directly to console
+ rich.print(pools_list)
+ except Exception as e:
+ raise SystemExit(f"Failed to export pools: {e}")
+
+
+def _import_helper(api_client: Client, filepath: Path):
+ """Help import pools from the json file."""
+ try:
+ with open(filepath) as f:
+ pools_json = json.load(f)
+ except JSONDecodeError as e:
+ raise SystemExit(f"Invalid json file: {e}")
+
+ if not isinstance(pools_json, list):
+ raise SystemExit("Invalid format: Expected a list of pool objects")
+
+ pools_to_update = []
+ for pool_config in pools_json:
+ if not isinstance(pool_config, dict) or "name" not in pool_config or "slots" not in pool_config:
+ raise SystemExit(f"Invalid pool configuration: {pool_config}")
+
+ pools_to_update.append(
+ PoolBody(
+ name=pool_config["name"],
+ slots=pool_config["slots"],
+ description=pool_config.get("description", ""),
+ include_deferred=pool_config.get("include_deferred", False),
+ )
+ )
+
+ bulk_body = BulkBodyPoolBody(
+ actions=[
+ BulkCreateActionPoolBody(
+ action="create",
+ entities=pools_to_update,
+ action_on_existence=BulkActionOnExistence.FAIL,
+ )
+ ]
+ )
+ result = api_client.pools.bulk(pools=bulk_body)
+ # Return the successful and failed entities directly from the response
+ return result.create.success, result.create.errors
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
similarity index 100%
copy from airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
similarity index 60%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
index 32a64efabc5..bfaa49a6682 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/commands/version_command.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -17,6 +16,19 @@
# under the License.
from __future__ import annotations
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+import rich
+
+from airflowctl import __version__ as airflowctl_version
+from airflowctl.api.client import NEW_API_CLIENT, ClientKind, provide_api_client
+
-__version__ = "0.1.0"
+@provide_api_client(kind=ClientKind.CLI)
+def version_info(arg, api_client=NEW_API_CLIENT):
+ """Get version information."""
+ version_dict = {"airflowctl_version": airflowctl_version}
+ if arg.remote:
+ version_response = api_client.version.get()
+ version_dict.update(version_response.model_dump())
+ rich.print(version_dict)
+ else:
+ rich.print(version_dict)
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/console_formatting.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/console_formatting.py
new file mode 100644
index 00000000000..38e06537516
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/console_formatting.py
@@ -0,0 +1,146 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import json
+import sys
+from collections.abc import Callable, Sequence
+from typing import TYPE_CHECKING, Any
+
+from rich.box import ASCII_DOUBLE_HEAD
+from rich.console import Console
+from rich.syntax import Syntax
+from rich.table import Table
+from tabulate import tabulate
+
+from airflowctl.ctl.utils import yaml
+
+if TYPE_CHECKING:
+ from typing import TypeGuard
+
+
+# TODO (bugraoz93): Use Vendor Approach and unify with airflow.platform for core-ctl
+def is_tty():
+ """Check if stdout is connected (is associated with a terminal device) to a tty(-like) device."""
+ if not hasattr(sys.stdout, "isatty"):
+ return False
+ return sys.stdout.isatty()
+
+
+def is_data_sequence(data: Sequence[dict | Any]) -> TypeGuard[Sequence[dict]]:
+ return all(isinstance(d, dict) for d in data)
+
+
+class AirflowConsole(Console):
+ """Airflow rich console."""
+
+ def __init__(self, show_header: bool = True, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Set the width to constant to pipe whole output from console
+ self._width = 200 if not is_tty() else self._width
+
+ # If show header in tables
+ self.show_header = show_header
+
+ def print_as_json(self, data: dict):
+ """Render dict as json text representation."""
+ json_content = json.dumps(data)
+ self.print(Syntax(json_content, "json", theme="ansi_dark"), soft_wrap=True)
+
+ def print_as_yaml(self, data: dict):
+ """Render dict as yaml text representation."""
+ yaml_content = yaml.dump(data)
+ self.print(Syntax(yaml_content, "yaml", theme="ansi_dark"), soft_wrap=True)
+
+ def print_as_table(self, data: list[dict]):
+ """Render list of dictionaries as table."""
+ if not data:
+ self.print("No data found")
+ return
+
+ table = SimpleTable(show_header=self.show_header)
+ for col in data[0]:
+ table.add_column(col)
+
+ for row in data:
+ table.add_row(*(str(d) for d in row.values()))
+ self.print(table)
+
+ def print_as_plain_table(self, data: list[dict]):
+ """Render list of dictionaries as a simple table than can be easily piped."""
+ if not data:
+ self.print("No data found")
+ return
+ rows = [d.values() for d in data]
+ output = tabulate(rows, tablefmt="plain", headers=list(data[0]))
+ self.print(output)
+
+ def _normalize_data(self, value: Any, output: str) -> list | str | dict | None:
+ if isinstance(value, (tuple, list)):
+ if output == "table":
+ return ",".join(str(self._normalize_data(x, output)) for x in value)
+ return [self._normalize_data(x, output) for x in value]
+ if isinstance(value, dict) and output != "table":
+ return {k: self._normalize_data(v, output) for k, v in value.items()}
+ if value is None:
+ return None
+ return str(value)
+
+ def print_as(
+ self,
+ data: Sequence[dict | Any],
+ output: str,
+ mapper: Callable[[Any], dict] | None = None,
+ ) -> None:
+ """Print provided using format specified by output argument."""
+ output_to_renderer: dict[str, Callable[[Any], None]] = {
+ "json": self.print_as_json,
+ "yaml": self.print_as_yaml,
+ "table": self.print_as_table,
+ "plain": self.print_as_plain_table,
+ }
+ renderer = output_to_renderer.get(output)
+ if not renderer:
+ raise ValueError(f"Unknown formatter: {output}. Allowed options: {list(output_to_renderer)}")
+
+ if mapper:
+ dict_data: Sequence[dict] = [mapper(d) for d in data]
+ elif is_data_sequence(data):
+ dict_data = data
+ else:
+ raise ValueError("To tabulate non-dictionary data you need to provide `mapper` function")
+ dict_data = [{k: self._normalize_data(v, output) for k, v in d.items()} for d in dict_data]
+ renderer(dict_data)
+
+
+class SimpleTable(Table):
+ """A rich Table with some default hardcoded for consistency."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.show_edge = kwargs.get("show_edge", False)
+ self.pad_edge = kwargs.get("pad_edge", False)
+ self.box = kwargs.get("box", ASCII_DOUBLE_HEAD)
+ self.show_header = kwargs.get("show_header", False)
+ self.title_style = kwargs.get("title_style", "bold green")
+ self.title_justify = kwargs.get("title_justify", "left")
+ self.caption = kwargs.get("caption", " ")
+
+ def add_column(self, *args, **kwargs) -> None:
+ """Add a column to the table. We use different default."""
+ kwargs["overflow"] = kwargs.get("overflow") # to avoid truncating
+ super().add_column(*args, **kwargs)
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/utils/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/ctl/utils/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/utils/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/airflow-ctl/src/airflowctl/ctl/utils/yaml.py b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/utils/yaml.py
new file mode 100644
index 00000000000..a52910f73ea
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/ctl/utils/yaml.py
@@ -0,0 +1,74 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# TODO (bugraoz93): Use Vendor Approach and unify with airflow.utils.yaml for core-ctl
+"""
+Use libyaml for YAML dump/load operations where possible.
+
+If libyaml is available we will use it -- it is significantly faster.
+
+This module delegates all other properties to the yaml module, so it can be used as:
+
+.. code-block:: python
+
+ import airflow.utils.yaml as yaml
+
+And then be used directly in place of the normal python module.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, BinaryIO, TextIO, cast
+
+if TYPE_CHECKING:
+ from yaml.error import MarkedYAMLError, YAMLError # noqa: F401
+
+
+def safe_load(stream: bytes | str | BinaryIO | TextIO) -> Any:
+ """Like yaml.safe_load, but use the C libyaml for speed where we can."""
+ # delay import until use.
+ from yaml import load as orig
+
+ try:
+ from yaml import CSafeLoader as SafeLoader
+ except ImportError:
+ from yaml import SafeLoader # type: ignore[assignment]
+
+ return orig(stream, SafeLoader)
+
+
+def dump(data: Any, **kwargs) -> str:
+ """Like yaml.safe_dump, but use the C libyaml for speed where we can."""
+ # delay import until use.
+ from yaml import dump as orig
+
+ try:
+ from yaml import CSafeDumper as SafeDumper
+ except ImportError:
+ from yaml import SafeDumper # type: ignore[assignment]
+
+ return cast("str", orig(data, Dumper=SafeDumper, **kwargs))
+
+
+def __getattr__(name):
+ # Delegate anything else to the yaml module
+ import yaml
+
+ if name == "FullLoader":
+ # Try to use CFullLoader by default
+ getattr(yaml, "CFullLoader", yaml.FullLoader)
+
+ return getattr(yaml, name)
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/exceptions.py
similarity index 52%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/exceptions.py
index 32a64efabc5..515879ec359 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/exceptions.py
@@ -15,8 +15,28 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+# Note: Any AirflowException raised is expected to cause the TaskInstance
+# to be marked in an ERROR state
+"""Exceptions used by AirflowCtl."""
+
from __future__ import annotations
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-__version__ = "0.1.0"
+class AirflowCtlException(Exception):
+ """
+ Base class for all AirflowCTL's errors.
+
+ Each custom exception should be derived from this class.
+ """
+
+
+class AirflowCtlNotFoundException(AirflowCtlException):
+ """Raise when the requested object/resource is not available in the system."""
+
+
+class AirflowCtlCredentialNotFoundException(AirflowCtlNotFoundException):
+ """Raise when a credential couldn't be found while performing an operation."""
+
+
+class AirflowCtlConnectionException(AirflowCtlException):
+ """Raise when a connection error occurs while performing an operation."""
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/utils/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/utils/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/utils/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/utils/helpers.py
similarity index 66%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/utils/helpers.py
index 32a64efabc5..d73defb3c5f 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/utils/helpers.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,17 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+
from __future__ import annotations
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+import itertools
+from collections.abc import Callable, Iterable
+from typing import TypeVar
+
+T = TypeVar("T")
+
-__version__ = "0.1.0"
+def partition(pred: Callable[[T], bool], iterable: Iterable[T]) -> tuple[Iterable[T], Iterable[T]]:
+ """Use a predicate to partition entries into false entries and true entries."""
+ iter_1, iter_2 = itertools.tee(iterable)
+ return itertools.filterfalse(pred, iter_1), filter(pred, iter_2)
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/src/airflowctl/utils/module_loading.py
similarity index 56%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/src/airflowctl/utils/module_loading.py
index 32a64efabc5..26009af8d4d 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/src/airflowctl/utils/module_loading.py
@@ -17,6 +17,23 @@
# under the License.
from __future__ import annotations
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+from importlib import import_module
-__version__ = "0.1.0"
+
+def import_string(dotted_path: str):
+ """
+ Import a dotted module path and return the attribute/class designated by the last name in the path.
+
+ Raise ImportError if the import failed.
+ """
+ try:
+ module_path, class_name = dotted_path.rsplit(".", 1)
+ except ValueError:
+ raise ImportError(f"{dotted_path} doesn't look like a module path")
+
+ module = import_module(module_path)
+
+ try:
+ return getattr(module, class_name)
+ except AttributeError:
+ raise ImportError(f'Module "{module_path}" does not define a "{class_name}" attribute/class')
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/api/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/api/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/api/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/api/test_client.py
similarity index 99%
copy from airflow-ctl/tests/airflow_ctl/api/test_client.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/api/test_client.py
index e2adcadff5e..39ffc436706 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -87,7 +87,7 @@ class TestClient:
assert err.value.args == ("Client error message: {'detail': 'Not found'}",)
@pytest.mark.parametrize(
- "base_url, client_kind, expected_base_url",
+ ("base_url", "client_kind", "expected_base_url"),
[
("http://localhost:8080", ClientKind.CLI, "http://localhost:8080/api/v2/"),
("http://localhost:8080", ClientKind.AUTH, "http://localhost:8080/auth/"),
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/api/test_operations.py
similarity index 99%
copy from airflow-ctl/tests/airflow_ctl/api/test_operations.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 1e7391b99d2..f0a638475c4 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -128,7 +128,7 @@ class TestBaseOperations:
client.connections.get("1")
@pytest.mark.parametrize(
- "total_entries, limit, expected_response",
+ ("total_entries", "limit", "expected_response"),
[
(1, 50, (HelloCollectionResponse(hellos=[HelloResponse(name="hello")], total_entries=1))),
(
@@ -207,6 +207,7 @@ class TestAssetsOperations:
producing_tasks=[],
consuming_tasks=[],
aliases=[],
+ watchers=[],
group="group",
)
asset_alias_response = AssetAliasResponse(
@@ -1066,7 +1067,7 @@ class TestPoolsOperations:
pools=[pool_response],
total_entries=1,
)
- pool_bulk_aresponse = BulkResponse(
+ pool_bulk_response = BulkResponse(
create=BulkActionResponse(success=[pool_name], errors=[]),
update=None,
delete=None,
@@ -1102,11 +1103,11 @@ class TestPoolsOperations:
def test_bulk(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v2/pools"
- return httpx.Response(200, json=json.loads(self.pool_bulk_aresponse.model_dump_json()))
+ return httpx.Response(200, json=json.loads(self.pool_bulk_response.model_dump_json()))
client = make_api_client(transport=httpx.MockTransport(handle_request))
response = client.pools.bulk(pools=self.pools_bulk_body)
- assert response == self.pool_bulk_aresponse
+ assert response == self.pool_bulk_response
def test_delete(self):
def handle_request(request: httpx.Request) -> httpx.Response:
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/__init__.py
similarity index 86%
copy from airflow-ctl/src/airflowctl/__init__.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/__init__.py
index 32a64efabc5..13a83393a91 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/__init__.py
@@ -1,4 +1,3 @@
-#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from __future__ import annotations
-
-__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-
-__version__ = "0.1.0"
diff --git a/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
new file mode 100644
index 00000000000..0ad428ee5ba
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_auth_command.py
@@ -0,0 +1,108 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import io
+import json
+import os
+import tempfile
+from unittest import mock
+from unittest.mock import patch
+
+import pytest
+
+from airflowctl.api.client import ClientKind
+from airflowctl.api.datamodels.auth_generated import LoginResponse
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.commands import auth_command
+
+
+class TestCliAuthCommands:
+ parser = cli_parser.get_parser()
+ login_response = LoginResponse(
+ access_token="TEST_TOKEN",
+ )
+
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_AUTH_LOGIN"})
+ @patch("airflowctl.api.client.keyring")
+ @pytest.mark.flaky(reruns=3, reruns_delay=10)
+ def test_login(self, mock_keyring, api_client_maker, monkeypatch):
+ with tempfile.TemporaryDirectory() as temp_airflow_home:
+ monkeypatch.setenv("AIRFLOW_HOME", temp_airflow_home)
+
+ api_client = api_client_maker(
+ path="/auth/token/cli",
+ response_json=self.login_response.model_dump(),
+ expected_http_status_code=201,
+ kind=ClientKind.AUTH,
+ )
+
+ mock_keyring.set_password = mock.MagicMock()
+ mock_keyring.get_password.return_value = None
+ env = "TEST_AUTH_LOGIN"
+
+ auth_command.login(
+ self.parser.parse_args(["auth", "login", "--api-url", "http://localhost:8080"]),
+ api_client=api_client,
+ )
+
+ config_path = os.path.join(temp_airflow_home, f"{env}.json")
+ assert os.path.exists(config_path)
+ with open(config_path) as f:
+ assert json.load(f) == {"api_url": "http://localhost:8080"}
+
+ mock_keyring.set_password.assert_called_once_with(
+ "airflowctl", "api_token_TEST_AUTH_LOGIN", "TEST_TOKEN"
+ )
+
+ # Test auth login with username and password
+ @patch("airflowctl.api.client.keyring")
+ def test_login_with_username_and_password(self, mock_keyring, api_client_maker):
+ api_client = api_client_maker(
+ path="/auth/token/cli",
+ response_json=self.login_response.model_dump(),
+ expected_http_status_code=201,
+ kind=ClientKind.AUTH,
+ )
+
+ mock_keyring.set_password = mock.MagicMock()
+ mock_keyring.get_password.return_value = None
+ with (
+ patch("sys.stdin", io.StringIO("test_password")),
+ patch("airflowctl.ctl.cli_config.getpass.getpass", return_value="test_password"),
+ ):
+ auth_command.login(
+ self.parser.parse_args(
+ [
+ "auth",
+ "login",
+ "--api-url",
+ "http://localhost:8080",
+ "--username",
+ "test_user",
+ "--password",
+ ]
+ ),
+ api_client=api_client,
+ )
+ mock_keyring.set_password.assert_has_calls(
+ [
+ mock.call("airflowctl", "api_token_production", ""),
+ mock.call("airflowctl", "api_token_production", "TEST_TOKEN"),
+ ]
+ )
diff --git a/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_config_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_config_command.py
new file mode 100644
index 00000000000..f87a9851a52
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_config_command.py
@@ -0,0 +1,363 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import os
+from unittest.mock import patch
+
+from airflowctl.api.client import ClientKind
+from airflowctl.api.datamodels.generated import Config, ConfigOption, ConfigSection
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.commands import config_command
+from airflowctl.ctl.commands.config_command import ConfigChange, ConfigParameter
+
+
+class TestCliConfigCommands:
+ parser = cli_parser.get_parser()
+
+ @patch("rich.print")
+ def test_lint_no_issues(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section",
+ options=[
+ ConfigOption(
+ key="test_key",
+ value="test_value",
+ )
+ ],
+ )
+ ]
+ )
+
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[green]No issues found in your airflow.cfg. It is ready for Airflow 3![/green]" in calls[0]
+
+ @patch("airflowctl.api.client.Credentials.load")
+ @patch("rich.print")
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_CONFIG"})
+ @patch(
+ "airflowctl.ctl.commands.config_command.CONFIGS_CHANGES",
+ [
+ ConfigChange(
+ config=ConfigParameter("test_section", "test_option"),
+ default_change=True,
+ old_default="old_default",
+ new_default="new_default",
+ ),
+ ],
+ )
+ def test_lint_detects_default_changed_configs(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section",
+ options=[
+ ConfigOption(
+ key="test_option",
+ value="old_default",
+ )
+ ],
+ )
+ ]
+ )
+
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+ api_client.configs.list.return_value = response_config
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[red]Found issues in your airflow.cfg:[/red]" in calls[0]
+ assert (
+ " - [yellow]Changed default value of `test_option` in `test_section` from `old_default` to `new_default`.[/yellow]"
+ in calls[1]
+ )
+
+ @patch("airflowctl.api.client.Credentials.load")
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_CONFIG"})
+ @patch("rich.print")
+ @patch(
+ "airflowctl.ctl.commands.config_command.CONFIGS_CHANGES",
+ [
+ ConfigChange(
+ config=ConfigParameter("test_section", "test_option"),
+ was_removed=True,
+ ),
+ ],
+ )
+ def test_lint_detects_removed_configs(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section",
+ options=[
+ ConfigOption(
+ key="test_option",
+ value="test_value",
+ )
+ ],
+ )
+ ]
+ )
+
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+ api_client.configs.list.return_value = response_config
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[red]Found issues in your airflow.cfg:[/red]" in calls[0]
+ assert (
+ "- [yellow]Removed deprecated `test_option` configuration parameter from `test_section` section.[/yellow]"
+ in calls[1]
+ )
+
+ @patch("airflowctl.api.client.Credentials.load")
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_CONFIG"})
+ @patch("rich.print")
+ @patch(
+ "airflowctl.ctl.commands.config_command.CONFIGS_CHANGES",
+ [
+ ConfigChange(
+ config=ConfigParameter("test_section_1", "test_option"),
+ renamed_to=ConfigParameter("test_section_2", "test_option"),
+ ),
+ ],
+ )
+ def test_lint_detects_renamed_configs_different_section(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section_1",
+ options=[
+ ConfigOption(
+ key="test_option",
+ value="test_value",
+ )
+ ],
+ ),
+ ConfigSection(
+ name="test_section_2",
+ options=[
+ ConfigOption(
+ key="test_option",
+ value="test_value",
+ )
+ ],
+ ),
+ ]
+ )
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+ api_client.configs.list.return_value = response_config
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[red]Found issues in your airflow.cfg:[/red]" in calls[0]
+ assert (
+ "- [yellow]`test_option` configuration parameter moved from `test_section_1` section to `test_section_2` section as `test_option`.[/yellow]"
+ in calls[1]
+ )
+
+ @patch("airflowctl.api.client.Credentials.load")
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_CONFIG"})
+ @patch("rich.print")
+ @patch(
+ "airflowctl.ctl.commands.config_command.CONFIGS_CHANGES",
+ [
+ ConfigChange(
+ config=ConfigParameter("test_section", "test_option_1"),
+ renamed_to=ConfigParameter("test_section", "test_option_2"),
+ ),
+ ],
+ )
+ def test_lint_detects_renamed_configs_same_section(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section",
+ options=[
+ ConfigOption(
+ key="test_option_1",
+ value="test_value_1",
+ ),
+ ConfigOption(
+ key="test_option_2",
+ value="test_value_2",
+ ),
+ ],
+ )
+ ]
+ )
+
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+ api_client.configs.list.return_value = response_config
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[red]Found issues in your airflow.cfg:[/red]" in calls[0]
+ assert (
+ "- [yellow]`test_option_1` configuration parameter renamed to `test_option_2` in the `test_section` section.[/yellow]"
+ in calls[1]
+ )
+
+ @patch("airflowctl.api.client.Credentials.load")
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_CONFIG"})
+ @patch("rich.print")
+ @patch(
+ "airflowctl.ctl.commands.config_command.CONFIGS_CHANGES",
+ [
+ ConfigChange(
+ config=ConfigParameter("test_section", "test_option"),
+ was_removed=False,
+ is_invalid_if="0",
+ suggestion="Please set the `test_option` configuration parameter to a value greater than 0.",
+ ),
+ ],
+ )
+ def test_lint_detects_invalid_values(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section",
+ options=[
+ ConfigOption(
+ key="test_option",
+ value="0",
+ )
+ ],
+ )
+ ]
+ )
+
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+ api_client.configs.list.return_value = response_config
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[red]Found issues in your airflow.cfg:[/red]" in calls[0]
+ assert (
+ "- [yellow]Invalid value `0` set for `test_option` configuration parameter in `test_section` section. "
+ "Please set the `test_option` configuration parameter to a value greater than 0.[/yellow]"
+ in calls[1]
+ )
+
+ @patch("airflowctl.api.client.Credentials.load")
+ @patch.dict(os.environ, {"AIRFLOW_CLI_TOKEN": "TEST_TOKEN"})
+ @patch.dict(os.environ, {"AIRFLOW_CLI_ENVIRONMENT": "TEST_CONFIG"})
+ @patch("rich.print")
+ @patch(
+ "airflowctl.ctl.commands.config_command.CONFIGS_CHANGES",
+ [
+ ConfigChange(
+ config=ConfigParameter("test_section", "test_option"),
+ suggestion="This is a test suggestion.",
+ ),
+ ],
+ )
+ def test_lint_detects_configs_with_suggestions(self, mock_rich_print, api_client_maker):
+ response_config = Config(
+ sections=[
+ ConfigSection(
+ name="test_section",
+ options=[
+ ConfigOption(
+ key="test_option",
+ value="test_value",
+ )
+ ],
+ )
+ ]
+ )
+
+ api_client = api_client_maker(
+ path="/api/v2/config",
+ response_json=response_config.model_dump(),
+ expected_http_status_code=200,
+ kind=ClientKind.CLI,
+ )
+ api_client.configs.list.return_value = response_config
+
+ config_command.lint(
+ self.parser.parse_args(["config", "lint"]),
+ api_client=api_client,
+ )
+
+ calls = [call[0][0] for call in mock_rich_print.call_args_list]
+ assert "[red]Found issues in your airflow.cfg:[/red]" in calls[0]
+ assert "This is a test suggestion." in calls[1]
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
similarity index 50%
copy from airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
index e0c9ce16d8d..a63a4b4fa6a 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_connections_command.py
@@ -17,7 +17,6 @@
from __future__ import annotations
import json
-import os
import pytest
@@ -25,38 +24,43 @@ from airflowctl.api.client import ClientKind
from airflowctl.api.datamodels.generated import (
BulkActionResponse,
BulkResponse,
- VariableCollectionResponse,
- VariableResponse,
+ ConnectionCollectionResponse,
+ ConnectionResponse,
)
from airflowctl.ctl import cli_parser
-from airflowctl.ctl.commands import variable_command
+from airflowctl.ctl.commands import connection_command
-class TestCliVariableCommands:
- key = "key"
- value = "value"
- description = "description"
+class TestCliConnectionCommands:
+ connection_id = "test_connection"
export_file_name = "exported_json.json"
parser = cli_parser.get_parser()
- variable_collection_response = VariableCollectionResponse(
- variables=[
- VariableResponse(
- key=key,
- value=value,
- description=description,
- is_encrypted=False,
- ),
+ connection_collection_response = ConnectionCollectionResponse(
+ connections=[
+ ConnectionResponse(
+ connection_id=connection_id,
+ conn_type="test_type",
+ host="test_host",
+ login="test_login",
+ password="test_password",
+ port=1234,
+ extra="{}",
+ description="Test connection description",
+ )
],
total_entries=1,
)
bulk_response_success = BulkResponse(
- create=BulkActionResponse(success=[key], errors=[]), update=None, delete=None
+ create=BulkActionResponse(success=[connection_id], errors=[]), update=None, delete=None
)
bulk_response_error = BulkResponse(
create=BulkActionResponse(
success=[],
errors=[
- {"error": f"The variables with these keys: {{'{key}'}} already exist.", "status_code": 409}
+ {
+ "error": f"The connection with these connection_ids: {{'{connection_id}'}} already exist.",
+ "status_code": 409,
+ }
],
),
update=None,
@@ -65,7 +69,7 @@ class TestCliVariableCommands:
def test_import_success(self, api_client_maker, tmp_path, monkeypatch):
api_client = api_client_maker(
- path="/api/v2/variables",
+ path="/api/v2/connections",
response_json=self.bulk_response_success.model_dump(),
expected_http_status_code=200,
kind=ClientKind.CLI,
@@ -73,20 +77,28 @@ class TestCliVariableCommands:
monkeypatch.chdir(tmp_path)
expected_json_path = tmp_path / self.export_file_name
- variable_file = {
- self.key: self.value,
+ connection_file = {
+ self.connection_id: {
+ "conn_type": "test_type",
+ "host": "test_host",
+ "login": "test_login",
+ "password": "test_password",
+ "port": 1234,
+ "extra": "{}",
+ "description": "Test connection description",
+ "connection_id": self.connection_id,
+ }
}
- expected_json_path.write_text(json.dumps(variable_file))
- response = variable_command.import_(
- self.parser.parse_args(["variables", "import", expected_json_path.as_posix()]),
+ expected_json_path.write_text(json.dumps(connection_file))
+ connection_command.import_(
+ self.parser.parse_args(["connections", "import", expected_json_path.as_posix()]),
api_client=api_client,
)
- assert response == [self.key]
def test_import_error(self, api_client_maker, tmp_path, monkeypatch):
api_client = api_client_maker(
- path="/api/v2/variables",
+ path="/api/v2/connections",
response_json=self.bulk_response_error.model_dump(),
expected_http_status_code=200,
kind=ClientKind.CLI,
@@ -94,32 +106,22 @@ class TestCliVariableCommands:
monkeypatch.chdir(tmp_path)
expected_json_path = tmp_path / self.export_file_name
- variable_file = {
- self.key: self.value,
+ connection_file = {
+ self.connection_id: {
+ "conn_type": "test_type",
+ "host": "test_host",
+ "login": "test_login",
+ "password": "test_password",
+ "port": 1234,
+ "extra": "{}",
+ "description": "Test connection description",
+ "connection_id": self.connection_id,
+ }
}
- expected_json_path.write_text(json.dumps(variable_file))
+ expected_json_path.write_text(json.dumps(connection_file))
with pytest.raises(SystemExit):
- variable_command.import_(
- self.parser.parse_args(["variables", "import", expected_json_path.as_posix()]),
+ connection_command.import_(
+ self.parser.parse_args(["connections", "import", expected_json_path.as_posix()]),
api_client=api_client,
)
-
- def test_export(self, api_client_maker, tmp_path, monkeypatch):
- api_client = api_client_maker(
- path="/api/v2/variables",
- response_json=self.variable_collection_response.model_dump(),
- expected_http_status_code=200,
- kind=ClientKind.CLI,
- )
-
- monkeypatch.chdir(tmp_path)
- expected_json_path = (tmp_path / self.export_file_name).as_posix()
- variable_command.export(
- self.parser.parse_args(["variables", "export", expected_json_path]),
- api_client=api_client,
- )
- assert os.path.exists(tmp_path / self.export_file_name)
-
- with open(expected_json_path) as f:
- assert json.load(f) == {self.key: {"description": self.description, "value": self.value}}
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py
similarity index 100%
copy from airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py
diff --git a/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
new file mode 100644
index 00000000000..44cc341ee1c
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_pool_command.py
@@ -0,0 +1,188 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for pool commands."""
+
+from __future__ import annotations
+
+import json
+from unittest import mock
+
+import pytest
+
+from airflowctl.api.client import Client
+from airflowctl.api.datamodels.generated import (
+ BulkActionOnExistence,
+ BulkBodyPoolBody,
+ BulkCreateActionPoolBody,
+)
+from airflowctl.ctl.commands import pool_command
+
+
+@pytest.fixture
+def mock_client():
+ """Create a mock client."""
+ with mock.patch("airflowctl.api.client.get_client") as mock_get_client:
+ client = mock.MagicMock(spec=Client)
+ mock_get_client.return_value.__enter__.return_value = client
+ yield client
+
+
+class TestPoolImportCommand:
+ """Test cases for pool import command."""
+
+ def test_import_missing_file(self, mock_client, tmp_path):
+ """Test import with missing file."""
+ non_existent = tmp_path / "non_existent.json"
+ with pytest.raises(SystemExit, match=f"Missing pools file {non_existent}"):
+ pool_command.import_(args=mock.MagicMock(file=non_existent))
+
+ def test_import_invalid_json(self, mock_client, tmp_path):
+ """Test import with invalid JSON file."""
+ invalid_json = tmp_path / "invalid.json"
+ invalid_json.write_text("invalid json")
+ with pytest.raises(SystemExit, match="Invalid json file"):
+ pool_command.import_(args=mock.MagicMock(file=invalid_json))
+
+ def test_import_invalid_pool_config(self, mock_client, tmp_path):
+ """Test import with invalid pool configuration."""
+ invalid_pool = tmp_path / "invalid_pool.json"
+ invalid_pool.write_text(json.dumps([{"invalid": "config"}]))
+ with pytest.raises(SystemExit, match="Invalid pool configuration: {'invalid': 'config'}"):
+ pool_command.import_(args=mock.MagicMock(file=invalid_pool))
+
+ def test_import_success(self, mock_client, tmp_path, capsys):
+ """Test successful pool import."""
+ pools_file = tmp_path / "pools.json"
+ pools_data = [
+ {
+ "name": "test_pool",
+ "slots": 1,
+ "description": "Test pool",
+ "include_deferred": True,
+ }
+ ]
+ pools_file.write_text(json.dumps(pools_data))
+
+ # Mock the bulk response with the correct structure
+ mock_response = mock.MagicMock()
+ mock_response.success = ["test_pool"]
+ mock_response.errors = []
+
+ mock_bulk_builder = mock.MagicMock()
+ mock_bulk_builder.create = mock_response
+
+ mock_client.pools.bulk.return_value = mock_bulk_builder
+
+ pool_command.import_(args=mock.MagicMock(file=pools_file))
+
+ # Verify bulk operation was called with correct parameters
+ mock_client.pools.bulk.assert_called_once()
+ call_args = mock_client.pools.bulk.call_args[1]
+ assert isinstance(call_args["pools"], BulkBodyPoolBody)
+ assert len(call_args["pools"].actions) == 1
+ action = call_args["pools"].actions[0]
+ assert isinstance(action, BulkCreateActionPoolBody)
+ assert action.action == "create"
+ assert action.action_on_existence == BulkActionOnExistence.FAIL
+ assert len(action.entities) == 1
+ assert action.entities[0].name == "test_pool"
+ assert action.entities[0].slots == 1
+ assert action.entities[0].description == "Test pool"
+ assert action.entities[0].include_deferred is True
+
+ # Update the assertion to match the actual output format
+ captured = capsys.readouterr()
+ assert str(["test_pool"]) in captured.out
+
+
+class TestPoolExportCommand:
+ """Test cases for pool export command."""
+
+ def test_export_json_to_file(self, mock_client, tmp_path, capsys):
+ """Test successful pool export to file with json output."""
+ export_file = tmp_path / "export.json"
+ # Create a proper pool object with dictionary attributes instead of MagicMock
+ pool = {
+ "name": "test_pool",
+ "slots": 1,
+ "description": "Test pool",
+ "include_deferred": True,
+ "occupied_slots": 0,
+ "running_slots": 0,
+ "queued_slots": 0,
+ "scheduled_slots": 0,
+ "open_slots": 1,
+ "deferred_slots": 0,
+ }
+ # Create a mock response with proper dictionary attributes
+ mock_pools = mock.MagicMock()
+ mock_pools.pools = [type("Pool", (), pool)()]
+ mock_pools.total_entries = 1
+ mock_client.pools.list.return_value = mock_pools
+
+ pool_command.export(args=mock.MagicMock(file=export_file, output="json"))
+
+ # Verify the exported file content
+ exported_data = json.loads(export_file.read_text())
+ assert len(exported_data) == 1
+ assert exported_data[0]["name"] == "test_pool"
+ assert exported_data[0]["slots"] == 1
+ assert exported_data[0]["description"] == "Test pool"
+ assert exported_data[0]["include_deferred"] is True
+
+ # Verify output message
+ captured = capsys.readouterr()
+ expected_output = f"Exported {len(exported_data)} pool(s) to {export_file}"
+ assert expected_output in captured.out.replace("\n", "")
+
+ def test_export_non_json_output(self, mock_client, tmp_path, capsys):
+ """Test pool export with non-json output format."""
+ # Create a proper dictionary structure
+ mock_pool = {
+ "name": "test_pool",
+ "slots": 1,
+ "description": "Test pool",
+ "include_deferred": True,
+ "occupied_slots": 0,
+ "running_slots": 0,
+ "queued_slots": 0,
+ "scheduled_slots": 0,
+ "open_slots": 1,
+ "deferred_slots": 0,
+ }
+ # Create a mock response with a proper pools attribute
+ mock_pools = mock.MagicMock()
+ mock_pools.pools = [mock.MagicMock(**mock_pool)]
+ mock_pools.total_entries = 1
+ mock_client.pools.list.return_value = mock_pools
+
+ pool_command.export(args=mock.MagicMock(file=tmp_path / "unused.json", output="table"))
+
+ # Verify console output contains the raw dict
+ captured = capsys.readouterr()
+ assert "test_pool" in captured.out
+ assert "slots" in captured.out
+ assert "description" in captured.out
+ assert "include_deferred" in captured.out
+
+ def test_export_failure(self, mock_client, tmp_path):
+ """Test pool export with API failure."""
+ export_file = tmp_path / "export.json"
+ mock_client.pools.list.side_effect = Exception("API Error")
+
+ with pytest.raises(SystemExit, match="Failed to export pools: API Error"):
+ pool_command.export(args=mock.MagicMock(file=export_file, output="json"))
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
similarity index 100%
copy from airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
copy to airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
diff --git a/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py
new file mode 100644
index 00000000000..72998b97442
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/commands/test_version_command.py
@@ -0,0 +1,63 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from contextlib import redirect_stdout
+from io import StringIO
+from unittest import mock
+
+import pytest
+
+from airflowctl.api.client import Client
+from airflowctl.ctl import cli_parser
+from airflowctl.ctl.commands.version_command import version_info
+
+
+@pytest.fixture
+def mock_client():
+ """create a mock client"""
+ with mock.patch("airflowctl.api.client.get_client") as mock_get_client:
+ client = mock.MagicMock(spec=Client)
+ mock_get_client.return_value.__enter__.return_value = client
+
+ client.version.get.return_value.model_dump.return_value = {
+ "version": "3.1.0",
+ "git_version": None,
+ "airflowctl_version": "0.1.0",
+ }
+
+ return client
+
+
+class TestVersionCommand:
+ """Test the version command."""
+
+ parser = cli_parser.get_parser()
+
+ def test_ctl_version_remote(self, mock_client):
+ with redirect_stdout(StringIO()) as stdout:
+ version_info(self.parser.parse_args(["version", "--remote"]), api_client=mock_client)
+ assert "version" in stdout.getvalue()
+ assert "git_version" in stdout.getvalue()
+ assert "airflowctl_version" in stdout.getvalue()
+
+ def test_ctl_version_only_local_version(self, mock_client):
+ """Test the version command with an exception."""
+ with redirect_stdout(StringIO()) as stdout:
+ version_info(self.parser.parse_args(["version"]), api_client=mock_client)
+ output = stdout.getvalue()
+ assert "airflowctl_version" in output
diff --git a/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
new file mode 100644
index 00000000000..c0ac022a256
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -0,0 +1,357 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from argparse import BooleanOptionalAction
+from textwrap import dedent
+
+import pytest
+
+from airflowctl.ctl.cli_config import ActionCommand, CommandFactory, GroupCommand, merge_commands
+
+
+@pytest.fixture
+def no_op_method():
+ """
+ No operation method to be used as a placeholder for the actual method.
+ """
+
+ def no_op():
+ pass
+
+ return no_op
+
+
+@pytest.fixture(scope="module")
+def test_args_create():
+ return [
+ (
+ "--dag-id",
+ {
+ "help": "dag_id for backfill operation",
+ "action": None,
+ "default": None,
+ "type": str,
+ "dest": None,
+ },
+ ),
+ (
+ "--from-date",
+ {
+ "help": "from_date for backfill operation",
+ "action": None,
+ "default": None,
+ "type": str,
+ "dest": None,
+ },
+ ),
+ (
+ "--to-date",
+ {
+ "help": "to_date for backfill operation",
+ "action": None,
+ "default": None,
+ "type": str,
+ "dest": None,
+ },
+ ),
+ (
+ "--run-backwards",
+ {
+ "help": "run_backwards for backfill operation",
+ "action": BooleanOptionalAction,
+ "default": False,
+ "type": bool,
+ "dest": None,
+ },
+ ),
+ (
+ "--dag-run-conf",
+ {
+ "help": "dag_run_conf for backfill operation",
+ "action": None,
+ "default": None,
+ "type": dict,
+ "dest": None,
+ },
+ ),
+ (
+ "--reprocess-behavior",
+ {
+ "help": "reprocess_behavior for backfill operation",
+ "action": None,
+ "default": None,
+ "type": str,
+ "dest": None,
+ },
+ ),
+ (
+ "--max-active-runs",
+ {
+ "help": "max_active_runs for backfill operation",
+ "action": None,
+ "default": None,
+ "type": int,
+ "dest": None,
+ },
+ ),
+ ]
+
+
+"""
+ help="Output format. Allowed values: json, yaml, plain, table (default: json)",
+ metavar="(table, json, yaml, plain)",
+ choices=("table", "json", "yaml", "plain"),
+ default="json",
+"""
+
+
+@pytest.fixture(scope="module")
+def test_args_list():
+ return [
+ (
+ "--output",
+ {
+ "help": "Output format. Allowed values: json, yaml, plain, table (default: json)",
+ "default": "json",
+ "type": str,
+ "dest": None,
+ },
+ ),
+ ]
+
+
+@pytest.fixture(scope="module")
+def test_args_get():
+ return [
+ (
+ "--backfill-id",
+ {
+ "help": "backfill_id for get operation in BackfillsOperations",
+ "default": None,
+ "type": str,
+ "dest": None,
+ },
+ ),
+ (
+ "--output",
+ {
+ "help": "Output format. Allowed values: json, yaml, plain, table (default: json)",
+ "default": "json",
+ "type": str,
+ "dest": None,
+ },
+ ),
+ ]
+
+
+@pytest.fixture(scope="module")
+def test_args_delete():
+ return [
+ (
+ "--backfill-id",
+ {
+ "help": "backfill_id for delete operation in BackfillsOperations",
+ "default": None,
+ "type": str,
+ "dest": None,
+ },
+ ),
+ (
+ "--output",
+ {
+ "help": "Output format. Allowed values: json, yaml, plain, table (default: json)",
+ "default": "json",
+ "type": str,
+ "dest": None,
+ },
+ ),
+ ]
+
+
+class TestCommandFactory:
+ @classmethod
+ def _save_temp_operations_py(cls, temp_file: str, file_content) -> None:
+ """
+ Save a temporary operations.py file with a simple Command Class to test the command factory.
+ """
+ with open(temp_file, "w") as f:
+ f.write(dedent(file_content))
+
+ def teardown_method(self):
+ """
+ Remove the temporary file after the test.
+ """
+ try:
+ import os
+
+ os.remove("test_command.py")
+ except FileNotFoundError:
+ pass
+
+ def test_command_factory(
+ self, no_op_method, test_args_create, test_args_list, test_args_get, test_args_delete
+ ):
+ """
+ Test the command factory.
+ """
+ # Create temporary file with pytest and write simple Command Class(check airflow-ctl/src/airflowctl/api/operations.py) to file
+ # to test the command factory
+ # Create a temporary file
+ temp_file = "test_command.py"
+ self._save_temp_operations_py(
+ temp_file=temp_file,
+ file_content="""
+ class NotAnOperation:
+ def test_method(self):
+ '''I am not included in the command factory.'''
+ pass
+
+ class BackfillsOperations(BaseOperations):
+ def create(self, backfill: BackfillPostBody) -> BackfillResponse | ServerResponseError:
+ try:
+ self.response = self.client.post("backfills", json=backfill.model_dump(mode="json"))
+ return BackfillResponse.model_validate_json(self.response.content)
+ except ServerResponseError as e:
+ raise e
+ def list(self) -> BackfillListResponse:
+ params = {"dag_id": dag_id} if dag_id else {}
+ self.response = self.client.get("backfills", params=params)
+ return BackfillListResponse.model_validate_json(self.response.content)
+ def get(self, backfill_id: str) -> BackfillResponse | ServerResponseError:
+ self.response = self.client.get(f"backfills/{backfill_id}")
+ return BackfillResponse.model_validate_json(self.response.content)
+ def delete(self, backfill_id: str) -> ServerResponseError | None:
+ self.response = self.client.delete(f"backfills/{backfill_id}")
+ return None
+ """,
+ )
+
+ command_factory = CommandFactory(file_path=temp_file)
+ generated_group_commands = command_factory.group_commands
+
+ for generated_group_command in generated_group_commands:
+ assert isinstance(generated_group_command, GroupCommand)
+ assert generated_group_command.name == "backfills"
+ assert generated_group_command.help == "Perform Backfills operations"
+ for sub_command in generated_group_command.subcommands:
+ if sub_command.name == "create":
+ for arg, test_arg in zip(sub_command.args, test_args_create):
+ assert arg.flags[0] == test_arg[0]
+ assert arg.kwargs["help"] == test_arg[1]["help"]
+ assert arg.kwargs["action"] == test_arg[1]["action"]
+ assert arg.kwargs["default"] == test_arg[1]["default"]
+ assert arg.kwargs["type"] == test_arg[1]["type"]
+ assert arg.kwargs["dest"] == test_arg[1]["dest"]
+ print(arg.flags)
+ elif sub_command.name == "list":
+ for arg, test_arg in zip(sub_command.args, test_args_list):
+ assert arg.flags[0] == test_arg[0]
+ assert arg.kwargs["help"] == test_arg[1]["help"]
+ assert arg.kwargs["default"] == test_arg[1]["default"]
+ assert arg.kwargs["type"] == test_arg[1]["type"]
+ elif sub_command.name == "get":
+ for arg, test_arg in zip(sub_command.args, test_args_get):
+ assert arg.flags[0] == test_arg[0]
+ assert arg.kwargs["help"] == test_arg[1]["help"]
+ assert arg.kwargs["default"] == test_arg[1]["default"]
+ assert arg.kwargs["type"] == test_arg[1]["type"]
+ elif sub_command.name == "delete":
+ for arg, test_arg in zip(sub_command.args, test_args_delete):
+ assert arg.flags[0] == test_arg[0]
+ assert arg.kwargs["help"] == test_arg[1]["help"]
+ assert arg.kwargs["default"] == test_arg[1]["default"]
+ assert arg.kwargs["type"] == test_arg[1]["type"]
+
+
+class TestCliConfigMethods:
+ def test_merge_commands(self, no_op_method):
+ """Test the merge_commands method."""
+ # Create two Command objects with different names and help texts
+ action_commands_1 = (
+ ActionCommand(
+ name="subcommand1",
+ help="This is command 1",
+ func=no_op_method,
+ args=(),
+ ),
+ ActionCommand(
+ name="subcommand2",
+ help="This is command 2",
+ func=no_op_method,
+ args=(),
+ ),
+ )
+ action_commands_2 = (
+ ActionCommand(
+ name="subcommand3",
+ help="This is command 3",
+ func=no_op_method,
+ args=(),
+ ),
+ ActionCommand(
+ name="subcommand4",
+ help="This is command 4",
+ func=no_op_method,
+ args=(),
+ ),
+ )
+ command_list_1 = [
+ GroupCommand(
+ name="command1",
+ help="This is command 1",
+ subcommands=action_commands_1,
+ ),
+ GroupCommand(
+ name="command2",
+ help="This is command 2",
+ subcommands=action_commands_2,
+ ),
+ ]
+ command_list_2 = [
+ GroupCommand(
+ name="command1",
+ help="This is command 1 new help",
+ description="This is command 1 new description",
+ subcommands=action_commands_2,
+ ),
+ GroupCommand(
+ name="command4",
+ help="This is command 4",
+ subcommands=action_commands_1,
+ ),
+ ]
+
+ # Merge the commands
+ merged_command = merge_commands(base_commands=command_list_1, commands_will_be_merged=command_list_2)
+ merged_command_names = [command.name for command in merged_command]
+ assert "command1" in merged_command_names
+ assert "command2" in merged_command_names
+ assert "command3" not in merged_command_names
+ assert "command4" in merged_command_names
+
+ for command in merged_command:
+ if command.name == "command1":
+ # assert command.help == "This is command 1 new help"
+ # assert command.description == "This is command 1 new description"
+ sub_command_names = [sc.name for sc in list(command.subcommands)]
+ print(f"sub_command_names: {sub_command_names}")
+ assert "subcommand1" in sub_command_names
+ assert "subcommand2" in sub_command_names
+ assert "subcommand3" in sub_command_names
+ assert "subcommand4" in sub_command_names
diff --git a/airflow-ctl/airflow-ctl/tests/conftest.py b/airflow-ctl/airflow-ctl/tests/conftest.py
new file mode 100644
index 00000000000..5b76e9124c4
--- /dev/null
+++ b/airflow-ctl/airflow-ctl/tests/conftest.py
@@ -0,0 +1,79 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import os
+from unittest.mock import patch
+
+import httpx
+import pytest
+
+from airflowctl.api.client import Client, ClientKind, Credentials
+
+pytest_plugins = "tests_common.pytest_plugin"
+
+# Task SDK does not need access to the Airflow database
+os.environ["_AIRFLOW_SKIP_DB_TESTS"] = "true"
+os.environ["_AIRFLOW__AS_LIBRARY"] = "true"
+
+
+@pytest.hookimpl()
+def pytest_addhooks(pluginmanager: pytest.PytestPluginManager):
+ # Python 3.12 starts warning about mixing os.fork + Threads, and the pytest-rerunfailures plugin uses
+ # threads internally. Since this is new code, and it should be flake free, we disable the re-run failures
+ # plugin early (so that it doesn't run it's pytest_configure which is where the thread starts up if xdist
+ # is discovered).
+ pluginmanager.set_blocked("rerunfailures")
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_runtest_setup(item):
+ if next(item.iter_markers(name="db_test"), None):
+ pytest.fail("Airflow CTL tests must not use database")
+
+
+@pytest.fixture(scope="session")
+def api_client_maker(client_credentials):
+ """
+ Create a CLI API client with a custom transport and returns callable to create a client with a custom transport
+ """
+
+ def make_api_client(transport: httpx.MockTransport, kind: ClientKind = ClientKind.CLI) -> Client:
+ """Get a client with a custom transport"""
+ return Client(base_url="test://server", transport=transport, token="", kind=kind)
+
+ def _api_client(
+ path: str, response_json: dict, expected_http_status_code: int, kind: ClientKind = ClientKind.CLI
+ ) -> Client:
+ """Get a client with a custom transport"""
+
+ def handle_request(request: httpx.Request) -> httpx.Response:
+ """Handle the request and return a response"""
+ assert request.url.path == path
+ return httpx.Response(expected_http_status_code, json=response_json)
+
+ return make_api_client(transport=httpx.MockTransport(handle_request), kind=kind)
+
+ return _api_client
+
+
+@pytest.fixture(scope="session")
+def client_credentials():
+ """Create credentials for CLI API"""
+ with patch("airflowctl.api.client.keyring"):
+ Credentials(api_url="http://localhost:9091", api_token="NO_TOKEN").save()
diff --git a/airflow-ctl/docs/images/command_hashes.txt b/airflow-ctl/docs/images/command_hashes.txt
index 6792a0234ef..c0e3b5995fa 100644
--- a/airflow-ctl/docs/images/command_hashes.txt
+++ b/airflow-ctl/docs/images/command_hashes.txt
@@ -3,12 +3,12 @@ assets:b3ae2b933e54528bf486ff28e887804d
auth:f396d4bce90215599dde6ad0a8f30f29
backfill:bbce9859a2d1ce054ad22db92dea8c05
config:cb175bedf29e8a2c2c6a2ebd13d770a7
-connections:a16225e1c7d28488d0da612752669b4b
+connections:e34b6b93f64714986139958c1f370428
dags:287a128a71c97d2b537e09a5c7c73c09
dagrun:f47ed2a89ed0f8c71f79dba53a3a3882
jobs:7f8680afff230eb9940bc7fca727bd52
pools:03fc7d948cbecf16ff8d640eb8f0ce43
providers:1c0afb2dff31d93ab2934b032a2250ab
-variables:0b04188937b3c364204ef4cc9a541c62
+variables:0354f8f4b0dde1c3771ed1568692c6ae
version:d4a7a6229b3a204f114283b62eac789b
auth login:5277c653ff6dce51f37472dc0bda9775
diff --git a/airflow-ctl/docs/images/output_connections.svg b/airflow-ctl/docs/images/output_connections.svg
index 8283756115f..7dc48cce16a 100644
--- a/airflow-ctl/docs/images/output_connections.svg
+++ b/airflow-ctl/docs/images/output_connections.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 811 562.4" xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 933 489.2" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -19,123 +19,110 @@
font-weight: 700;
}
- .terminal-3087056112-matrix {
+ .terminal-1162206820-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-3087056112-title {
+ .terminal-1162206820-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-3087056112-r1 { fill: #ff8700 }
-.terminal-3087056112-r2 { fill: #c5c8c6 }
-.terminal-3087056112-r3 { fill: #808080 }
-.terminal-3087056112-r4 { fill: #68a0b3 }
-.terminal-3087056112-r5 { fill: #c5c8c6;font-weight: bold }
+ .terminal-1162206820-r1 { fill: #ff8700 }
+.terminal-1162206820-r2 { fill: #c5c8c6 }
+.terminal-1162206820-r3 { fill: #808080 }
+.terminal-1162206820-r4 { fill: #68a0b3 }
</style>
<defs>
- <clipPath id="terminal-3087056112-clip-terminal">
- <rect x="0" y="0" width="792.0" height="511.4" />
+ <clipPath id="terminal-1162206820-clip-terminal">
+ <rect x="0" y="0" width="914.0" height="438.2" />
</clipPath>
- <clipPath id="terminal-3087056112-line-0">
- <rect x="0" y="1.5" width="793" height="24.65"/>
+ <clipPath id="terminal-1162206820-line-0">
+ <rect x="0" y="1.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-1">
- <rect x="0" y="25.9" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-1">
+ <rect x="0" y="25.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-2">
- <rect x="0" y="50.3" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-2">
+ <rect x="0" y="50.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-3">
- <rect x="0" y="74.7" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-3">
+ <rect x="0" y="74.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-4">
- <rect x="0" y="99.1" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-4">
+ <rect x="0" y="99.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-5">
- <rect x="0" y="123.5" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-5">
+ <rect x="0" y="123.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-6">
- <rect x="0" y="147.9" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-6">
+ <rect x="0" y="147.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-7">
- <rect x="0" y="172.3" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-7">
+ <rect x="0" y="172.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-8">
- <rect x="0" y="196.7" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-8">
+ <rect x="0" y="196.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-9">
- <rect x="0" y="221.1" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-9">
+ <rect x="0" y="221.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-10">
- <rect x="0" y="245.5" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-10">
+ <rect x="0" y="245.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-11">
- <rect x="0" y="269.9" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-11">
+ <rect x="0" y="269.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-12">
- <rect x="0" y="294.3" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-12">
+ <rect x="0" y="294.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-13">
- <rect x="0" y="318.7" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-13">
+ <rect x="0" y="318.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-14">
- <rect x="0" y="343.1" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-14">
+ <rect x="0" y="343.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-15">
- <rect x="0" y="367.5" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-15">
+ <rect x="0" y="367.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-3087056112-line-16">
- <rect x="0" y="391.9" width="793" height="24.65"/>
- </clipPath>
-<clipPath id="terminal-3087056112-line-17">
- <rect x="0" y="416.3" width="793" height="24.65"/>
- </clipPath>
-<clipPath id="terminal-3087056112-line-18">
- <rect x="0" y="440.7" width="793" height="24.65"/>
- </clipPath>
-<clipPath id="terminal-3087056112-line-19">
- <rect x="0" y="465.1" width="793" height="24.65"/>
+<clipPath id="terminal-1162206820-line-16">
+ <rect x="0" y="391.9" width="915" height="24.65"/>
</clipPath>
</defs>
- <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="560.4" rx="8"/>
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="931" height="487.2" rx="8"/>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
- <g transform="translate(9, 41)" clip-path="url(#terminal-3087056112-clip-terminal)">
+ <g transform="translate(9, 41)" clip-path="url(#terminal-1162206820-clip-terminal)">
- <g class="terminal-3087056112-matrix">
- <text class="terminal-3087056112-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-3087056112-line-0)">Usage:</text><text class="terminal-3087056112-r3" x="85.4" y="20" textLength="268.4" clip-path="url(#terminal-3087056112-line-0)">airflowctl connections</text><text class="terminal-3087056112-r2" x="353.8" y="20" textLength="24.4" clip-path="url(#terminal-3087056112-line-0)"> [</text><text class="terminal-3087056112-r4" x="378.2" y="20" textLength="24.4" clip-pat [...]
-</text><text class="terminal-3087056112-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-3087056112-line-1)">
-</text><text class="terminal-3087056112-r2" x="0" y="68.8" textLength="366" clip-path="url(#terminal-3087056112-line-2)">Perform Connections operations</text><text class="terminal-3087056112-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-3087056112-line-2)">
-</text><text class="terminal-3087056112-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-3087056112-line-3)">
-</text><text class="terminal-3087056112-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-3087056112-line-4)">Positional Arguments:</text><text class="terminal-3087056112-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-3087056112-line-4)">
-</text><text class="terminal-3087056112-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-3087056112-line-5)">COMMAND</text><text class="terminal-3087056112-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-3087056112-line-5)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-3087056112-line-6)">create</text><text class="terminal-3087056112-r2" x="231.8" y="166.4" textLength="292.8" clip-path="url(#terminal-3087056112-line-6)">Perform create operation</text><text class="terminal-3087056112-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-3087056112-line-6)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="190.8" textLength="183" clip-path="url(#terminal-3087056112-line-7)">create-defaults</text><text class="terminal-3087056112-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-3087056112-line-7)">
-</text><text class="terminal-3087056112-r2" x="231.8" y="215.2" textLength="402.6" clip-path="url(#terminal-3087056112-line-8)">Perform create_defaults operation</text><text class="terminal-3087056112-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-3087056112-line-8)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="239.6" textLength="73.2" clip-path="url(#terminal-3087056112-line-9)">delete</text><text class="terminal-3087056112-r2" x="231.8" y="239.6" textLength="292.8" clip-path="url(#terminal-3087056112-line-9)">Perform delete operation</text><text class="terminal-3087056112-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-3087056112-line-9)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="264" textLength="36.6" clip-path="url(#terminal-3087056112-line-10)">get</text><text class="terminal-3087056112-r2" x="231.8" y="264" textLength="256.2" clip-path="url(#terminal-3087056112-line-10)">Perform get operation</text><text class="terminal-3087056112-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-3087056112-line-10)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="288.4" textLength="73.2" clip-path="url(#terminal-3087056112-line-11)">import</text><text class="terminal-3087056112-r2" x="231.8" y="288.4" textLength="549" clip-path="url(#terminal-3087056112-line-11)">Import connections from a file. This feature </text><text class="terminal-3087056112-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-3087056112-line-11)">
-</text><text class="terminal-3087056112-r2" x="0" y="312.8" textLength="390.4" clip-path="url(#terminal-3087056112-line-12)">is compatible with airflow CLI `</text><text class="terminal-3087056112-r5" x="390.4" y="312.8" textLength="329.4" clip-path="url(#terminal-3087056112-line-12)">airflow connections export </text><text class="terminal-3087056112-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-3087056112-line-12)">
-</text><text class="terminal-3087056112-r5" x="0" y="337.2" textLength="73.2" clip-path="url(#terminal-3087056112-line-13)">a.json</text><text class="terminal-3087056112-r2" x="73.2" y="337.2" textLength="329.4" clip-path="url(#terminal-3087056112-line-13)">` command. Export it from `</text><text class="terminal-3087056112-r5" x="402.6" y="337.2" textLength="134.2" clip-path="url(#terminal-3087056112-line-13)">airflow CLI</text><text class="terminal-30870561 [...]
-</text><text class="terminal-3087056112-r2" x="0" y="361.6" textLength="317.2" clip-path="url(#terminal-3087056112-line-14)">securely via this command.</text><text class="terminal-3087056112-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-3087056112-line-14)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="386" textLength="48.8" clip-path="url(#terminal-3087056112-line-15)">list</text><text class="terminal-3087056112-r2" x="231.8" y="386" textLength="268.4" clip-path="url(#terminal-3087056112-line-15)">Perform list operation</text><text class="terminal-3087056112-r2" x="793" y="386" textLength="12.2" clip-path="url(#terminal-3087056112-line-15)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="410.4" textLength="48.8" clip-path="url(#terminal-3087056112-line-16)">test</text><text class="terminal-3087056112-r2" x="231.8" y="410.4" textLength="268.4" clip-path="url(#terminal-3087056112-line-16)">Perform test operation</text><text class="terminal-3087056112-r2" x="793" y="410.4" textLength="12.2" clip-path="url(#terminal-3087056112-line-16)">
-</text><text class="terminal-3087056112-r4" x="48.8" y="434.8" textLength="73.2" clip-path="url(#terminal-3087056112-line-17)">update</text><text class="terminal-3087056112-r2" x="231.8" y="434.8" textLength="292.8" clip-path="url(#terminal-3087056112-line-17)">Perform update operation</text><text class="terminal-3087056112-r2" x="793" y="434.8" textLength="12.2" clip-path="url(#terminal-3087056112-line-17)">
-</text><text class="terminal-3087056112-r2" x="793" y="459.2" textLength="12.2" clip-path="url(#terminal-3087056112-line-18)">
-</text><text class="terminal-3087056112-r1" x="0" y="483.6" textLength="97.6" clip-path="url(#terminal-3087056112-line-19)">Options:</text><text class="terminal-3087056112-r2" x="793" y="483.6" textLength="12.2" clip-path="url(#terminal-3087056112-line-19)">
-</text><text class="terminal-3087056112-r4" x="24.4" y="508" textLength="24.4" clip-path="url(#terminal-3087056112-line-20)">-h</text><text class="terminal-3087056112-r2" x="48.8" y="508" textLength="24.4" clip-path="url(#terminal-3087056112-line-20)">, </text><text class="terminal-3087056112-r4" x="73.2" y="508" textLength="73.2" clip-path="url(#terminal-3087056112-line-20)">--help</text><text class="terminal-3087056112-r2" x="231.8" y="508" textLength="378.2" clip-path="url(#termi [...]
+ <g class="terminal-1162206820-matrix">
+ <text class="terminal-1162206820-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-1162206820-line-0)">Usage:</text><text class="terminal-1162206820-r3" x="85.4" y="20" textLength="268.4" clip-path="url(#terminal-1162206820-line-0)">airflowctl connections</text><text class="terminal-1162206820-r2" x="353.8" y="20" textLength="24.4" clip-path="url(#terminal-1162206820-line-0)"> [</text><text class="terminal-1162206820-r4" x="378.2" y="20" textLength="24.4" clip-pat [...]
+</text><text class="terminal-1162206820-r2" x="915" y="44.4" textLength="12.2" clip-path="url(#terminal-1162206820-line-1)">
+</text><text class="terminal-1162206820-r2" x="0" y="68.8" textLength="366" clip-path="url(#terminal-1162206820-line-2)">Perform Connections operations</text><text class="terminal-1162206820-r2" x="915" y="68.8" textLength="12.2" clip-path="url(#terminal-1162206820-line-2)">
+</text><text class="terminal-1162206820-r2" x="915" y="93.2" textLength="12.2" clip-path="url(#terminal-1162206820-line-3)">
+</text><text class="terminal-1162206820-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-1162206820-line-4)">Positional Arguments:</text><text class="terminal-1162206820-r2" x="915" y="117.6" textLength="12.2" clip-path="url(#terminal-1162206820-line-4)">
+</text><text class="terminal-1162206820-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-1162206820-line-5)">COMMAND</text><text class="terminal-1162206820-r2" x="915" y="142" textLength="12.2" clip-path="url(#terminal-1162206820-line-5)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-1162206820-line-6)">create</text><text class="terminal-1162206820-r2" x="231.8" y="166.4" textLength="292.8" clip-path="url(#terminal-1162206820-line-6)">Perform create operation</text><text class="terminal-1162206820-r2" x="915" y="166.4" textLength="12.2" clip-path="url(#terminal-1162206820-line-6)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="190.8" textLength="183" clip-path="url(#terminal-1162206820-line-7)">create-defaults</text><text class="terminal-1162206820-r2" x="915" y="190.8" textLength="12.2" clip-path="url(#terminal-1162206820-line-7)">
+</text><text class="terminal-1162206820-r2" x="231.8" y="215.2" textLength="402.6" clip-path="url(#terminal-1162206820-line-8)">Perform create_defaults operation</text><text class="terminal-1162206820-r2" x="915" y="215.2" textLength="12.2" clip-path="url(#terminal-1162206820-line-8)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="239.6" textLength="73.2" clip-path="url(#terminal-1162206820-line-9)">delete</text><text class="terminal-1162206820-r2" x="231.8" y="239.6" textLength="292.8" clip-path="url(#terminal-1162206820-line-9)">Perform delete operation</text><text class="terminal-1162206820-r2" x="915" y="239.6" textLength="12.2" clip-path="url(#terminal-1162206820-line-9)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="264" textLength="36.6" clip-path="url(#terminal-1162206820-line-10)">get</text><text class="terminal-1162206820-r2" x="231.8" y="264" textLength="256.2" clip-path="url(#terminal-1162206820-line-10)">Perform get operation</text><text class="terminal-1162206820-r2" x="915" y="264" textLength="12.2" clip-path="url(#terminal-1162206820-line-10)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="288.4" textLength="73.2" clip-path="url(#terminal-1162206820-line-11)">import</text><text class="terminal-1162206820-r2" x="231.8" y="288.4" textLength="671" clip-path="url(#terminal-1162206820-line-11)">Import connections from a file exported with local CLI.</text><text class="terminal-1162206820-r2" x="915" y="288.4" textLength="12.2" clip-path="url(#terminal-1162206820-line-11)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="312.8" textLength="48.8" clip-path="url(#terminal-1162206820-line-12)">list</text><text class="terminal-1162206820-r2" x="231.8" y="312.8" textLength="268.4" clip-path="url(#terminal-1162206820-line-12)">Perform list operation</text><text class="terminal-1162206820-r2" x="915" y="312.8" textLength="12.2" clip-path="url(#terminal-1162206820-line-12)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="337.2" textLength="48.8" clip-path="url(#terminal-1162206820-line-13)">test</text><text class="terminal-1162206820-r2" x="231.8" y="337.2" textLength="268.4" clip-path="url(#terminal-1162206820-line-13)">Perform test operation</text><text class="terminal-1162206820-r2" x="915" y="337.2" textLength="12.2" clip-path="url(#terminal-1162206820-line-13)">
+</text><text class="terminal-1162206820-r4" x="48.8" y="361.6" textLength="73.2" clip-path="url(#terminal-1162206820-line-14)">update</text><text class="terminal-1162206820-r2" x="231.8" y="361.6" textLength="292.8" clip-path="url(#terminal-1162206820-line-14)">Perform update operation</text><text class="terminal-1162206820-r2" x="915" y="361.6" textLength="12.2" clip-path="url(#terminal-1162206820-line-14)">
+</text><text class="terminal-1162206820-r2" x="915" y="386" textLength="12.2" clip-path="url(#terminal-1162206820-line-15)">
+</text><text class="terminal-1162206820-r1" x="0" y="410.4" textLength="97.6" clip-path="url(#terminal-1162206820-line-16)">Options:</text><text class="terminal-1162206820-r2" x="915" y="410.4" textLength="12.2" clip-path="url(#terminal-1162206820-line-16)">
+</text><text class="terminal-1162206820-r4" x="24.4" y="434.8" textLength="24.4" clip-path="url(#terminal-1162206820-line-17)">-h</text><text class="terminal-1162206820-r2" x="48.8" y="434.8" textLength="24.4" clip-path="url(#terminal-1162206820-line-17)">, </text><text class="terminal-1162206820-r4" x="73.2" y="434.8" textLength="73.2" clip-path="url(#terminal-1162206820-line-17)">--help</text><text class="terminal-1162206820-r2" x="231.8" y="434.8" textLength="378.2" clip-path="ur [...]
</text>
</g>
</g>
diff --git a/airflow-ctl/docs/images/output_variables.svg b/airflow-ctl/docs/images/output_variables.svg
index 31599fa47c9..78c24d31074 100644
--- a/airflow-ctl/docs/images/output_variables.svg
+++ b/airflow-ctl/docs/images/output_variables.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 811 440.4" xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 933 416.0" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -19,102 +19,98 @@
font-weight: 700;
}
- .terminal-4094392876-matrix {
+ .terminal-938427142-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-4094392876-title {
+ .terminal-938427142-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-4094392876-r1 { fill: #ff8700 }
-.terminal-4094392876-r2 { fill: #c5c8c6 }
-.terminal-4094392876-r3 { fill: #808080 }
-.terminal-4094392876-r4 { fill: #68a0b3 }
+ .terminal-938427142-r1 { fill: #ff8700 }
+.terminal-938427142-r2 { fill: #c5c8c6 }
+.terminal-938427142-r3 { fill: #808080 }
+.terminal-938427142-r4 { fill: #68a0b3 }
</style>
<defs>
- <clipPath id="terminal-4094392876-clip-terminal">
- <rect x="0" y="0" width="792.0" height="389.4" />
+ <clipPath id="terminal-938427142-clip-terminal">
+ <rect x="0" y="0" width="914.0" height="365.0" />
</clipPath>
- <clipPath id="terminal-4094392876-line-0">
- <rect x="0" y="1.5" width="793" height="24.65"/>
+ <clipPath id="terminal-938427142-line-0">
+ <rect x="0" y="1.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-1">
- <rect x="0" y="25.9" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-1">
+ <rect x="0" y="25.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-2">
- <rect x="0" y="50.3" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-2">
+ <rect x="0" y="50.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-3">
- <rect x="0" y="74.7" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-3">
+ <rect x="0" y="74.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-4">
- <rect x="0" y="99.1" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-4">
+ <rect x="0" y="99.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-5">
- <rect x="0" y="123.5" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-5">
+ <rect x="0" y="123.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-6">
- <rect x="0" y="147.9" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-6">
+ <rect x="0" y="147.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-7">
- <rect x="0" y="172.3" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-7">
+ <rect x="0" y="172.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-8">
- <rect x="0" y="196.7" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-8">
+ <rect x="0" y="196.7" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-9">
- <rect x="0" y="221.1" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-9">
+ <rect x="0" y="221.1" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-10">
- <rect x="0" y="245.5" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-10">
+ <rect x="0" y="245.5" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-11">
- <rect x="0" y="269.9" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-11">
+ <rect x="0" y="269.9" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-12">
- <rect x="0" y="294.3" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-12">
+ <rect x="0" y="294.3" width="915" height="24.65"/>
</clipPath>
-<clipPath id="terminal-4094392876-line-13">
- <rect x="0" y="318.7" width="793" height="24.65"/>
- </clipPath>
-<clipPath id="terminal-4094392876-line-14">
- <rect x="0" y="343.1" width="793" height="24.65"/>
+<clipPath id="terminal-938427142-line-13">
+ <rect x="0" y="318.7" width="915" height="24.65"/>
</clipPath>
</defs>
- <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="809" height="438.4" rx="8"/>
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="931" height="414" rx="8"/>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
- <g transform="translate(9, 41)" clip-path="url(#terminal-4094392876-clip-terminal)">
+ <g transform="translate(9, 41)" clip-path="url(#terminal-938427142-clip-terminal)">
- <g class="terminal-4094392876-matrix">
- <text class="terminal-4094392876-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-4094392876-line-0)">Usage:</text><text class="terminal-4094392876-r3" x="85.4" y="20" textLength="244" clip-path="url(#terminal-4094392876-line-0)">airflowctl variables</text><text class="terminal-4094392876-r2" x="329.4" y="20" textLength="24.4" clip-path="url(#terminal-4094392876-line-0)"> [</text><text class="terminal-4094392876-r4" x="353.8" y="20" textLength="24.4" clip-path="u [...]
-</text><text class="terminal-4094392876-r2" x="793" y="44.4" textLength="12.2" clip-path="url(#terminal-4094392876-line-1)">
-</text><text class="terminal-4094392876-r2" x="0" y="68.8" textLength="341.6" clip-path="url(#terminal-4094392876-line-2)">Perform Variables operations</text><text class="terminal-4094392876-r2" x="793" y="68.8" textLength="12.2" clip-path="url(#terminal-4094392876-line-2)">
-</text><text class="terminal-4094392876-r2" x="793" y="93.2" textLength="12.2" clip-path="url(#terminal-4094392876-line-3)">
-</text><text class="terminal-4094392876-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-4094392876-line-4)">Positional Arguments:</text><text class="terminal-4094392876-r2" x="793" y="117.6" textLength="12.2" clip-path="url(#terminal-4094392876-line-4)">
-</text><text class="terminal-4094392876-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-4094392876-line-5)">COMMAND</text><text class="terminal-4094392876-r2" x="793" y="142" textLength="12.2" clip-path="url(#terminal-4094392876-line-5)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-4094392876-line-6)">create</text><text class="terminal-4094392876-r2" x="170.8" y="166.4" textLength="292.8" clip-path="url(#terminal-4094392876-line-6)">Perform create operation</text><text class="terminal-4094392876-r2" x="793" y="166.4" textLength="12.2" clip-path="url(#terminal-4094392876-line-6)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="190.8" textLength="73.2" clip-path="url(#terminal-4094392876-line-7)">delete</text><text class="terminal-4094392876-r2" x="170.8" y="190.8" textLength="292.8" clip-path="url(#terminal-4094392876-line-7)">Perform delete operation</text><text class="terminal-4094392876-r2" x="793" y="190.8" textLength="12.2" clip-path="url(#terminal-4094392876-line-7)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="215.2" textLength="73.2" clip-path="url(#terminal-4094392876-line-8)">export</text><text class="terminal-4094392876-r2" x="170.8" y="215.2" textLength="244" clip-path="url(#terminal-4094392876-line-8)">Export all variables</text><text class="terminal-4094392876-r2" x="793" y="215.2" textLength="12.2" clip-path="url(#terminal-4094392876-line-8)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="239.6" textLength="36.6" clip-path="url(#terminal-4094392876-line-9)">get</text><text class="terminal-4094392876-r2" x="170.8" y="239.6" textLength="256.2" clip-path="url(#terminal-4094392876-line-9)">Perform get operation</text><text class="terminal-4094392876-r2" x="793" y="239.6" textLength="12.2" clip-path="url(#terminal-4094392876-line-9)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="264" textLength="73.2" clip-path="url(#terminal-4094392876-line-10)">import</text><text class="terminal-4094392876-r2" x="170.8" y="264" textLength="195.2" clip-path="url(#terminal-4094392876-line-10)">Import variables</text><text class="terminal-4094392876-r2" x="793" y="264" textLength="12.2" clip-path="url(#terminal-4094392876-line-10)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="288.4" textLength="48.8" clip-path="url(#terminal-4094392876-line-11)">list</text><text class="terminal-4094392876-r2" x="170.8" y="288.4" textLength="268.4" clip-path="url(#terminal-4094392876-line-11)">Perform list operation</text><text class="terminal-4094392876-r2" x="793" y="288.4" textLength="12.2" clip-path="url(#terminal-4094392876-line-11)">
-</text><text class="terminal-4094392876-r4" x="48.8" y="312.8" textLength="73.2" clip-path="url(#terminal-4094392876-line-12)">update</text><text class="terminal-4094392876-r2" x="170.8" y="312.8" textLength="292.8" clip-path="url(#terminal-4094392876-line-12)">Perform update operation</text><text class="terminal-4094392876-r2" x="793" y="312.8" textLength="12.2" clip-path="url(#terminal-4094392876-line-12)">
-</text><text class="terminal-4094392876-r2" x="793" y="337.2" textLength="12.2" clip-path="url(#terminal-4094392876-line-13)">
-</text><text class="terminal-4094392876-r1" x="0" y="361.6" textLength="97.6" clip-path="url(#terminal-4094392876-line-14)">Options:</text><text class="terminal-4094392876-r2" x="793" y="361.6" textLength="12.2" clip-path="url(#terminal-4094392876-line-14)">
-</text><text class="terminal-4094392876-r4" x="24.4" y="386" textLength="24.4" clip-path="url(#terminal-4094392876-line-15)">-h</text><text class="terminal-4094392876-r2" x="48.8" y="386" textLength="24.4" clip-path="url(#terminal-4094392876-line-15)">, </text><text class="terminal-4094392876-r4" x="73.2" y="386" textLength="73.2" clip-path="url(#terminal-4094392876-line-15)">--help</text><text class="terminal-4094392876-r2" x="170.8" y="386" textLength="378.2" clip-path="url(#termi [...]
+ <g class="terminal-938427142-matrix">
+ <text class="terminal-938427142-r1" x="0" y="20" textLength="73.2" clip-path="url(#terminal-938427142-line-0)">Usage:</text><text class="terminal-938427142-r3" x="85.4" y="20" textLength="244" clip-path="url(#terminal-938427142-line-0)">airflowctl variables</text><text class="terminal-938427142-r2" x="329.4" y="20" textLength="24.4" clip-path="url(#terminal-938427142-line-0)"> [</text><text class="terminal-938427142-r4" x="353.8" y="20" textLength="24.4" clip-path="url(#ter [...]
+</text><text class="terminal-938427142-r2" x="915" y="44.4" textLength="12.2" clip-path="url(#terminal-938427142-line-1)">
+</text><text class="terminal-938427142-r2" x="0" y="68.8" textLength="341.6" clip-path="url(#terminal-938427142-line-2)">Perform Variables operations</text><text class="terminal-938427142-r2" x="915" y="68.8" textLength="12.2" clip-path="url(#terminal-938427142-line-2)">
+</text><text class="terminal-938427142-r2" x="915" y="93.2" textLength="12.2" clip-path="url(#terminal-938427142-line-3)">
+</text><text class="terminal-938427142-r1" x="0" y="117.6" textLength="256.2" clip-path="url(#terminal-938427142-line-4)">Positional Arguments:</text><text class="terminal-938427142-r2" x="915" y="117.6" textLength="12.2" clip-path="url(#terminal-938427142-line-4)">
+</text><text class="terminal-938427142-r4" x="24.4" y="142" textLength="85.4" clip-path="url(#terminal-938427142-line-5)">COMMAND</text><text class="terminal-938427142-r2" x="915" y="142" textLength="12.2" clip-path="url(#terminal-938427142-line-5)">
+</text><text class="terminal-938427142-r4" x="48.8" y="166.4" textLength="73.2" clip-path="url(#terminal-938427142-line-6)">create</text><text class="terminal-938427142-r2" x="170.8" y="166.4" textLength="292.8" clip-path="url(#terminal-938427142-line-6)">Perform create operation</text><text class="terminal-938427142-r2" x="915" y="166.4" textLength="12.2" clip-path="url(#terminal-938427142-line-6)">
+</text><text class="terminal-938427142-r4" x="48.8" y="190.8" textLength="73.2" clip-path="url(#terminal-938427142-line-7)">delete</text><text class="terminal-938427142-r2" x="170.8" y="190.8" textLength="292.8" clip-path="url(#terminal-938427142-line-7)">Perform delete operation</text><text class="terminal-938427142-r2" x="915" y="190.8" textLength="12.2" clip-path="url(#terminal-938427142-line-7)">
+</text><text class="terminal-938427142-r4" x="48.8" y="215.2" textLength="36.6" clip-path="url(#terminal-938427142-line-8)">get</text><text class="terminal-938427142-r2" x="170.8" y="215.2" textLength="256.2" clip-path="url(#terminal-938427142-line-8)">Perform get operation</text><text class="terminal-938427142-r2" x="915" y="215.2" textLength="12.2" clip-path="url(#terminal-938427142-line-8)">
+</text><text class="terminal-938427142-r4" x="48.8" y="239.6" textLength="73.2" clip-path="url(#terminal-938427142-line-9)">import</text><text class="terminal-938427142-r2" x="170.8" y="239.6" textLength="646.6" clip-path="url(#terminal-938427142-line-9)">Import variables from a file exported with local CLI.</text><text class="terminal-938427142-r2" x="915" y="239.6" textLength="12.2" clip-path="url(#terminal-938427142-line-9)">
+</text><text class="terminal-938427142-r4" x="48.8" y="264" textLength="48.8" clip-path="url(#terminal-938427142-line-10)">list</text><text class="terminal-938427142-r2" x="170.8" y="264" textLength="268.4" clip-path="url(#terminal-938427142-line-10)">Perform list operation</text><text class="terminal-938427142-r2" x="915" y="264" textLength="12.2" clip-path="url(#terminal-938427142-line-10)">
+</text><text class="terminal-938427142-r4" x="48.8" y="288.4" textLength="73.2" clip-path="url(#terminal-938427142-line-11)">update</text><text class="terminal-938427142-r2" x="170.8" y="288.4" textLength="292.8" clip-path="url(#terminal-938427142-line-11)">Perform update operation</text><text class="terminal-938427142-r2" x="915" y="288.4" textLength="12.2" clip-path="url(#terminal-938427142-line-11)">
+</text><text class="terminal-938427142-r2" x="915" y="312.8" textLength="12.2" clip-path="url(#terminal-938427142-line-12)">
+</text><text class="terminal-938427142-r1" x="0" y="337.2" textLength="97.6" clip-path="url(#terminal-938427142-line-13)">Options:</text><text class="terminal-938427142-r2" x="915" y="337.2" textLength="12.2" clip-path="url(#terminal-938427142-line-13)">
+</text><text class="terminal-938427142-r4" x="24.4" y="361.6" textLength="24.4" clip-path="url(#terminal-938427142-line-14)">-h</text><text class="terminal-938427142-r2" x="48.8" y="361.6" textLength="24.4" clip-path="url(#terminal-938427142-line-14)">, </text><text class="terminal-938427142-r4" x="73.2" y="361.6" textLength="73.2" clip-path="url(#terminal-938427142-line-14)">--help</text><text class="terminal-938427142-r2" x="170.8" y="361.6" textLength="378.2" clip-path="url(#term [...]
</text>
</g>
</g>
diff --git a/airflow-ctl/docs/installation/installing-from-sources.rst b/airflow-ctl/docs/installation/installing-from-sources.rst
index 2d2f9a421f5..8f13d381db5 100644
--- a/airflow-ctl/docs/installation/installing-from-sources.rst
+++ b/airflow-ctl/docs/installation/installing-from-sources.rst
@@ -28,12 +28,17 @@ Released packages
You can choose different version of Airflow by selecting a different version from the drop-down at
the top-left of the page.
-The ``source``, ``sdist`` and ``whl`` packages released are the "official" sources of installation that you
-can use if you want to verify the origin of the packages and want to verify checksums and signatures of
-the packages. The packages are available via the
+The Source packages are official packages of the Apache Software Foundation - and the ones that you can
+use is you want to build the packages yourself from the source code and be sure that the provenance of
+the packages is verified and matches the source code from the repository and you can verify the
+checksums and signatures of the packages.
+
+The ``sdist`` and ``whl`` packages released are the convenience packages - of installation also installed from
+the same sources and you can still verify the origin of the packages and want to verify checksums and
+signatures of the packages. The packages are available via the Official Apache Software Foundations Downloads
`Official Apache Software Foundations Downloads <https://dlcdn.apache.org/>`_
-The {{ airflowctl_version }} downloads of Airflow Ctl are available at:
+The ``|version|`` downloads of Airflow Ctl are available at:
.. jinja:: official_download_page
diff --git a/airflow-ctl/docs/installation/prerequisites.rst b/airflow-ctl/docs/installation/prerequisites.rst
index 9ca449639a7..a155a8debd8 100644
--- a/airflow-ctl/docs/installation/prerequisites.rst
+++ b/airflow-ctl/docs/installation/prerequisites.rst
@@ -30,6 +30,7 @@ Keyring Backend
airflowctl uses keyring to store the API token securely. This ensures that the token is not stored in plain text and is only accessible to authorized users.
Recommended keyring backends are:
+
* `macOS Keychain <https://en.wikipedia.org/wiki/Keychain_%28software%29>`_
* `Freedesktop Secret Service <http://standards.freedesktop.org/secret-service/>`_ supports many DE including GNOME (requires `secretstorage <https://pypi.python.org/pypi/secretstorage>`_)
* `KDE4 & KDE5 KWallet <https://en.wikipedia.org/wiki/KWallet>`_ (requires `dbus <https://pypi.python.org/pypi/dbus-python>`_)
@@ -61,3 +62,27 @@ use cases. Simply install them to make them available:
- Password Store (pass) backend for python's keyring
- `keyring_jeepney <https://pypi.org/project/keyring_jeepney>`__
- a pure Python backend using the secret service ``DBus`` API for desktop Linux (requires ``keyring<24``).
+
+
+Python Version Compatibility
+----------------------------
+``airflowctl`` is compatible with versions of Python 3.10 through Python 3.14.
+Currently, Python 3.14 is not supported. Thanks for your understanding!
+We will work on adding support for Python 3.14.
+
+.. list-table::
+ :widths: 15 85
+ :header-rows: 1
+
+ * - Python Version
+ - Supported
+ * - 3.10
+ - Yes
+ * - 3.11
+ - Yes
+ * - 3.12
+ - Yes
+ * - 3.13
+ - Yes
+ * - 3.14
+ - No
diff --git a/airflow-ctl/newsfragments/59850.significant.rst b/airflow-ctl/newsfragments/59850.significant.rst
new file mode 100644
index 00000000000..dfb10ae852c
--- /dev/null
+++ b/airflow-ctl/newsfragments/59850.significant.rst
@@ -0,0 +1,30 @@
+Remove ``airflowctl variables export`` command
+
+The ``airflowctl variables export`` command has been removed from the remote CLI. Variable export functionality is only available through the local Airflow CLI.
+
+**What changed:**
+
+- Removed ``airflowctl variables export`` command
+
+**Migration:**
+
+Use the local Airflow CLI command for exporting variables:
+
+.. code-block:: bash
+
+ # Old (no longer available)
+ airflowctl variables export variables.json
+
+ # New (use local CLI)
+ airflow variables export variables.json
+
+* Types of change
+
+ * [ ] Dag changes
+ * [ ] Config changes
+ * [ ] API changes
+ * [x] CLI changes
+ * [ ] Behaviour changes
+ * [ ] Plugin changes
+ * [ ] Dependency changes
+ * [ ] Code interface changes
diff --git a/airflow-ctl/pyproject.toml b/airflow-ctl/pyproject.toml
index 8c2fe12a7c5..b917c804b20 100644
--- a/airflow-ctl/pyproject.toml
+++ b/airflow-ctl/pyproject.toml
@@ -26,7 +26,7 @@ license-files = ["LICENSE", "NOTICE"]
# out-of-the box. Airflow-ctl is a small tool that does not have many dependencies and does not use
# sophisticated features of Python, so it should work with Python 3.14+ once all it's dependencies are
# updated to support it.
-requires-python = ">=3.10"
+requires-python = ">=3.10,!=3.14"
dependencies = [
# TODO there could be still missing deps such as airflow-core
"argcomplete>=1.10",
diff --git a/airflow-ctl/src/airflowctl/__init__.py b/airflow-ctl/src/airflowctl/__init__.py
index 32a64efabc5..f3fd163907d 100644
--- a/airflow-ctl/src/airflowctl/__init__.py
+++ b/airflow-ctl/src/airflowctl/__init__.py
@@ -19,4 +19,4 @@ from __future__ import annotations
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
-__version__ = "0.1.0"
+__version__ = "0.1.1"
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index f546515584a..6472a722397 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -49,6 +49,16 @@ class AssetAliasResponse(BaseModel):
group: Annotated[str, Field(title="Group")]
+class AssetWatcherResponse(BaseModel):
+ """
+ Asset watcher serializer for responses.
+ """
+
+ name: Annotated[str, Field(title="Name")]
+ trigger_id: Annotated[int, Field(title="Trigger Id")]
+ created_date: Annotated[datetime, Field(title="Created Date")]
+
+
class BaseInfoResponse(BaseModel):
"""
Base info serializer for responses.
@@ -162,6 +172,7 @@ class ClearTaskInstancesBody(BaseModel):
title="Run On Latest Version",
),
] = False
+ prevent_running_task: Annotated[bool | None, Field(title="Prevent Running Task")] = False
class Value(RootModel[list]):
@@ -192,6 +203,10 @@ class ConfigSection(BaseModel):
options: Annotated[list[ConfigOption], Field(title="Options")]
+class TeamName(RootModel[str]):
+ root: Annotated[str, Field(max_length=50, title="Team Name")]
+
+
class ConnectionBody(BaseModel):
"""
Connection Serializer for requests body.
@@ -209,6 +224,7 @@ class ConnectionBody(BaseModel):
port: Annotated[int | None, Field(title="Port")] = None
password: Annotated[str | None, Field(title="Password")] = None
extra: Annotated[str | None, Field(title="Extra")] = None
+ team_name: Annotated[TeamName | None, Field(title="Team Name")] = None
class ConnectionResponse(BaseModel):
@@ -225,6 +241,7 @@ class ConnectionResponse(BaseModel):
port: Annotated[int | None, Field(title="Port")] = None
password: Annotated[str | None, Field(title="Password")] = None
extra: Annotated[str | None, Field(title="Extra")] = None
+ team_name: Annotated[str | None, Field(title="Team Name")] = None
class ConnectionTestResponse(BaseModel):
@@ -245,6 +262,7 @@ class CreateAssetEventsBody(BaseModel):
extra="forbid",
)
asset_id: Annotated[int, Field(title="Asset Id")]
+ partition_key: Annotated[str | None, Field(title="Partition Key")] = None
extra: Annotated[dict[str, Any] | None, Field(title="Extra")] = None
@@ -864,6 +882,7 @@ class TriggerDAGRunPostBody(BaseModel):
run_after: Annotated[datetime | None, Field(title="Run After")] = None
conf: Annotated[dict[str, Any] | None, Field(title="Conf")] = None
note: Annotated[str | None, Field(title="Note")] = None
+ partition_key: Annotated[str | None, Field(title="Partition Key")] = None
class TriggerResponse(BaseModel):
@@ -875,6 +894,7 @@ class TriggerResponse(BaseModel):
classpath: Annotated[str, Field(title="Classpath")]
kwargs: Annotated[str, Field(title="Kwargs")]
created_date: Annotated[datetime, Field(title="Created Date")]
+ queue: Annotated[str | None, Field(title="Queue")] = None
triggerer_id: Annotated[int | None, Field(title="Triggerer Id")] = None
@@ -913,6 +933,7 @@ class VariableBody(BaseModel):
key: Annotated[str, Field(max_length=250, title="Key")]
value: JsonValue
description: Annotated[str | None, Field(title="Description")] = None
+ team_name: Annotated[TeamName | None, Field(title="Team Name")] = None
class VariableResponse(BaseModel):
@@ -924,6 +945,7 @@ class VariableResponse(BaseModel):
value: Annotated[str, Field(title="Value")]
description: Annotated[str | None, Field(title="Description")] = None
is_encrypted: Annotated[bool, Field(title="Is Encrypted")]
+ team_name: Annotated[str | None, Field(title="Team Name")] = None
class VersionInfo(BaseModel):
@@ -1036,6 +1058,7 @@ class AssetEventResponse(BaseModel):
source_map_index: Annotated[int, Field(title="Source Map Index")]
created_dagruns: Annotated[list[DagRunAssetReference], Field(title="Created Dagruns")]
timestamp: Annotated[datetime, Field(title="Timestamp")]
+ partition_key: Annotated[str | None, Field(title="Partition Key")] = None
class AssetResponse(BaseModel):
@@ -1054,6 +1077,7 @@ class AssetResponse(BaseModel):
producing_tasks: Annotated[list[TaskOutletAssetReference], Field(title="Producing Tasks")]
consuming_tasks: Annotated[list[TaskInletAssetReference], Field(title="Consuming Tasks")]
aliases: Annotated[list[AssetAliasResponse], Field(title="Aliases")]
+ watchers: Annotated[list[AssetWatcherResponse], Field(title="Watchers")]
last_asset_event: LastAssetEventResponse | None = None
@@ -1133,6 +1157,48 @@ class BulkCreateActionVariableBody(BaseModel):
action_on_existence: BulkActionOnExistence | None = "fail"
+class BulkDeleteActionConnectionBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ action: Annotated[
+ Literal["delete"], Field(description="The action to be performed on the entities.", title="Action")
+ ]
+ entities: Annotated[
+ list[str | ConnectionBody],
+ Field(description="A list of entity id/key or entity objects to be deleted.", title="Entities"),
+ ]
+ action_on_non_existence: BulkActionNotOnExistence | None = "fail"
+
+
+class BulkDeleteActionPoolBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ action: Annotated[
+ Literal["delete"], Field(description="The action to be performed on the entities.", title="Action")
+ ]
+ entities: Annotated[
+ list[str | PoolBody],
+ Field(description="A list of entity id/key or entity objects to be deleted.", title="Entities"),
+ ]
+ action_on_non_existence: BulkActionNotOnExistence | None = "fail"
+
+
+class BulkDeleteActionVariableBody(BaseModel):
+ model_config = ConfigDict(
+ extra="forbid",
+ )
+ action: Annotated[
+ Literal["delete"], Field(description="The action to be performed on the entities.", title="Action")
+ ]
+ entities: Annotated[
+ list[str | VariableBody],
+ Field(description="A list of entity id/key or entity objects to be deleted.", title="Entities"),
+ ]
+ action_on_non_existence: BulkActionNotOnExistence | None = "fail"
+
+
class BulkTaskInstanceBody(BaseModel):
"""
Request body for bulk update, and delete task instances.
@@ -1149,6 +1215,8 @@ class BulkTaskInstanceBody(BaseModel):
include_past: Annotated[bool | None, Field(title="Include Past")] = False
task_id: Annotated[str, Field(title="Task Id")]
map_index: Annotated[int | None, Field(title="Map Index")] = None
+ dag_id: Annotated[str | None, Field(title="Dag Id")] = None
+ dag_run_id: Annotated[str | None, Field(title="Dag Run Id")] = None
class BulkUpdateActionBulkTaskInstanceBody(BaseModel):
@@ -1161,6 +1229,13 @@ class BulkUpdateActionBulkTaskInstanceBody(BaseModel):
entities: Annotated[
list[BulkTaskInstanceBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1174,6 +1249,13 @@ class BulkUpdateActionConnectionBody(BaseModel):
entities: Annotated[
list[ConnectionBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1187,6 +1269,13 @@ class BulkUpdateActionPoolBody(BaseModel):
entities: Annotated[
list[PoolBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1200,6 +1289,13 @@ class BulkUpdateActionVariableBody(BaseModel):
entities: Annotated[
list[VariableBody], Field(description="A list of entities to be updated.", title="Entities")
]
+ update_mask: Annotated[
+ list[str] | None,
+ Field(
+ description="A list of field names to update for each entity.Only these fields will be applied from the request body to the database model.Any extra fields provided will be ignored.",
+ title="Update Mask",
+ ),
+ ] = None
action_on_non_existence: BulkActionNotOnExistence | None = "fail"
@@ -1272,6 +1368,7 @@ class DAGDetailsResponse(BaseModel):
default_args: Annotated[dict[str, Any] | None, Field(title="Default Args")] = None
owner_links: Annotated[dict[str, str] | None, Field(title="Owner Links")] = None
is_favorite: Annotated[bool | None, Field(title="Is Favorite")] = False
+ active_runs_count: Annotated[int | None, Field(title="Active Runs Count")] = 0
file_token: Annotated[str, Field(description="Return file token.", title="File Token")]
concurrency: Annotated[
int,
@@ -1359,6 +1456,7 @@ class DAGRunResponse(BaseModel):
dag_versions: Annotated[list[DagVersionResponse], Field(title="Dag Versions")]
bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
dag_display_name: Annotated[str, Field(title="Dag Display Name")]
+ partition_key: Annotated[str | None, Field(title="Partition Key")] = None
class DAGRunsBatchBody(BaseModel):
@@ -1390,6 +1488,11 @@ class DAGRunsBatchBody(BaseModel):
end_date_gt: Annotated[datetime | None, Field(title="End Date Gt")] = None
end_date_lte: Annotated[datetime | None, Field(title="End Date Lte")] = None
end_date_lt: Annotated[datetime | None, Field(title="End Date Lt")] = None
+ duration_gte: Annotated[float | None, Field(title="Duration Gte")] = None
+ duration_gt: Annotated[float | None, Field(title="Duration Gt")] = None
+ duration_lte: Annotated[float | None, Field(title="Duration Lte")] = None
+ duration_lt: Annotated[float | None, Field(title="Duration Lt")] = None
+ conf_contains: Annotated[str | None, Field(title="Conf Contains")] = None
class DAGVersionCollectionResponse(BaseModel):
@@ -1441,6 +1544,26 @@ class EventLogCollectionResponse(BaseModel):
total_entries: Annotated[int, Field(title="Total Entries")]
+class HITLDetailHistory(BaseModel):
+ """
+ Schema for Human-in-the-loop detail history.
+ """
+
+ options: Annotated[list[str], Field(min_length=1, title="Options")]
+ subject: Annotated[str, Field(title="Subject")]
+ body: Annotated[str | None, Field(title="Body")] = None
+ defaults: Annotated[list[str] | None, Field(title="Defaults")] = None
+ multiple: Annotated[bool | None, Field(title="Multiple")] = False
+ params: Annotated[dict[str, Any] | None, Field(title="Params")] = None
+ assigned_users: Annotated[list[HITLUser] | None, Field(title="Assigned Users")] = None
+ created_at: Annotated[datetime, Field(title="Created At")]
+ responded_by_user: HITLUser | None = None
+ responded_at: Annotated[datetime | None, Field(title="Responded At")] = None
+ chosen_options: Annotated[list[str] | None, Field(title="Chosen Options")] = None
+ params_input: Annotated[dict[str, Any] | None, Field(title="Params Input")] = None
+ response_received: Annotated[bool | None, Field(title="Response Received")] = False
+
+
class HITLDetailResponse(BaseModel):
"""
Response of updating a Human-in-the-loop detail.
@@ -1605,6 +1728,7 @@ class TaskInstanceHistoryResponse(BaseModel):
executor: Annotated[str | None, Field(title="Executor")] = None
executor_config: Annotated[str, Field(title="Executor Config")]
dag_version: DagVersionResponse | None = None
+ hitl_detail: HITLDetailHistory | None = None
class TaskInstanceResponse(BaseModel):
@@ -1667,7 +1791,7 @@ class TaskResponse(BaseModel):
pool_slots: Annotated[float | None, Field(title="Pool Slots")] = None
execution_timeout: TimeDelta | None = None
retry_delay: TimeDelta | None = None
- retry_exponential_backoff: Annotated[bool, Field(title="Retry Exponential Backoff")]
+ retry_exponential_backoff: Annotated[float, Field(title="Retry Exponential Backoff")]
priority_weight: Annotated[float | None, Field(title="Priority Weight")] = None
weight_rule: Annotated[str | None, Field(title="Weight Rule")] = None
ui_color: Annotated[str | None, Field(title="Ui Color")] = None
@@ -1729,62 +1853,52 @@ class BackfillCollectionResponse(BaseModel):
total_entries: Annotated[int, Field(title="Total Entries")]
-class BulkCreateActionBulkTaskInstanceBody(BaseModel):
+class BulkBodyConnectionBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
- action: Annotated[
- Literal["create"], Field(description="The action to be performed on the entities.", title="Action")
- ]
- entities: Annotated[
- list[BulkTaskInstanceBody], Field(description="A list of entities to be created.", title="Entities")
+ actions: Annotated[
+ list[
+ BulkCreateActionConnectionBody | BulkUpdateActionConnectionBody | BulkDeleteActionConnectionBody
+ ],
+ Field(title="Actions"),
]
- action_on_existence: BulkActionOnExistence | None = "fail"
-class BulkDeleteActionBulkTaskInstanceBody(BaseModel):
+class BulkBodyPoolBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
- action: Annotated[
- Literal["delete"], Field(description="The action to be performed on the entities.", title="Action")
- ]
- entities: Annotated[
- list[str | BulkTaskInstanceBody],
- Field(description="A list of entity id/key or entity objects to be deleted.", title="Entities"),
+ actions: Annotated[
+ list[BulkCreateActionPoolBody | BulkUpdateActionPoolBody | BulkDeleteActionPoolBody],
+ Field(title="Actions"),
]
- action_on_non_existence: BulkActionNotOnExistence | None = "fail"
-class BulkDeleteActionConnectionBody(BaseModel):
+class BulkBodyVariableBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
- action: Annotated[
- Literal["delete"], Field(description="The action to be performed on the entities.", title="Action")
- ]
- entities: Annotated[
- list[str | BulkTaskInstanceBody],
- Field(description="A list of entity id/key or entity objects to be deleted.", title="Entities"),
+ actions: Annotated[
+ list[BulkCreateActionVariableBody | BulkUpdateActionVariableBody | BulkDeleteActionVariableBody],
+ Field(title="Actions"),
]
- action_on_non_existence: BulkActionNotOnExistence | None = "fail"
-class BulkDeleteActionPoolBody(BaseModel):
+class BulkCreateActionBulkTaskInstanceBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
action: Annotated[
- Literal["delete"], Field(description="The action to be performed on the entities.", title="Action")
+ Literal["create"], Field(description="The action to be performed on the entities.", title="Action")
]
entities: Annotated[
- list[str | BulkTaskInstanceBody],
- Field(description="A list of entity id/key or entity objects to be deleted.", title="Entities"),
+ list[BulkTaskInstanceBody], Field(description="A list of entities to be created.", title="Entities")
]
- action_on_non_existence: BulkActionNotOnExistence | None = "fail"
+ action_on_existence: BulkActionOnExistence | None = "fail"
-class BulkDeleteActionVariableBody(BaseModel):
+class BulkDeleteActionBulkTaskInstanceBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
@@ -1839,7 +1953,6 @@ class HITLDetail(BaseModel):
Schema for Human-in-the-loop detail.
"""
- task_instance: TaskInstanceResponse
options: Annotated[list[str], Field(min_length=1, title="Options")]
subject: Annotated[str, Field(title="Subject")]
body: Annotated[str | None, Field(title="Body")] = None
@@ -1853,6 +1966,7 @@ class HITLDetail(BaseModel):
chosen_options: Annotated[list[str] | None, Field(title="Chosen Options")] = None
params_input: Annotated[dict[str, Any] | None, Field(title="Params Input")] = None
response_received: Annotated[bool | None, Field(title="Response Received")] = False
+ task_instance: TaskInstanceResponse
class HITLDetailCollection(BaseModel):
@@ -1912,35 +2026,3 @@ class BulkBodyBulkTaskInstanceBody(BaseModel):
],
Field(title="Actions"),
]
-
-
-class BulkBodyConnectionBody(BaseModel):
- model_config = ConfigDict(
- extra="forbid",
- )
- actions: Annotated[
- list[
- BulkCreateActionConnectionBody | BulkUpdateActionConnectionBody | BulkDeleteActionConnectionBody
- ],
- Field(title="Actions"),
- ]
-
-
-class BulkBodyPoolBody(BaseModel):
- model_config = ConfigDict(
- extra="forbid",
- )
- actions: Annotated[
- list[BulkCreateActionPoolBody | BulkUpdateActionPoolBody | BulkDeleteActionPoolBody],
- Field(title="Actions"),
- ]
-
-
-class BulkBodyVariableBody(BaseModel):
- model_config = ConfigDict(
- extra="forbid",
- )
- actions: Annotated[
- list[BulkCreateActionVariableBody | BulkUpdateActionVariableBody | BulkDeleteActionVariableBody],
- Field(title="Actions"),
- ]
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index da548530eb0..a578396acd4 100644
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -74,13 +74,11 @@ def safe_call_command(function: Callable, args: Iterable[Arg]) -> None:
try:
function(args)
- except AirflowCtlCredentialNotFoundException as e:
- rich.print(f"command failed due to {e}")
- sys.exit(1)
- except AirflowCtlConnectionException as e:
- rich.print(f"command failed due to {e}")
- sys.exit(1)
- except AirflowCtlNotFoundException as e:
+ except (
+ AirflowCtlCredentialNotFoundException,
+ AirflowCtlConnectionException,
+ AirflowCtlNotFoundException,
+ ) as e:
rich.print(f"command failed due to {e}")
sys.exit(1)
except (httpx.RemoteProtocolError, httpx.ReadError) as e:
@@ -202,8 +200,7 @@ class Password(argparse.Action):
ARG_FILE = Arg(
flags=("file",),
metavar="FILEPATH",
- help="File path to read from or write to. "
- "For import commands, it is a file to read from. For export commands, it is a file to write to.",
+ help="File path to read from for import commands.",
)
ARG_OUTPUT = Arg(
(
@@ -254,9 +251,8 @@ ARG_AUTH_PASSWORD = Arg(
# Dag Commands Args
ARG_DAG_ID = Arg(
- flags=("--dag-id",),
+ flags=("dag_id",),
type=str,
- dest="dag_id",
help="The DAG ID of the DAG to pause or unpause",
)
@@ -804,9 +800,7 @@ CONFIG_COMMANDS = (
CONNECTION_COMMANDS = (
ActionCommand(
name="import",
- help="Import connections from a file. "
- "This feature is compatible with airflow CLI `airflow connections export a.json` command. "
- "Export it from `airflow CLI` and import it securely via this command.",
+ help="Import connections from a file exported with local CLI.",
func=lazy_load_command("airflowctl.ctl.commands.connection_command.import_"),
args=(Arg(flags=("file",), metavar="FILEPATH", help="Connections JSON file"),),
),
@@ -854,16 +848,10 @@ POOL_COMMANDS = (
VARIABLE_COMMANDS = (
ActionCommand(
name="import",
- help="Import variables",
+ help="Import variables from a file exported with local CLI.",
func=lazy_load_command("airflowctl.ctl.commands.variable_command.import_"),
args=(ARG_FILE, ARG_VARIABLE_ACTION_ON_EXISTING_KEY),
),
- ActionCommand(
- name="export",
- help="Export all variables",
- func=lazy_load_command("airflowctl.ctl.commands.variable_command.export"),
- args=(ARG_FILE,),
- ),
)
core_commands: list[CLICommand] = [
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py b/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
index d5ffed31d8c..466be2ccac7 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/variable_command.py
@@ -19,7 +19,6 @@ from __future__ import annotations
import json
import os
import sys
-from pathlib import Path
import rich
@@ -79,24 +78,3 @@ def import_(args, api_client=NEW_API_CLIENT) -> list[str]:
rich.print(success_message.format(success=result.create.success))
return result.create.success
-
-
-@provide_api_client(kind=ClientKind.CLI)
-def export(args, api_client=NEW_API_CLIENT) -> None:
- """Export all the variables to the file."""
- success_message = "[green]Export successful! {total_entries} variable(s) to {file}[/green]"
- var_dict = {}
- variables = api_client.variables.list()
-
- for variable in variables.variables:
- if variable.description:
- var_dict[variable.key] = {
- "value": variable.value,
- "description": variable.description,
- }
- else:
- var_dict[variable.key] = variable.value
-
- with open(Path(args.file), "w") as var_file:
- json.dump(var_dict, var_file, sort_keys=True, indent=4)
- rich.print(success_message.format(total_entries=variables.total_entries, file=args.file))
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_client.py b/airflow-ctl/tests/airflow_ctl/api/test_client.py
index e2adcadff5e..39ffc436706 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_client.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_client.py
@@ -87,7 +87,7 @@ class TestClient:
assert err.value.args == ("Client error message: {'detail': 'Not found'}",)
@pytest.mark.parametrize(
- "base_url, client_kind, expected_base_url",
+ ("base_url", "client_kind", "expected_base_url"),
[
("http://localhost:8080", ClientKind.CLI, "http://localhost:8080/api/v2/"),
("http://localhost:8080", ClientKind.AUTH, "http://localhost:8080/auth/"),
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index 1e7391b99d2..f0a638475c4 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -128,7 +128,7 @@ class TestBaseOperations:
client.connections.get("1")
@pytest.mark.parametrize(
- "total_entries, limit, expected_response",
+ ("total_entries", "limit", "expected_response"),
[
(1, 50, (HelloCollectionResponse(hellos=[HelloResponse(name="hello")], total_entries=1))),
(
@@ -207,6 +207,7 @@ class TestAssetsOperations:
producing_tasks=[],
consuming_tasks=[],
aliases=[],
+ watchers=[],
group="group",
)
asset_alias_response = AssetAliasResponse(
@@ -1066,7 +1067,7 @@ class TestPoolsOperations:
pools=[pool_response],
total_entries=1,
)
- pool_bulk_aresponse = BulkResponse(
+ pool_bulk_response = BulkResponse(
create=BulkActionResponse(success=[pool_name], errors=[]),
update=None,
delete=None,
@@ -1102,11 +1103,11 @@ class TestPoolsOperations:
def test_bulk(self):
def handle_request(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v2/pools"
- return httpx.Response(200, json=json.loads(self.pool_bulk_aresponse.model_dump_json()))
+ return httpx.Response(200, json=json.loads(self.pool_bulk_response.model_dump_json()))
client = make_api_client(transport=httpx.MockTransport(handle_request))
response = client.pools.bulk(pools=self.pools_bulk_body)
- assert response == self.pool_bulk_aresponse
+ assert response == self.pool_bulk_response
def test_delete(self):
def handle_request(request: httpx.Request) -> httpx.Response:
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py
index 98fb318024b..fbc6ee1240b 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_dag_command.py
@@ -93,7 +93,7 @@ class TestDagCommands:
)
assert self.dag_response_paused.is_paused is False
dag_response_dict = dag_command.pause(
- self.parser.parse_args(["dags", "pause", "--dag-id", self.dag_id]),
+ self.parser.parse_args(["dags", "pause", self.dag_id]),
api_client=api_client,
)
assert dag_response_dict["is_paused"] is False
@@ -107,7 +107,7 @@ class TestDagCommands:
)
with pytest.raises(SystemExit):
dag_command.pause(
- self.parser.parse_args(["dags", "pause", "--dag-id", self.dag_id]),
+ self.parser.parse_args(["dags", "pause", self.dag_id]),
api_client=api_client,
)
@@ -120,7 +120,7 @@ class TestDagCommands:
)
assert self.dag_response_unpaused.is_paused is True
dag_response_dict = dag_command.unpause(
- self.parser.parse_args(["dags", "unpause", "--dag-id", self.dag_id]),
+ self.parser.parse_args(["dags", "unpause", self.dag_id]),
api_client=api_client,
)
assert dag_response_dict["is_paused"] is True
@@ -134,6 +134,6 @@ class TestDagCommands:
)
with pytest.raises(SystemExit):
dag_command.unpause(
- self.parser.parse_args(["dags", "unpause", "--dag-id", self.dag_id]),
+ self.parser.parse_args(["dags", "unpause", self.dag_id]),
api_client=api_client,
)
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
index e0c9ce16d8d..9703c8f866b 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/commands/test_variable_command.py
@@ -17,7 +17,6 @@
from __future__ import annotations
import json
-import os
import pytest
@@ -104,22 +103,3 @@ class TestCliVariableCommands:
self.parser.parse_args(["variables", "import", expected_json_path.as_posix()]),
api_client=api_client,
)
-
- def test_export(self, api_client_maker, tmp_path, monkeypatch):
- api_client = api_client_maker(
- path="/api/v2/variables",
- response_json=self.variable_collection_response.model_dump(),
- expected_http_status_code=200,
- kind=ClientKind.CLI,
- )
-
- monkeypatch.chdir(tmp_path)
- expected_json_path = (tmp_path / self.export_file_name).as_posix()
- variable_command.export(
- self.parser.parse_args(["variables", "export", expected_json_path]),
- api_client=api_client,
- )
- assert os.path.exists(tmp_path / self.export_file_name)
-
- with open(expected_json_path) as f:
- assert json.load(f) == {self.key: {"description": self.description, "value": self.value}}
diff --git a/scripts/ci/prek/check_airflowctl_command_coverage.py b/scripts/ci/prek/check_airflowctl_command_coverage.py
new file mode 100755
index 00000000000..8a81dd098c6
--- /dev/null
+++ b/scripts/ci/prek/check_airflowctl_command_coverage.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10,<3.11"
+# dependencies = [
+# "rich>=13.6.0",
+# ]
+# ///
+"""
+Check that all airflowctl CLI commands have integration test coverage by comparing commands from operations.py against test_commands in conftest.py.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.resolve()))
+from common_prek_utils import AIRFLOW_ROOT_PATH, console
+
+OPERATIONS_FILE = AIRFLOW_ROOT_PATH / "airflow-ctl" / "src" / "airflowctl" / "api" / "operations.py"
+CTL_TESTS_FILE = (
+ AIRFLOW_ROOT_PATH / "airflow-ctl-tests" / "tests" / "airflowctl_tests" / "test_airflowctl_commands.py"
+)
+
+# Operations excluded from CLI (see cli_config.py)
+EXCLUDED_OPERATION_CLASSES = {"BaseOperations", "LoginOperations", "VersionOperations"}
+EXCLUDED_METHODS = {
+ "__init__",
+ "__init_subclass__",
+ "error",
+ "_check_flag_and_exit_if_server_response_error",
+ "bulk",
+ "export",
+}
+
+EXCLUDED_COMMANDS = {
+ "assets delete-dag-queued-events",
+ "assets delete-queued-event",
+ "assets delete-queued-events",
+ "assets get-by-alias",
+ "assets get-dag-queued-event",
+ "assets get-dag-queued-events",
+ "assets get-queued-events",
+ "assets list-by-alias",
+ "assets materialize",
+ "backfill cancel",
+ "backfill create",
+ "backfill create-dry-run",
+ "backfill get",
+ "backfill pause",
+ "backfill unpause",
+ "connections create-defaults",
+ "connections test",
+ "dags delete",
+ "dags get-import-error",
+ "dags get-tags",
+}
+
+
+def parse_operations() -> dict[str, list[str]]:
+ commands: dict[str, list[str]] = {}
+
+ with open(OPERATIONS_FILE) as f:
+ tree = ast.parse(f.read(), filename=str(OPERATIONS_FILE))
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ClassDef) and node.name.endswith("Operations"):
+ if node.name in EXCLUDED_OPERATION_CLASSES:
+ continue
+
+ group_name = node.name.replace("Operations", "").lower()
+ commands[group_name] = []
+
+ for child in node.body:
+ if isinstance(child, ast.FunctionDef):
+ method_name = child.name
+ if method_name in EXCLUDED_METHODS or method_name.startswith("_"):
+ continue
+ subcommand = method_name.replace("_", "-")
+ commands[group_name].append(subcommand)
+
+ return commands
+
+
+def parse_tested_commands() -> set[str]:
+ tested: set[str] = set()
+
+ with open(CTL_TESTS_FILE) as f:
+ content = f.read()
+
+ # Match command patterns like "assets list", "dags list-import-errors", etc.
+ # Also handles f-strings like f"dagrun get..." or f'dagrun get...'
+ pattern = r'f?["\']([a-z]+(?:-[a-z]+)*\s+[a-z]+(?:-[a-z]+)*)'
+ for match in re.findall(pattern, content):
+ parts = match.split()
+ if len(parts) >= 2:
+ tested.add(f"{parts[0]} {parts[1]}")
+
+ return tested
+
+
+def main():
+ available = parse_operations()
+ tested = parse_tested_commands()
+
+ missing = []
+ for group, subcommands in sorted(available.items()):
+ for subcommand in sorted(subcommands):
+ cmd = f"{group} {subcommand}"
+ if cmd not in tested and cmd not in EXCLUDED_COMMANDS:
+ missing.append(cmd)
+
+ if missing:
+ console.print("[red]ERROR: Commands not covered by integration tests:[/]")
+ for cmd in missing:
+ console.print(f" [red]- {cmd}[/]")
+ console.print()
+ console.print("[yellow]Fix by either:[/]")
+ console.print(f"1. Add test to {CTL_TESTS_FILE}")
+ console.print(f"2. Add to EXCLUDED_COMMANDS in {__file__}")
+ sys.exit(1)
+
+ total = sum(len(cmds) for cmds in available.values())
+ console.print(
+ f"[green]All {total} CLI commands covered ({len(tested)} tested, {len(EXCLUDED_COMMANDS)} excluded)[/]"
+ )
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/in_container/run_capture_airflowctl_help.py b/scripts/in_container/run_capture_airflowctl_help.py
index 733cc3145b2..b35472bffca 100644
--- a/scripts/in_container/run_capture_airflowctl_help.py
+++ b/scripts/in_container/run_capture_airflowctl_help.py
@@ -84,7 +84,7 @@ def regenerate_help_images_for_all_airflowctl_commands(commands: list[str], skip
os.makedirs(AIRFLOWCTL_IMAGES_PATH, exist_ok=True)
env = os.environ.copy()
env["TERM"] = "xterm-256color"
- env["COLUMNS"] = "65"
+ env["COLUMNS"] = "75"
old_hash_dict = {}
new_hash_dict = {}
@@ -100,8 +100,8 @@ def regenerate_help_images_for_all_airflowctl_commands(commands: list[str], skip
# Check for changes
changed_commands = []
- for command in commands:
- command = command or "main"
+ for command_raw in commands:
+ command = command_raw or "main"
console.print(f"[bright_blue]Checking command: {command}[/]", end="")
if skip_hash_check: