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

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 9cde6aa11db Stop airflowctl updates from resetting omitted bool fields 
(#74383)
9cde6aa11db is described below

commit 9cde6aa11dbdd8dfaba8119a65fe768215153141
Author: Shawn Sun <[email protected]>
AuthorDate: Sun Nov 1 01:47:45 2026 -0800

    Stop airflowctl updates from resetting omitted bool fields (#74383)
    
    * Stop airflowctl updates from resetting omitted bool fields
    
    Squashed onto main for a clean rebase. Includes the integration-test and 
backfill test changes from Shahar Epstein's commit 82a7ed8e0c.
    
    Generated-by: GitHub Copilot CLI (Claude Opus 5.5)
    
    * Fail airflowctl pools update early when --include-deferred is omitted
    
    The server requires include_deferred on a pool PATCH without an update 
mask, so an omitted flag now gets the same client-side message as dags update 
instead of a raw 422.
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../airflowctl_tests/test_airflowctl_commands.py   |   2 +-
 airflow-ctl/src/airflowctl/api/operations.py       |   2 +-
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  35 +++---
 .../tests/airflow_ctl/api/test_operations.py       |   9 ++
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 122 ++++++++++++++++++++-
 5 files changed, 154 insertions(+), 16 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 79ed6d72229..48a213902d4 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
@@ -138,7 +138,7 @@ TEST_COMMANDS = [
     "pools list",
     "pools get test_pool",
     "pools get test_pool -o yaml",
-    "pools update --pool=test_pool --slots=10",
+    "pools update --pool=test_pool --slots=10 --no-include-deferred",
     "pools import tests/airflowctl_tests/fixtures/test_pools.json",
     "pools export tests/airflowctl_tests/fixtures/pools_export.json 
--output=json",
     "pools delete test_pool",
diff --git a/airflow-ctl/src/airflowctl/api/operations.py 
b/airflow-ctl/src/airflowctl/api/operations.py
index 497bec2f11a..556d857f371 100644
--- a/airflow-ctl/src/airflowctl/api/operations.py
+++ b/airflow-ctl/src/airflowctl/api/operations.py
@@ -616,7 +616,7 @@ class DagsOperations(BaseOperations):
 
     def update(self, dag_id: str, dag_body: DAGPatchBody) -> DAGResponse | 
ServerResponseError:
         self.response = self.client.patch(
-            f"dags/{_quote_path_param(dag_id)}", 
json=dag_body.model_dump(mode="json")
+            f"dags/{_quote_path_param(dag_id)}", 
json=dag_body.model_dump(mode="json", exclude_none=True)
         )
         return validate_response(self.response.content, DAGResponse)
 
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 9ebabc729e0..455454fc271 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -603,10 +603,6 @@ class CommandFactory:
             "set",
             "clear",
         ]
-        # Datamodels whose generated bool flags follow the datamodel field 
defaults instead of
-        # defaulting to False, so the CLI keeps the API semantics (e.g. a bare 
``tasks clear``
-        # must keep ``dry_run=True`` and preview instead of clearing).
-        self.field_bool_default_datamodels = ["ClearTaskInstancesBody"]
         self.exclude_operation_names = ["LoginOperations", 
"VersionOperations", "BaseOperations"]
         self.exclude_method_names = [
             "error",
@@ -804,11 +800,16 @@ class CommandFactory:
                 return suffix
         return None
 
-    def _get_bool_arg_default(self, parameter_type: str, field_default: Any) 
-> bool | None:
-        """Get default for a generated bool flag: the datamodel field default 
for datamodels in ``field_bool_default_datamodels``, otherwise False."""
-        if parameter_type in self.field_bool_default_datamodels and (
-            field_default is None or isinstance(field_default, bool)
-        ):
+    @staticmethod
+    def _get_bool_arg_default(field_default: Any) -> bool | None:
+        """
+        Get the default for a generated bool flag from its datamodel field 
default.
+
+        An omitted flag must keep the API semantics: a bare ``tasks clear`` 
keeps ``dry_run=True``,
+        and an optional field such as ``is_paused`` or ``include_deferred`` 
stays ``None`` so it is
+        not sent at all instead of overwriting the stored value with ``False``.
+        """
+        if field_default is None or isinstance(field_default, bool):
             return field_default
         return False
 
@@ -841,9 +842,7 @@ class CommandFactory:
                     arg_action=argparse.BooleanOptionalAction if annotation is 
bool else None,  # type: ignore
                     arg_help=f"{field} for {parameter_key} operation",
                     arg_default=(
-                        self._get_bool_arg_default(parameter_type, 
field_type.default)
-                        if annotation is bool
-                        else None
+                        self._get_bool_arg_default(field_type.default) if 
annotation is bool else None
                     ),
                     arg_required=(
                         field_type.is_required()  # no default?
@@ -911,7 +910,7 @@ class CommandFactory:
 
     def _apply_datamodel_defaults(self, datamodel: type, params: dict) -> dict:
         """
-        Apply datamodel-specific default values.
+        Apply datamodel-specific default values and checks.
 
         Centralizes special handling for different datamodels to keep
         CLI parameter processing logic clean and maintainable.
@@ -950,6 +949,16 @@ class CommandFactory:
                 else:
                     params[field] = [item.strip() for item in 
raw_value.split(",") if item.strip()]
 
+        # The server requires exactly one of these, so fail early instead of 
showing its raw validation error
+        if datamodel.__name__ == "DAGPatchBody":
+            provided = [field for field in ("is_paused", "scheduling_state") 
if params.get(field) is not None]
+            if len(provided) != 1:
+                raise SystemExit("Provide exactly one of --is-paused / 
--no-is-paused or --scheduling-state.")
+
+        # The server requires include_deferred on a pool PATCH without an 
update mask
+        if datamodel.__name__ == "PoolPatchBody" and 
params.get("include_deferred") is None:
+            raise SystemExit("Provide --include-deferred or 
--no-include-deferred.")
+
         return params
 
     def _validate_datamodel(self, datamodel: type[BaseModel], params: dict) -> 
Any:
diff --git a/airflow-ctl/tests/airflow_ctl/api/test_operations.py 
b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
index debac6ccacb..b7efd5b02a6 100644
--- a/airflow-ctl/tests/airflow_ctl/api/test_operations.py
+++ b/airflow-ctl/tests/airflow_ctl/api/test_operations.py
@@ -1496,6 +1496,15 @@ class TestDagOperations:
         response = client.dags.update(dag_id="dag_id", 
dag_body=self.dag_patch_body)
         assert response == self.dag_response
 
+    def test_update_omits_none_fields_from_request_body(self):
+        def handle_request(request: httpx.Request) -> httpx.Response:
+            assert json.loads(request.content.decode()) == {"is_paused": True}
+            return httpx.Response(200, 
json=json.loads(self.dag_response.model_dump_json()))
+
+        client = make_api_client(transport=httpx.MockTransport(handle_request))
+        response = client.dags.update(dag_id="dag_id", 
dag_body=self.dag_patch_body)
+        assert response == self.dag_response
+
     def test_delete(self):
         def handle_request(request: httpx.Request) -> httpx.Response:
             assert request.url.path == "/api/v2/dags/dag_id"
diff --git a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py 
b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
index 93ead45c2f7..3d09bef569e 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -32,14 +32,25 @@ import pytest
 
 from airflowctl.api.client import Client
 from airflowctl.api.datamodels.generated import (
+    BackfillPostBody,
     ClearTaskInstancesBody,
     ConnectionBody,
     ConnectionTestResponse,
+    DAGPatchBody,
+    DagSchedulingState,
     PoolBody,
     PoolCollectionResponse,
+    PoolPatchBody,
     PoolResponse,
 )
-from airflowctl.api.operations import ConnectionsOperations, DagRunOperations, 
ServerResponseError
+from airflowctl.api.operations import (
+    BackfillOperations,
+    ConnectionsOperations,
+    DagRunOperations,
+    DagsOperations,
+    PoolsOperations,
+    ServerResponseError,
+)
 from airflowctl.ctl import cli_parser
 from airflowctl.ctl.cli_config import (
     ARG_AUTH_TOKEN,
@@ -1494,6 +1505,115 @@ class TestCliConfigMethods:
         assert [row["dag_run_id"] for row in rows] == ["manual_run"]
         assert rows[0]["conf"] == ["a", "b"]
 
+    @pytest.mark.parametrize(
+        ("extra_argv", "expected_body"),
+        [
+            pytest.param(
+                ["--scheduling-state", "paused"],
+                DAGPatchBody(scheduling_state=DagSchedulingState.PAUSED),
+                id="scheduling-state-alone",
+            ),
+            pytest.param(["--is-paused"], DAGPatchBody(is_paused=True), 
id="explicit-pause"),
+            pytest.param(["--no-is-paused"], DAGPatchBody(is_paused=False), 
id="explicit-unpause"),
+        ],
+    )
+    @mock.patch.object(AirflowConsole, "print_as", autospec=True)
+    @mock.patch.object(DagsOperations, "update", autospec=True)
+    def test_dags_update_sends_only_given_state(
+        self, mocked_update, mocked_print_as, extra_argv, expected_body
+    ):
+        args = cli_parser.get_parser().parse_args(["dags", "update", "my_dag", 
*extra_argv])
+
+        args.func(args, api_client=mock.MagicMock(spec=Client))
+
+        assert mocked_update.call_args.kwargs["dag_body"] == expected_body
+
+    @pytest.mark.parametrize(
+        "extra_argv",
+        [
+            pytest.param([], id="no-state-does-not-unpause"),
+            pytest.param(["--is-paused", "--scheduling-state", "paused"], 
id="both-states"),
+        ],
+    )
+    @mock.patch.object(AirflowConsole, "print_as", autospec=True)
+    @mock.patch.object(DagsOperations, "update", autospec=True)
+    def test_dags_update_requires_exactly_one_state(self, mocked_update, 
mocked_print_as, extra_argv):
+        args = cli_parser.get_parser().parse_args(["dags", "update", "my_dag", 
*extra_argv])
+
+        with pytest.raises(
+            SystemExit, match="exactly one of --is-paused / --no-is-paused or 
--scheduling-state"
+        ):
+            args.func(args, api_client=mock.MagicMock(spec=Client))
+
+        mocked_update.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("extra_argv", "expected_include_deferred"),
+        [
+            pytest.param(["--include-deferred"], True, id="explicit-include"),
+            pytest.param(["--no-include-deferred"], False, 
id="explicit-exclude"),
+        ],
+    )
+    @mock.patch.object(AirflowConsole, "print_as", autospec=True)
+    @mock.patch.object(PoolsOperations, "update", autospec=True)
+    def test_pools_update_sends_given_include_deferred(
+        self, mocked_update, mocked_print_as, extra_argv, 
expected_include_deferred
+    ):
+        args = cli_parser.get_parser().parse_args(
+            ["pools", "update", "--pool", "p", "--slots", "10", *extra_argv]
+        )
+
+        args.func(args, api_client=mock.MagicMock(spec=Client))
+
+        assert mocked_update.call_args.kwargs["pool_body"] == PoolPatchBody(
+            pool="p", slots=10, include_deferred=expected_include_deferred
+        )
+
+    @mock.patch.object(AirflowConsole, "print_as", autospec=True)
+    @mock.patch.object(PoolsOperations, "update", autospec=True)
+    def test_pools_update_requires_include_deferred(self, mocked_update, 
mocked_print_as):
+        """Omitting ``--include-deferred`` must neither send 
``include_deferred=False`` nor reach the server."""
+        args = cli_parser.get_parser().parse_args(["pools", "update", 
"--pool", "p", "--slots", "10"])
+
+        with pytest.raises(SystemExit, match="Provide --include-deferred or 
--no-include-deferred"):
+            args.func(args, api_client=mock.MagicMock(spec=Client))
+
+        mocked_update.assert_not_called()
+
+    @pytest.mark.parametrize(
+        ("extra_argv", "expected_run_on_latest_version"),
+        [
+            pytest.param([], None, id="omitted-flag-stays-unset"),
+            pytest.param(["--run-on-latest-version"], True, 
id="explicit-run-on-latest"),
+            pytest.param(["--no-run-on-latest-version"], False, 
id="explicit-run-on-original"),
+        ],
+    )
+    @mock.patch.object(AirflowConsole, "print_as", autospec=True)
+    @mock.patch.object(BackfillOperations, "create", autospec=True)
+    def test_backfill_create_omitted_run_on_latest_version_stays_unset(
+        self, mocked_create, mocked_print_as, extra_argv, 
expected_run_on_latest_version
+    ):
+        """Omitting ``--run-on-latest-version`` must not send 
``run_on_latest_version=False``."""
+        args = cli_parser.get_parser().parse_args(
+            [
+                "backfill",
+                "create",
+                "--dag-id",
+                "d",
+                "--from-date",
+                "2024-01-01T00:00:00Z",
+                "--to-date",
+                "2024-01-02T00:00:00Z",
+                *extra_argv,
+            ]
+        )
+
+        args.func(args, api_client=mock.MagicMock(spec=Client))
+
+        backfill = mocked_create.call_args.kwargs["backfill"]
+        assert isinstance(backfill, BackfillPostBody)
+        assert backfill.run_on_latest_version is expected_run_on_latest_version
+
     def test_parser_resolves_nested_subcommand(self):
         """The parser descends into the state-store subgroup, binds the func, 
and keeps the auth token."""
         args = cli_parser.get_parser().parse_args(

Reply via email to