From 797a19edf3fd1aabff890bd324257456585fa0c3 Mon Sep 17 00:00:00 2001 From: Arseniy Kopylov Date: Sun, 24 May 2026 17:35:57 +0300 Subject: [PATCH] Fix `dags next-execution --table` crash when no next schedule exists The `--table` path passed all items from `iter_next_dagrun_info()` directly into a list comprehension that calls `operator.attrgetter(c)` on each item. When there is no next scheduled run (schedule=None, or @once after its run, or more executions requested than the schedule provides), the iterator yields `None`. Calling `attrgetter` on `None` raises `AttributeError: 'NoneType' object has no attribute '...'`. The non-table path already handled `None` correctly by printing a warning to stderr and skipping to the next iteration. Apply the same guard to the table path: skip `None` items and print the same warning message. Fixes #67394 --- .../src/airflow/cli/commands/dag_command.py | 12 +++++++- .../unit/cli/commands/test_dag_command.py | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/cli/commands/dag_command.py b/airflow-core/src/airflow/cli/commands/dag_command.py index 13a4ad905969c..821ea0a1e265c 100644 --- a/airflow-core/src/airflow/cli/commands/dag_command.py +++ b/airflow-core/src/airflow/cli/commands/dag_command.py @@ -368,7 +368,17 @@ def iter_next_dagrun_info() -> Iterator[DagRunInfo | None]: else: columns = ["logical_date", "data_interval.start", "data_interval.end", "run_after"] getters = [(c, operator.attrgetter(c)) for c in columns] - AirflowConsole().print_as_table([{n: f(o) for n, f in getters} for o in iter_next_dagrun_info()]) + rows = [] + for o in iter_next_dagrun_info(): + if o is None: + print( + "[WARN] No following schedule can be found. " + "This DAG may have schedule interval '@once' or `None`.", + file=sys.stderr, + ) + else: + rows.append({n: f(o) for n, f in getters}) + AirflowConsole().print_as_table(rows) return if args.field: diff --git a/airflow-core/tests/unit/cli/commands/test_dag_command.py b/airflow-core/tests/unit/cli/commands/test_dag_command.py index 183e80ee216b0..11248c3fe119c 100644 --- a/airflow-core/tests/unit/cli/commands/test_dag_command.py +++ b/airflow-core/tests/unit/cli/commands/test_dag_command.py @@ -272,6 +272,36 @@ def test_next_execution(self, dag_id, delta, schedule, catchup, first, second, t clear_db_dags() parse_and_sync_to_db(os.devnull, include_examples=True) + def test_next_execution_table_none_schedule(self, tmp_path, stdout_capture, capsys): + """--table must not crash when schedule=None yields a None DagRunInfo.""" + dag_id = "no_schedule_table_test" + file_content = os.linesep.join( + [ + "from airflow import DAG", + "from airflow.providers.standard.operators.empty import EmptyOperator", + "from pendulum import today", + f"dag = DAG('{dag_id}', start_date=today(tz='UTC'), schedule=None)", + "task = EmptyOperator(task_id='empty_task', dag=dag)", + ] + ) + dag_file = tmp_path / f"{dag_id}.py" + dag_file.write_text(file_content) + + with time_machine.travel(DEFAULT_DATE): + clear_db_dags() + parse_and_sync_to_db(tmp_path, include_examples=False) + + args = self.parser.parse_args(["dags", "next-execution", dag_id, "--table"]) + # Must not raise AttributeError when DagRunInfo is None + dag_command.dag_next_execution(args) + + captured = capsys.readouterr() + assert "No following schedule can be found" in captured.err + + # Rebuild Test DB for other tests + clear_db_dags() + parse_and_sync_to_db(os.devnull, include_examples=True) + @conf_vars({("core", "load_examples"): "true"}) def test_cli_report(self, stdout_capture): args = self.parser.parse_args(["dags", "report", "--output", "json"])