This is an automated email from the ASF dual-hosted git repository.

Lee-W pushed a commit to branch airflow-ctl/v0-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/airflow-ctl/v0-1-test by this 
push:
     new bff302734cd [airflow-ctl/v0-1-test] Split airflowctl dags state 
argument into run_id and --logical-date (#69915) (#70352)
bff302734cd is described below

commit bff302734cdeca75f744487d2a7d0b8ad6ee121c
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jul 24 10:30:04 2026 +0800

    [airflow-ctl/v0-1-test] Split airflowctl dags state argument into run_id 
and --logical-date (#69915) (#70352)
    
    Co-authored-by: PoAn Yang <[email protected]>
    Signed-off-by: PoAn Yang <[email protected]>
---
 .../airflowctl_tests/test_airflowctl_commands.py   |   2 +-
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  15 ++-
 .../src/airflowctl/ctl/commands/dag_command.py     |  82 ++++++++------
 .../tests/airflow_ctl/api/test_operations.py       |  14 +++
 .../airflow_ctl/ctl/commands/test_dag_command.py   | 122 ++++++++++++++-------
 5 files changed, 159 insertions(+), 76 deletions(-)

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 924cbde90d5..67c241b440f 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
@@ -85,7 +85,7 @@ TEST_COMMANDS = [
     # Order of trigger, state, and pause/unpause is important for test 
stability
     "dags trigger example_bash_operator --logical-date={date_param} 
--run-after={date_param}",
     'dags state example_bash_operator "manual__{date_param}"',
-    'dags state example_bash_operator "{date_param}"',
+    'dags state example_bash_operator --logical-date "{date_param}"',
     # Test trigger without logical-date (should default to now)
     "dags trigger example_bash_operator",
     "dags next-execution example_bash_operator",
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 40ee7a1c67a..b36ba0a46e1 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -281,10 +281,16 @@ ARG_DAG_ID = Arg(
     type=str,
     help="The Dag ID of the Dag to pause or unpause",
 )
-ARG_LOGICAL_DATE_OR_RUN_ID = Arg(
-    flags=("logical_date_or_run_id",),
+ARG_RUN_ID = Arg(
+    flags=("run_id",),
     type=str,
-    help="The logical date with a timezone offset or run ID of the Dag run",
+    nargs="?",
+    help="The run ID of the Dag run (pass this or --logical-date, not both)",
+)
+ARG_LOGICAL_DATE = Arg(
+    flags=("--logical-date",),
+    type=str,
+    help="The logical date of the Dag run with a timezone offset (pass this or 
run_id, not both)",
 )
 
 ARG_ACTION_ON_EXISTING_KEY = Arg(
@@ -1002,7 +1008,8 @@ DAG_COMMANDS = (
         func=lazy_load_command("airflowctl.ctl.commands.dag_command.state"),
         args=(
             ARG_DAG_ID,
-            ARG_LOGICAL_DATE_OR_RUN_ID,
+            ARG_RUN_ID,
+            ARG_LOGICAL_DATE,
         ),
     ),
     ActionCommand(
diff --git a/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py 
b/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py
index f0562891d11..d2acee3d24a 100644
--- a/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py
+++ b/airflow-ctl/src/airflowctl/ctl/commands/dag_command.py
@@ -25,7 +25,13 @@ from typing import Literal
 import rich
 from rich.text import Text
 
-from airflowctl.api.client import NEW_API_CLIENT, ClientKind, 
ServerResponseError, provide_api_client
+from airflowctl.api.client import (
+    NEW_API_CLIENT,
+    Client,
+    ClientKind,
+    ServerResponseError,
+    provide_api_client,
+)
 from airflowctl.api.datamodels.generated import DAGPatchBody, DAGRunResponse
 from airflowctl.ctl.console_formatting import AirflowConsole
 
@@ -108,53 +114,61 @@ def next_execution(args, api_client=NEW_API_CLIENT) -> 
dict | None:
     return result
 
 
-def _parse_logical_date(value: str) -> datetime.datetime | None:
-    """Parse an ISO-formatted logical date."""
+def _get_dag_run_by_run_id(api_client: Client, dag_id: str, run_id: str) -> 
DAGRunResponse:
+    """Get a Dag run by its run ID."""
+    try:
+        return api_client.dag_runs.get(dag_id=dag_id, dag_run_id=run_id, 
suppress_error_log=True)
+    except ServerResponseError as e:
+        if e.response.status_code != 404:
+            raise
+        rich.print(f"[red]Dag run {run_id!r} of Dag {dag_id!r} not 
found[/red]")
+        sys.exit(1)
+
+
+def _get_dag_run_by_logical_date(api_client: Client, dag_id: str, value: str) 
-> DAGRunResponse:
+    """Get the Dag run with an exact logical date match."""
     try:
         logical_date = datetime.datetime.fromisoformat(value.replace("Z", 
"+00:00"))
     except ValueError:
-        return None
+        rich.print(f"[red]Invalid --logical-date: {value!r}[/red]")
+        sys.exit(1)
     if logical_date.tzinfo is None:
-        raise SystemExit("Logical date must include a timezone offset")
-    return logical_date
-
+        rich.print("[red]--logical-date must include a timezone offset[/red]")
+        sys.exit(1)
 
-def _get_dag_run_by_run_id_or_logical_date(api_client, dag_id: str, value: 
str) -> DAGRunResponse | None:
-    """Get a Dag run by run ID, falling back to an exact logical date match."""
+    dag_runs = []
     try:
-        return api_client.dag_runs.get(dag_id=dag_id, dag_run_id=value, 
suppress_error_log=True)
-    except ServerResponseError as e:
-        if e.response.status_code != 404:
-            raise
-
-    if logical_date := _parse_logical_date(value):
-        response = api_client.dag_runs.list(
+        dag_runs = api_client.dag_runs.list(
             dag_id=dag_id,
             logical_date_gte=logical_date,
             logical_date_lte=logical_date,
             order_by="-id",
             limit=1,
-        )
-        if response.dag_runs:
-            return response.dag_runs[0]
-    else:
-        api_client.dag_runs.list(dag_id=dag_id, limit=1)
-    return None
+            suppress_error_log=True,
+        ).dag_runs
+    except ServerResponseError as e:
+        if e.response.status_code != 404:
+            raise
+    if not dag_runs:
+        rich.print(f"[red]Dag run for {dag_id} with logical date {value!r} not 
found[/red]")
+        sys.exit(1)
+    return dag_runs[0]
 
 
 @provide_api_client(kind=ClientKind.CLI)
 def state(args, api_client=NEW_API_CLIENT) -> None:
     """Show the state and configuration of a Dag run."""
-    dag_run = _get_dag_run_by_run_id_or_logical_date(
-        api_client=api_client,
-        dag_id=args.dag_id,
-        value=args.logical_date_or_run_id,
-    )
-    if not dag_run:
-        rich.print("[yellow]No matching Dag run found.[/yellow]")
+    if (args.run_id is None) == (args.logical_date is None):
+        rich.print("[red]Provide either run_id or --logical-date, but not 
both[/red]")
+        sys.exit(1)
+
+    if args.run_id:
+        dag_run = _get_dag_run_by_run_id(api_client, args.dag_id, args.run_id)
+    else:
+        dag_run = _get_dag_run_by_logical_date(api_client, args.dag_id, 
args.logical_date)
+
+    state_value = getattr(dag_run.state, "value", dag_run.state)
+    if dag_run.conf:
+        rich.print(Text(f"{state_value}, {json.dumps(dag_run.conf)}"))
     else:
-        state_value = getattr(dag_run.state, "value", dag_run.state)
-        if dag_run.conf:
-            rich.print(Text(f"{state_value}, {json.dumps(dag_run.conf)}"))
-        else:
-            rich.print(Text(state_value))
+        rich.print(Text(state_value))
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index ed970408433..cf96b75f28c 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -1230,6 +1230,20 @@ class TestDagRunOperations:
         )
         assert response == self.dag_run_collection_response
 
+    @pytest.mark.parametrize("suppress_error_log", [False, True])
+    def test_list_passes_error_log_suppression_extension(self, 
suppress_error_log):
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            assert request.extensions["airflowctl_suppress_error_log"] is 
suppress_error_log
+            return httpx.Response(200, 
json=json.loads(self.dag_run_collection_response.model_dump_json()))
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        response = client.dag_runs.list(
+            dag_id=self.dag_id,
+            limit=1,
+            suppress_error_log=suppress_error_log,
+        )
+        assert response == self.dag_run_collection_response
+
     def test_list_with_logical_date_filters_and_order(self):
         logical_date = datetime.datetime(2025, 1, 1, 
tzinfo=datetime.timezone.utc)
 
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 f5966f09c0f..62c402afb6c 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
@@ -29,12 +29,16 @@ from airflowctl.ctl import cli_parser
 from airflowctl.ctl.commands import dag_command
 
 
-def _server_error(status_code: int) -> ServerResponseError:
+def _make_server_error(status_code: int) -> ServerResponseError:
     request = httpx.Request("GET", 
"http://testserver/api/v2/dags/test_dag/dagRuns/test_run";)
     response = httpx.Response(status_code, request=request, json={"detail": 
"boom"})
     return ServerResponseError(message="boom", request=request, 
response=response)
 
 
+def _normalize_rich_output(text: str) -> str:
+    return " ".join(text.split())
+
+
 class TestDagCommands:
     parser = cli_parser.get_parser()
     dag_id = "test_dag"
@@ -245,13 +249,14 @@ class TestDagCommands:
     def test_state_by_logical_date(self, capsys):
         api_client = mock.MagicMock()
         logical_date = datetime.datetime(2025, 1, 1, 
tzinfo=datetime.timezone.utc)
-        api_client.dag_runs.get.side_effect = _server_error(404)
         api_client.dag_runs.list.return_value.dag_runs = [
             mock.MagicMock(state="failed", conf={"reason": "[red]test[/red]"})
         ]
 
         dag_command.state(
-            self.parser.parse_args(["dags", "state", self.dag_id, 
logical_date.isoformat()]),
+            self.parser.parse_args(
+                ["dags", "state", self.dag_id, "--logical-date", 
logical_date.isoformat()]
+            ),
             api_client=api_client,
         )
 
@@ -262,67 +267,95 @@ class TestDagCommands:
             logical_date_lte=logical_date,
             order_by="-id",
             limit=1,
+            suppress_error_log=True,
         )
+        api_client.dag_runs.get.assert_not_called()
 
     @pytest.mark.parametrize(
-        ("value", "expected_list_kwargs"),
+        "extra_args",
         [
-            pytest.param("missing_run", {"dag_id": dag_id, "limit": 1}, 
id="run-id"),
-            pytest.param(
-                "2025-01-01T00:00:00+00:00",
-                {
-                    "dag_id": dag_id,
-                    "logical_date_gte": datetime.datetime(2025, 1, 1, 
tzinfo=datetime.timezone.utc),
-                    "logical_date_lte": datetime.datetime(2025, 1, 1, 
tzinfo=datetime.timezone.utc),
-                    "order_by": "-id",
-                    "limit": 1,
-                },
-                id="logical-date",
-            ),
+            [],
+            ["test_run", "--logical-date", "2025-01-01T00:00:00+00:00"],
         ],
+        ids=["neither", "both"],
     )
-    @mock.patch("rich.print")
-    def test_state_missing_run_prints_message(self, mock_rich_print, value, 
expected_list_kwargs):
+    def test_state_requires_exactly_one_of_run_id_and_logical_date(self, 
extra_args, capsys):
         api_client = mock.MagicMock()
-        api_client.dag_runs.get.side_effect = _server_error(404)
-        api_client.dag_runs.list.return_value.dag_runs = []
 
-        dag_command.state(
-            self.parser.parse_args(["dags", "state", self.dag_id, value]),
-            api_client=api_client,
+        with pytest.raises(SystemExit) as ctx:
+            dag_command.state(
+                self.parser.parse_args(["dags", "state", self.dag_id, 
*extra_args]),
+                api_client=api_client,
+            )
+
+        assert ctx.value.code == 1
+        api_client.dag_runs.get.assert_not_called()
+        assert _normalize_rich_output(capsys.readouterr().out) == (
+            "Provide either run_id or --logical-date, but not both"
         )
 
-        mock_rich_print.assert_called_once_with("[yellow]No matching Dag run 
found.[/yellow]")
-        
api_client.dag_runs.list.assert_called_once_with(**expected_list_kwargs)
+    @pytest.mark.parametrize(
+        ("logical_date", "expected_message"),
+        [
+            ("not-a-date", "Invalid --logical-date: 'not-a-date'"),
+            ("2025-01-01T00:00:00", "--logical-date must include a timezone 
offset"),
+        ],
+        ids=["unparsable", "naive"],
+    )
+    def test_state_rejects_bad_logical_date(self, logical_date, 
expected_message, capsys):
+        api_client = mock.MagicMock()
 
-    def test_state_missing_dag_propagates_api_error(self):
+        with pytest.raises(SystemExit) as ctx:
+            dag_command.state(
+                self.parser.parse_args(["dags", "state", self.dag_id, 
"--logical-date", logical_date]),
+                api_client=api_client,
+            )
+
+        assert ctx.value.code == 1
+        api_client.dag_runs.list.assert_not_called()
+        assert _normalize_rich_output(capsys.readouterr().out) == 
expected_message
+
+    @pytest.mark.parametrize("list_failure", ["no_matching_run", 
"dag_not_found_404"])
+    def test_state_dag_run_not_found_by_logical_date(self, list_failure, 
capsys):
         api_client = mock.MagicMock()
-        api_client.dag_runs.get.side_effect = _server_error(404)
-        api_client.dag_runs.list.side_effect = error = _server_error(404)
+        if list_failure == "no_matching_run":
+            api_client.dag_runs.list.return_value.dag_runs = []
+        else:
+            api_client.dag_runs.list.side_effect = _make_server_error(404)
 
-        with pytest.raises(ServerResponseError) as ctx:
+        with pytest.raises(SystemExit) as ctx:
             dag_command.state(
-                self.parser.parse_args(["dags", "state", self.dag_id, 
"missing_run"]),
+                self.parser.parse_args(
+                    ["dags", "state", self.dag_id, "--logical-date", 
"2025-01-01T00:00:00+00:00"]
+                ),
                 api_client=api_client,
             )
 
-        assert ctx.value is error
+        assert ctx.value.code == 1
+        api_client.dag_runs.get.assert_not_called()
+        assert _normalize_rich_output(capsys.readouterr().out) == (
+            "Dag run for test_dag with logical date 
'2025-01-01T00:00:00+00:00' not found"
+        )
 
-    def test_state_rejects_naive_logical_date(self):
+    def test_state_dag_run_not_found_by_run_id(self, capsys):
         api_client = mock.MagicMock()
-        api_client.dag_runs.get.side_effect = _server_error(404)
+        api_client.dag_runs.get.side_effect = _make_server_error(404)
 
-        with pytest.raises(SystemExit, match="Logical date must include a 
timezone offset"):
+        with pytest.raises(SystemExit) as ctx:
             dag_command.state(
-                self.parser.parse_args(["dags", "state", self.dag_id, 
"2025-01-01T00:00:00"]),
+                self.parser.parse_args(["dags", "state", self.dag_id, 
"missing_run"]),
                 api_client=api_client,
             )
 
+        assert ctx.value.code == 1
         api_client.dag_runs.list.assert_not_called()
+        assert _normalize_rich_output(capsys.readouterr().out) == (
+            "Dag run 'missing_run' of Dag 'test_dag' not found"
+        )
 
-    def test_state_propagates_non_404_api_error(self):
+    def test_state_reraises_non_404_dag_run_get_error(self):
         api_client = mock.MagicMock()
-        api_client.dag_runs.get.side_effect = error = _server_error(500)
+        api_client.dag_runs.get.side_effect = error = _make_server_error(500)
 
         with pytest.raises(ServerResponseError) as ctx:
             dag_command.state(
@@ -332,3 +365,18 @@ class TestDagCommands:
 
         assert ctx.value is error
         api_client.dag_runs.list.assert_not_called()
+
+    def test_state_reraises_non_404_dag_run_list_error(self):
+        api_client = mock.MagicMock()
+        api_client.dag_runs.list.side_effect = error = _make_server_error(500)
+
+        with pytest.raises(ServerResponseError) as ctx:
+            dag_command.state(
+                self.parser.parse_args(
+                    ["dags", "state", self.dag_id, "--logical-date", 
"2025-01-01T00:00:00+00:00"]
+                ),
+                api_client=api_client,
+            )
+
+        assert ctx.value is error
+        api_client.dag_runs.get.assert_not_called()

Reply via email to