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 ffa426b64d7 airflowctl: make required CLI params positional, keep 
optional as --flag (#66768)
ffa426b64d7 is described below

commit ffa426b64d7d99a66482b817887ee74d399824c4
Author: Stefan Wang <[email protected]>
AuthorDate: Tue May 12 14:57:46 2026 -0700

    airflowctl: make required CLI params positional, keep optional as --flag 
(#66768)
    
    * airflowctl: make required CLI params positional, keep optional as --flag
    
    Auto-generated commands such as ``airflowctl dags get-details`` now accept
    required primitive parameters positionally:
    
        airflowctl dags get-details my_dag_id
    
    instead of the previous ``--dag-id my_dag_id`` form. Optional parameters
    and booleans keep the ``--flag`` form.
    
    This follows the dev-list lazy consensus on airflowctl parameter style.
    
    A parameter is considered required when the operation method declares it
    without a default and without ``| None`` in its annotation. Datamodel-
    expanded body fields are unaffected — they are not "parameters of the
    operation method" in this sense and continue to use ``--flag``.
    
    * tests: tmp_path fixture for command-factory; positional form for 
integration tests
    
    Two follow-ups to the positional-required-args change:
    
    - ``TestCommandFactory._save_temp_operations_py`` previously wrote a
      shared ``test_command.py`` in cwd; under pytest-xdist that file is
      raced by workers, so ``next(arg for arg in jobs_list_args if ...)``
      in one test could see content written by another and raise
      ``StopIteration``. Helper now takes the per-test ``tmp_path`` and
      returns the full path. The classmethod ``teardown_method`` that
      removed the shared file is no longer needed (pytest auto-cleans
      ``tmp_path``).
    
    - The Airflow CTL PROD-image integration tests still invoked converted
      parameters with the old ``--flag value`` form (e.g.
      ``--variable-key=X``, ``--section X --option Y``,
      ``--dag-id=example_bash_operator``). Updated each occurrence to the
      positional form that the regenerated CLI now expects. Optional
      parameters (``--logical-date``, ``--run-after``, ``--is-paused``,
      ``--state``, ``--limit``, etc.) stay as ``--flag``.
    
    Signed-off-by: 1fanwang <[email protected]>
    
    ---------
    
    Signed-off-by: 1fanwang <[email protected]>
---
 .../airflowctl_tests/test_airflowctl_commands.py   |  60 ++++-----
 .../test_config_sensitive_masking.py               |   4 +-
 airflow-ctl/RELEASE_NOTES.rst                      |  11 ++
 airflow-ctl/src/airflowctl/ctl/cli_config.py       |  75 +++++++++--
 .../tests/airflow_ctl/ctl/test_cli_config.py       | 148 ++++++++++++++++-----
 5 files changed, 224 insertions(+), 74 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 15595269d26..b8b49ad3ae7 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py
@@ -54,12 +54,12 @@ TEST_COMMANDS = [
     "auth list-envs",
     # Assets commands
     "assets list",
-    "assets get --asset-id=1",
+    "assets get 1",
     "assets create-event --asset-id=1",
     # Backfill commands
-    "backfill list",
+    "backfill list example_bash_operator",
     # Config commands
-    "config get --section core --option executor",
+    "config get core executor",
     "config list",
     "config lint",
     # Connections commands
@@ -67,62 +67,62 @@ TEST_COMMANDS = [
     "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 get test_con",
+    "connections get 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",
+    "connections delete test_con",
+    "connections delete 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 get example_bash_operator",
+    "dags get-details example_bash_operator",
+    "dags get-stats example_bash_operator",
+    "dags get-version example_bash_operator 1",
     "dags list-import-errors",
-    "dags list-version --dag-id=example_bash_operator",
+    "dags list-version example_bash_operator",
     "dags list-warning",
     # Order of trigger and pause/unpause is important for test stability 
because state checked
-    "dags trigger --dag-id=example_bash_operator --logical-date={date_param} 
--run-after={date_param}",
+    "dags trigger example_bash_operator --logical-date={date_param} 
--run-after={date_param}",
     # Test trigger without logical-date (should default to now)
-    "dags trigger --dag-id=example_bash_operator",
+    "dags trigger example_bash_operator",
     "dags pause example_bash_operator",
     "dags unpause example_bash_operator",
     # Dag Run commands
-    'dagrun get --dag-id=example_bash_operator 
--dag-run-id="manual__{date_param}"',
-    "dags update --dag-id=example_bash_operator --no-is-paused",
+    'dagrun get example_bash_operator "manual__{date_param}"',
+    "dags update example_bash_operator --no-is-paused",
     # Dag Run commands
     "dagrun list --dag-id example_bash_operator --state success --limit=1",
     # XCom commands - need a Dag run with completed tasks
-    'xcom add --dag-id=example_bash_operator 
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key={xcom_key} 
--value=\'{{"test": "value"}}\'',
-    'xcom get --dag-id=example_bash_operator 
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key={xcom_key}',
-    'xcom list --dag-id=example_bash_operator 
--dag-run-id="manual__{date_param}" --task-id=runme_0',
-    'xcom edit --dag-id=example_bash_operator 
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key={xcom_key} 
--value=\'{{"updated": "value"}}\'',
-    'xcom delete --dag-id=example_bash_operator 
--dag-run-id="manual__{date_param}" --task-id=runme_0 --key={xcom_key}',
+    'xcom add example_bash_operator "manual__{date_param}" runme_0 {xcom_key} 
\'{{"test": "value"}}\'',
+    'xcom get example_bash_operator "manual__{date_param}" runme_0 {xcom_key}',
+    'xcom list example_bash_operator "manual__{date_param}" runme_0',
+    'xcom edit example_bash_operator "manual__{date_param}" runme_0 {xcom_key} 
\'{{"updated": "value"}}\'',
+    'xcom delete example_bash_operator "manual__{date_param}" runme_0 
{xcom_key}',
     # 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 get test_pool",
+    "pools get test_pool -o yaml",
     "pools update --pool=test_pool --slots=10",
     "pools import tests/airflowctl_tests/fixtures/test_pools.json",
     "pools export tests/airflowctl_tests/fixtures/pools_export.json 
--output=json",
-    "pools delete --pool=test_pool",
-    "pools delete --pool=test_import_pool",
+    "pools delete test_pool",
+    "pools delete 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 get test_key",
+    "variables get 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",
+    "variables delete test_key",
+    "variables delete test_import_var",
+    "variables delete test_import_var_with_desc",
     # Plugins command
     "plugins list",
     "plugins list-import-errors",
diff --git 
a/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py 
b/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
index 776629da3a2..a9e98d9365c 100644
--- a/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
+++ b/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py
@@ -23,8 +23,8 @@ SENSITIVE_CONFIG_COMMANDS = [
     # Test that config list shows masked sensitive values
     "config list",
     # Test that getting specific sensitive config values are masked
-    "config get --section core --option fernet_key",
-    "config get --section database --option sql_alchemy_conn",
+    "config get core fernet_key",
+    "config get database sql_alchemy_conn",
 ]
 
 
diff --git a/airflow-ctl/RELEASE_NOTES.rst b/airflow-ctl/RELEASE_NOTES.rst
index dc250d0ec04..0de56a413dc 100644
--- a/airflow-ctl/RELEASE_NOTES.rst
+++ b/airflow-ctl/RELEASE_NOTES.rst
@@ -15,6 +15,17 @@
     specific language governing permissions and limitations
     under the License.
 
+airflowctl unreleased
+---------------------
+
+Significant Changes
+^^^^^^^^^^^^^^^^^^^
+
+- Expose required primitive parameters of auto-generated commands as positional
+  arguments instead of ``--flag`` options. Optional parameters keep the
+  ``--flag`` form. Follows the dev-list lazy consensus on airflowctl parameter
+  style (see 
`<https://lists.apache.org/thread/m1qvcvow3l17ytv40vhslh40wn3rntrm>`_).
+
 airflowctl 0.1.4 (2026-04-18)
 -----------------------------
 
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py 
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index aa96304f501..1d3c30121b6 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -423,11 +423,19 @@ class CommandFactory:
             args = []
             return_annotation: str = ""
 
-            for arg in node.args.args:
+            # In ``ast.arguments``, ``defaults`` aligns with the *tail* of
+            # ``args``. A parameter is required when its position from the
+            # left is *before* the first defaulted position. Equivalent to
+            # ``len(args) - len(defaults)``.
+            positional_args = [a for a in node.args.args if a.arg != "self"]
+            defaults_count = len(node.args.defaults)
+            required_count = len(positional_args) - defaults_count
+            required_param_names: set[str] = {a.arg for a in 
positional_args[:required_count]}
+
+            for arg in positional_args:
                 arg_name = arg.arg
                 arg_type = ast.unparse(arg.annotation) if arg.annotation else 
"Any"
-                if arg_name != "self":
-                    args.append({arg_name: arg_type})
+                args.append({arg_name: arg_type})
 
             if node.returns:
                 return_annotation = [
@@ -437,6 +445,7 @@ class CommandFactory:
             return {
                 "name": func_name,
                 "parameters": args,
+                "required_param_names": required_param_names,
                 "return_type": return_annotation,
                 "parent": parent_node,
             }
@@ -532,6 +541,26 @@ class CommandFactory:
             action=arg_action,
         )
 
+    @staticmethod
+    def _create_positional_arg(
+        parameter_key: str,
+        arg_type: type | Callable,
+        arg_help: str,
+    ) -> Arg:
+        """
+        Build a positional ``Arg`` for a required primitive parameter.
+
+        ``argparse`` rejects ``default`` and ``dest`` on positional arguments,
+        so this helper keeps both unset and uses the raw parameter name (with
+        underscores) as the flag so the parsed ``Namespace`` attribute lines up
+        with the operation method's signature.
+        """
+        return Arg(
+            flags=(parameter_key,),
+            type=arg_type,
+            help=arg_help,
+        )
+
     def _create_arg_for_non_primitive_type(
         self,
         parameter_type: str,
@@ -577,20 +606,44 @@ class CommandFactory:
         """Create Arg from Operation Method checking for parameters and return 
types."""
         for operation in self.operations:
             args = []
+            required_names: set[str] = operation.get("required_param_names") 
or set()
             for parameter in operation.get("parameters"):
                 for parameter_key, parameter_type in parameter.items():
                     if self._is_primitive_type(type_name=parameter_type):
                         base_parameter_type = parameter_type.replace(" | 
None", "").strip()
                         is_bool = base_parameter_type == "bool"
-                        args.append(
-                            self._create_arg(
-                                arg_flags=("--" + 
self._sanitize_arg_parameter_key(parameter_key),),
-                                
arg_type=self._python_type_from_string(parameter_type),
-                                arg_action=argparse.BooleanOptionalAction if 
is_bool else None,
-                                arg_help=f"{parameter_key} for 
{operation.get('name')} operation in {operation.get('parent').name}",
-                                arg_default=None,
-                            )
+                        # Required, non-bool primitives are exposed as 
positional
+                        # arguments per the dev-list lazy consensus
+                        # 
(https://lists.apache.org/thread/m1qvcvow3l17ytv40vhslh40wn3rntrm).
+                        # Bool stays --flag/--no-flag and ``parameter_type``
+                        # ending in ``| None`` is treated as optional.
+                        is_required_positional = (
+                            parameter_key in required_names and not is_bool 
and "| None" not in parameter_type
                         )
+                        if is_required_positional:
+                            args.append(
+                                self._create_positional_arg(
+                                    parameter_key=parameter_key,
+                                    
arg_type=self._python_type_from_string(parameter_type),
+                                    arg_help=(
+                                        f"{parameter_key} for 
{operation.get('name')} "
+                                        f"operation in 
{operation.get('parent').name}"
+                                    ),
+                                )
+                            )
+                        else:
+                            args.append(
+                                self._create_arg(
+                                    arg_flags=("--" + 
self._sanitize_arg_parameter_key(parameter_key),),
+                                    
arg_type=self._python_type_from_string(parameter_type),
+                                    arg_action=argparse.BooleanOptionalAction 
if is_bool else None,
+                                    arg_help=(
+                                        f"{parameter_key} for 
{operation.get('name')} "
+                                        f"operation in 
{operation.get('parent').name}"
+                                    ),
+                                    arg_default=None,
+                                )
+                            )
                     else:
                         args.extend(
                             self._create_arg_for_non_primitive_type(
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 e0278cd7c53..945b5abb833 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import argparse
 from argparse import BooleanOptionalAction
+from pathlib import Path
 from textwrap import dedent
 
 import httpx
@@ -158,12 +159,10 @@ def test_args_list():
 def test_args_get():
     return [
         (
-            "--backfill-id",
+            "backfill_id",
             {
                 "help": "backfill_id for get operation in BackfillsOperations",
-                "default": None,
                 "type": str,
-                "dest": None,
             },
         ),
         (
@@ -182,12 +181,10 @@ def test_args_get():
 def test_args_delete():
     return [
         (
-            "--backfill-id",
+            "backfill_id",
             {
                 "help": "backfill_id for delete operation in 
BackfillsOperations",
-                "default": None,
                 "type": str,
-                "dest": None,
             },
         ),
         (
@@ -204,26 +201,26 @@ def test_args_delete():
 
 class TestCommandFactory:
     @classmethod
-    def _save_temp_operations_py(cls, temp_file: str, file_content) -> None:
+    def _save_temp_operations_py(cls, tmp_path: Path, file_content: str) -> 
Path:
         """
         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.
+        Writing inside a per-test ``tmp_path`` keeps the file isolated under
+        parallel execution (pytest-xdist), avoiding cross-worker overwrites of
+        a shared ``test_command.py`` in the cwd. Returns the full path.
         """
-        try:
-            import os
-
-            os.remove("test_command.py")
-        except FileNotFoundError:
-            pass
+        temp_file = tmp_path / "test_command.py"
+        temp_file.write_text(dedent(file_content))
+        return temp_file
 
     def test_command_factory(
-        self, no_op_method, test_args_create, test_args_list, test_args_get, 
test_args_delete
+        self,
+        tmp_path,
+        no_op_method,
+        test_args_create,
+        test_args_list,
+        test_args_get,
+        test_args_delete,
     ):
         """
         Test the command factory.
@@ -231,9 +228,8 @@ class TestCommandFactory:
         # 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,
+        temp_file = self._save_temp_operations_py(
+            tmp_path=tmp_path,
             file_content="""
                 class NotAnOperation:
                     def test_method(self):
@@ -260,7 +256,7 @@ class TestCommandFactory:
             """,
         )
 
-        command_factory = CommandFactory(file_path=temp_file)
+        command_factory = CommandFactory(file_path=str(temp_file))
         generated_group_commands = command_factory.group_commands
 
         for generated_group_command in generated_group_commands:
@@ -287,20 +283,19 @@ class TestCommandFactory:
                     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.get("default") == 
test_arg[1].get("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.get("default") == 
test_arg[1].get("default")
                         assert arg.kwargs["type"] == test_arg[1]["type"]
 
-    def test_command_factory_optional_bool_uses_boolean_optional_action(self):
+    def test_command_factory_optional_bool_uses_boolean_optional_action(self, 
tmp_path):
         """Optional bool parameters should support --flag and --no-flag 
forms."""
-        temp_file = "test_command.py"
-        self._save_temp_operations_py(
-            temp_file=temp_file,
+        temp_file = self._save_temp_operations_py(
+            tmp_path=tmp_path,
             file_content="""
                 class JobsOperations(BaseOperations):
                     def list(self, is_alive: bool | None = None) -> 
JobCollectionResponse | ServerResponseError:
@@ -309,7 +304,7 @@ class TestCommandFactory:
             """,
         )
 
-        command_factory = CommandFactory(file_path=temp_file)
+        command_factory = CommandFactory(file_path=str(temp_file))
         generated_group_commands = command_factory.group_commands
 
         jobs_list_args = []
@@ -326,6 +321,97 @@ class TestCommandFactory:
         assert is_alive_arg.kwargs["default"] is None
         assert is_alive_arg.kwargs["type"] is bool
 
+    def test_command_factory_required_primitive_param_is_positional(self, 
tmp_path):
+        """Required primitive parameters (no default, not Optional) become 
positional arguments.
+
+        Following the dev-list lazy consensus on ``airflowctl`` parameter style
+        
(`<https://lists.apache.org/thread/m1qvcvow3l17ytv40vhslh40wn3rntrm>`_),
+        required parameters of auto-generated commands should be positional and
+        optional parameters keep the ``--flag`` form.
+        """
+        temp_file = self._save_temp_operations_py(
+            tmp_path=tmp_path,
+            file_content="""
+                class WidgetsOperations(BaseOperations):
+                    def get(self, widget_id: str) -> WidgetResponse | 
ServerResponseError:
+                        self.response = self.client.get(f"widgets/{widget_id}")
+                        return 
WidgetResponse.model_validate_json(self.response.content)
+                    def delete(self, widget_id: str) -> ServerResponseError | 
None:
+                        self.response = 
self.client.delete(f"widgets/{widget_id}")
+                        return None
+                    def update_version(
+                        self, widget_id: str, version: int, note: str | None = 
None
+                    ) -> WidgetResponse | ServerResponseError:
+                        self.response = self.client.patch(
+                            f"widgets/{widget_id}/version/{version}",
+                            json={"note": note},
+                        )
+                        return 
WidgetResponse.model_validate_json(self.response.content)
+            """,
+        )
+
+        command_factory = CommandFactory(file_path=str(temp_file))
+        sub_commands = {}
+        for generated_group_command in command_factory.group_commands:
+            if generated_group_command.name != "widgets":
+                continue
+            for sub_command in generated_group_command.subcommands:
+                sub_commands[sub_command.name] = sub_command
+
+        # get: single required str -> positional
+        get_args = list(sub_commands["get"].args)
+        widget_id_arg = next(arg for arg in get_args if arg.flags[0] in 
("widget_id", "--widget-id"))
+        assert widget_id_arg.flags == ("widget_id",)
+        assert widget_id_arg.kwargs["type"] is str
+        assert "default" not in widget_id_arg.kwargs
+        assert "action" not in widget_id_arg.kwargs
+
+        # delete: same shape
+        delete_args = list(sub_commands["delete"].args)
+        delete_widget_id_arg = next(
+            arg for arg in delete_args if arg.flags[0] in ("widget_id", 
"--widget-id")
+        )
+        assert delete_widget_id_arg.flags == ("widget_id",)
+
+        # update-version: two required (str + int) positional, ``note`` keeps 
--flag form
+        update_args = list(sub_commands["update-version"].args)
+        update_widget_id_arg = next(
+            arg for arg in update_args if arg.flags[0] in ("widget_id", 
"--widget-id")
+        )
+        version_arg = next(arg for arg in update_args if arg.flags[0] in 
("version", "--version"))
+        note_arg = next(arg for arg in update_args if arg.flags[0] in ("note", 
"--note"))
+        assert update_widget_id_arg.flags == ("widget_id",)
+        assert version_arg.flags == ("version",)
+        assert version_arg.kwargs["type"] is int
+        assert note_arg.flags == ("--note",)
+        assert note_arg.kwargs["default"] is None
+
+    def test_command_factory_primitive_param_with_default_keeps_flag(self, 
tmp_path):
+        """Primitive parameters with a default value keep the ``--flag`` 
form."""
+        temp_file = self._save_temp_operations_py(
+            tmp_path=tmp_path,
+            file_content="""
+                class WidgetsOperations(BaseOperations):
+                    def list(self, limit: int = 100) -> 
WidgetCollectionResponse | ServerResponseError:
+                        self.response = self.client.get("widgets", 
params={"limit": limit})
+                        return 
WidgetCollectionResponse.model_validate_json(self.response.content)
+            """,
+        )
+
+        command_factory = CommandFactory(file_path=str(temp_file))
+        list_args = []
+        for generated_group_command in command_factory.group_commands:
+            if generated_group_command.name != "widgets":
+                continue
+            for sub_command in generated_group_command.subcommands:
+                if sub_command.name == "list":
+                    list_args = list(sub_command.args)
+                    break
+
+        limit_arg = next(arg for arg in list_args if arg.flags[0] in ("limit", 
"--limit"))
+        assert limit_arg.flags == ("--limit",)
+        assert limit_arg.kwargs["type"] is int
+
 
 class TestCliConfigMethods:
     @pytest.mark.parametrize(

Reply via email to