This is an automated email from the ASF dual-hosted git repository.
Lee-W 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 fa9c935035d airflowctl: fix datetime.datetime CLI parameters rejecting
all input (#70249)
fa9c935035d is described below
commit fa9c935035dca69b3319469869976b56e5d790d8
Author: Bramhanand Lingala <[email protected]>
AuthorDate: Tue Oct 27 05:37:37 2026 -0400
airflowctl: fix datetime.datetime CLI parameters rejecting all input
(#70249)
Co-authored-by: Wei Lee <[email protected]>
---
airflow-ctl/src/airflowctl/ctl/cli_config.py | 31 +++++++++-
.../tests/airflow_ctl/ctl/test_cli_config.py | 66 ++++++++++++++++++++++
2 files changed, 95 insertions(+), 2 deletions(-)
diff --git a/airflow-ctl/src/airflowctl/ctl/cli_config.py
b/airflow-ctl/src/airflowctl/ctl/cli_config.py
index 95979dbe503..b0f229be2ff 100755
--- a/airflow-ctl/src/airflowctl/ctl/cli_config.py
+++ b/airflow-ctl/src/airflowctl/ctl/cli_config.py
@@ -216,6 +216,33 @@ def json_dict_type(val: str | dict[str, Any]) -> dict[str,
Any]:
return parsed
+def is_iso_datetime_type(val: str | datetime.datetime) -> datetime.datetime:
+ """Parse an ISO-8601 datetime argument."""
+ if isinstance(val, datetime.datetime):
+ return val
+ try:
+ if val.endswith("Z"):
+ val = val[:-1] + "+00:00"
+ return datetime.datetime.fromisoformat(val)
+ except ValueError as e:
+ raise argparse.ArgumentTypeError(f"invalid datetime value: {val!r}")
from e
+
+
+def is_iso_date_type(val: str | datetime.date) -> datetime.date:
+ """
+ Parse an ISO-8601 date string into a datetime.date.
+
+ Used as the argparse ``type=`` callable for datetime.date-typed CLI
+ parameters, mirroring is_iso_datetime_type() above.
+ """
+ if isinstance(val, datetime.date):
+ return val
+ try:
+ return datetime.date.fromisoformat(val)
+ except ValueError as e:
+ raise argparse.ArgumentTypeError(f"Invalid ISO-8601 date: {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"
@@ -662,8 +689,8 @@ class CommandFactory:
"dict": json_dict_type,
"tuple": tuple,
"set": set,
- "datetime.date": datetime.date,
- "datetime.datetime": datetime.datetime,
+ "datetime.date": is_iso_date_type,
+ "datetime.datetime": is_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 8417860fcc4..4e8bf00c906 100644
--- a/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
+++ b/airflow-ctl/tests/airflow_ctl/ctl/test_cli_config.py
@@ -18,6 +18,7 @@
from __future__ import annotations
import argparse
+import datetime
import json
from argparse import BooleanOptionalAction
from datetime import UTC
@@ -41,6 +42,8 @@ from airflowctl.ctl.cli_config import (
CommandFactory,
GroupCommand,
add_auth_token_to_all_commands,
+ is_iso_date_type,
+ is_iso_datetime_type,
json_dict_type,
merge_commands,
safe_call_command,
@@ -435,6 +438,51 @@ class TestCommandFactory:
with pytest.raises(argparse.ArgumentTypeError, match="expected JSON
object"):
json_dict_type(value)
+ def test_is_iso_datetime_type_returns_datetime_input_unchanged(self):
+ """A datetime.datetime input is returned as-is without re-parsing."""
+
+ value = datetime.datetime(2026, 7, 1, tzinfo=UTC)
+
+ assert is_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"),
+ ("2026-07-01T12:34:56Z", "2026-07-01T12:34:56+00:00"),
+ ],
+ )
+ def test_is_iso_datetime_type_parses_iso_string(self, value, expected):
+ """An ISO-8601 datetime string (date-only or full) is parsed into a
datetime."""
+ assert is_iso_datetime_type(value).isoformat() == expected
+
+ def test_is_iso_datetime_type_rejects_invalid_value(self):
+ """A non-parseable string raises an ArgumentTypeError."""
+ with pytest.raises(argparse.ArgumentTypeError, match="invalid datetime
value"):
+ is_iso_datetime_type("not-a-date")
+
+ def test_is_iso_datetime_type_accepts_trailing_z(self):
+ """A trailing Z (as emitted by the API) parses on all supported Python
versions."""
+ assert is_iso_datetime_type("2026-07-01T12:00:00Z") ==
datetime.datetime(
+ 2026, 7, 1, 12, 0, 0, tzinfo=UTC
+ )
+
+ def test_is_iso_date_type_parses_iso_date(self):
+ """An ISO-8601 date string is parsed into a datetime.date."""
+ assert is_iso_date_type("2026-07-01") == datetime.date(2026, 7, 1)
+
+ def test_is_iso_date_type_returns_date_input_unchanged(self):
+ """A datetime.date input is returned as-is without re-parsing."""
+ d = datetime.date(2026, 7, 1)
+ assert is_iso_date_type(d) is d
+
+ def test_is_iso_date_type_rejects_invalid_value(self):
+ """A non-parseable string raises an ArgumentTypeError."""
+ with pytest.raises(argparse.ArgumentTypeError, match="Invalid ISO-8601
date"):
+ is_iso_date_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.
@@ -1013,6 +1061,24 @@ class TestCliConfigMethods:
return
pytest.fail(f"Auto-generated command not found: {group_name}
{subcommand_name}")
+ def test_command_factory_wires_iso_parser_to_datetime_params(self):
+ """A generated datetime CLI arg parses an ISO date end to end."""
+ command_factory = CommandFactory()
+ dagrun_list_args = []
+
+ for group in command_factory.group_commands:
+ if group.name != "dagrun":
+ continue
+
+ for sub in group.subcommands:
+ if sub.name == "list":
+ dagrun_list_args = list(sub.args)
+ break
+
+ start_date_arg = next(a for a in dagrun_list_args if a.flags ==
("--start-date",))
+
+ assert start_date_arg.kwargs["type"]("2026-07-01") ==
datetime.datetime(2026, 7, 1)
+
@staticmethod
def _call_generated_command(monkeypatch, operations_class, method_name:
str, **parsed_args):
"""Run the auto-generated command for ``operations_class.method_name``
and return its call kwargs."""