diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py b/airflow-ctl/src/airflowctl/ctl/cli_config.py index 899067d341040..c00003632aa97 100755 --- a/airflow-ctl/src/airflowctl/ctl/cli_config.py +++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py @@ -209,6 +209,16 @@ def json_dict_type(val: str | dict[str, Any]) -> dict[str, Any]: return parsed +def iso_datetime_type(val: str | datetime.datetime) -> datetime.datetime: + """Parse an ISO-8601 datetime argument.""" + if isinstance(val, datetime.datetime): + return val + try: + return datetime.datetime.fromisoformat(val) + except ValueError as e: + raise argparse.ArgumentTypeError(f"invalid datetime value: {val!r}") from e + + 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" @@ -566,7 +576,7 @@ def _python_type_from_string(type_name: str | type) -> type | Callable: "dict": json_dict_type, "tuple": tuple, "set": set, - "datetime.datetime": datetime.datetime, + "datetime.datetime": iso_datetime_type, "dict[str, typing.Any]": json_dict_type, } # Default to ``str`` to preserve previous behaviour for any unrecognised 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 d568cacc0981c..3e389eb7b7ccc 100644 --- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py +++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py @@ -34,6 +34,7 @@ CommandFactory, GroupCommand, add_auth_token_to_all_commands, + iso_datetime_type, json_dict_type, merge_commands, safe_call_command, @@ -364,6 +365,37 @@ def test_json_dict_type_rejects_non_object_json(self, value): with pytest.raises(argparse.ArgumentTypeError, match="expected JSON object"): json_dict_type(value) + def test_iso_datetime_type_returns_datetime_input_unchanged(self): + """A datetime.datetime input is returned as-is without re-parsing.""" + import datetime + + value = datetime.datetime(2026, 7, 1, tzinfo=datetime.timezone.utc) + + assert iso_datetime_type(value) is value + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("2026-07-01", "2026-07-01T00:00:00"), + ("2026-07-01T00:00:00", "2026-07-01T00:00:00"), + ("2026-07-01T12:34:56+00:00", "2026-07-01T12:34:56+00:00"), + ], + ) + def test_iso_datetime_type_parses_iso_string(self, value, expected): + """An ISO-8601 datetime string (date-only or full) is parsed into a datetime. + + Regression test for https://github.com/apache/airflow/issues/70232: previously the + bare ``datetime.datetime`` class was used as the argparse ``type=`` callable, so + argparse called ``datetime.datetime(value)`` on the raw string, which always raised + a ``TypeError`` regardless of the input. + """ + assert iso_datetime_type(value).isoformat() == expected + + def test_iso_datetime_type_rejects_invalid_value(self): + """A non-parseable string raises an ArgumentTypeError.""" + with pytest.raises(argparse.ArgumentTypeError, match="invalid datetime value"): + iso_datetime_type("not-a-date") + def test_command_factory_required_primitive_param_is_positional(self, tmp_path): """Required primitive parameters (no default, not Optional) become positional arguments.