This is an automated email from the ASF dual-hosted git repository.
bugraoz93 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 a1cec525543 Fix airflowctl JSON parsing for Dag run conf (#68985)
a1cec525543 is described below
commit a1cec525543c048b492f617e174b6c862664e55e
Author: Atakan Yenel <[email protected]>
AuthorDate: Mon Jul 13 21:44:51 2026 +0100
Fix airflowctl JSON parsing for Dag run conf (#68985)
* Fix airflowctl JSON parsing for Dag run conf
airflowctl dags trigger --conf rejected JSON object strings because the
generated parser validated them with raw dict, which cannot consume a JSON
string. Parse JSON object CLI values into dictionaries before validation so
conf values such as {"my-key": "my-value"} are accepted.
closes: #67989
* Expand json_dict_type test coverage for airflowctl conf parsing
Cover dict input early return, invalid JSON, and non-object JSON so all
branches of the conf parser are exercised.
---------
Co-authored-by: Atakan Yenel <[email protected]>
---
airflow-ctl/src/airflowctl/ctl/cli_config.py | 18 ++++++++-
.../tests/airflow_ctl/ctl/test_cli_config.py | 44 +++++++++++++++++++++-
2 files changed, 59 insertions(+), 3 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index d0901e795ef..40ee7a1c67a 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -24,6 +24,7 @@ import argparse
import ast
import datetime
import inspect
+import json
import os
import sys
from argparse import Namespace
@@ -195,6 +196,19 @@ def string_lower_type(val):
return val.strip().lower()
+def json_dict_type(val: str | dict[str, Any]) -> dict[str, Any]:
+ """Parse JSON object argument."""
+ if isinstance(val, dict):
+ return val
+ try:
+ parsed = json.loads(val)
+ except json.JSONDecodeError as e:
+ raise argparse.ArgumentTypeError(f"invalid JSON object: {val!r}") from
e
+ if not isinstance(parsed, dict):
+ raise argparse.ArgumentTypeError(f"expected JSON object: {val!r}")
+ return parsed
+
+
def _load_help_texts_yaml() -> dict[str, dict[str, str]]:
"""Load the help texts yaml for the auto-generated commands."""
help_texts_path = Path(__file__).parent / "help_texts.yaml"
@@ -516,11 +530,11 @@ class CommandFactory:
"str": str,
"bytes": bytes,
"list": list,
- "dict": dict,
+ "dict": json_dict_type,
"tuple": tuple,
"set": set,
"datetime.datetime": datetime.datetime,
- "dict[str, typing.Any]": dict,
+ "dict[str, typing.Any]": json_dict_type,
}
# Default to ``str`` to preserve previous behaviour for any
unrecognised
# type names while still allowing the CLI to function.
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 945b5abb833..281bd2b040b 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -33,6 +33,7 @@ from airflowctl.ctl.cli_config import (
CommandFactory,
GroupCommand,
add_auth_token_to_all_commands,
+ json_dict_type,
merge_commands,
safe_call_command,
)
@@ -105,7 +106,7 @@ def test_args_create():
"help": "dag_run_conf for backfill operation",
"action": None,
"default": None,
- "type": dict,
+ "type": json_dict_type,
"dest": None,
},
),
@@ -321,6 +322,47 @@ class TestCommandFactory:
assert is_alive_arg.kwargs["default"] is None
assert is_alive_arg.kwargs["type"] is bool
+ def test_command_factory_parses_json_dict_datamodel_fields(self):
+ """Dict datamodel fields should parse JSON object CLI values."""
+ command_factory = CommandFactory()
+ dags_trigger_args = []
+ for generated_group_command in command_factory.group_commands:
+ if generated_group_command.name != "dags":
+ continue
+ for sub_command in generated_group_command.subcommands:
+ if sub_command.name == "trigger":
+ dags_trigger_args = list(sub_command.args)
+ break
+
+ conf_arg = next(arg for arg in dags_trigger_args if arg.flags ==
("--conf",))
+ parsed_conf = conf_arg.kwargs["type"]('{"my-key": "my-value"}')
+
+ assert parsed_conf == {"my-key": "my-value"}
+
+ def test_json_dict_type_returns_dict_input_unchanged(self):
+ """A dict input is returned as-is without re-parsing."""
+ value = {"my-key": "my-value"}
+
+ assert json_dict_type(value) is value
+
+ def test_json_dict_type_parses_json_object(self):
+ """A JSON object string is parsed into a dict."""
+ assert json_dict_type('{"my-key": "my-value"}') == {"my-key":
"my-value"}
+
+ def test_json_dict_type_rejects_invalid_json(self):
+ """Invalid JSON raises an ArgumentTypeError."""
+ with pytest.raises(argparse.ArgumentTypeError, match="invalid JSON
object"):
+ json_dict_type("{not valid json}")
+
+ @pytest.mark.parametrize(
+ "value",
+ ["[]", '"string"', "123", "true", "null"],
+ )
+ def test_json_dict_type_rejects_non_object_json(self, value):
+ """Valid JSON that is not an object raises an ArgumentTypeError."""
+ with pytest.raises(argparse.ArgumentTypeError, match="expected JSON
object"):
+ json_dict_type(value)
+
def test_command_factory_required_primitive_param_is_positional(self,
tmp_path):
"""Required primitive parameters (no default, not Optional) become
positional arguments.