Repository: apache/airflow

Issue:
A dag's schedule interval can no longer be an instance of dateutils.relativedelta
### Apache Airflow version

2.2.1 (latest released)

### Operating System

debian

### Versions of Apache Airflow Providers

apache-airflow==2.2.1
apache-airflow-providers-amazon==2.3.0
apache-airflow-providers-ftp==2.0.1
apache-airflow-providers-google==6.0.0
apache-airflow-providers-http==2.0.1
apache-airflow-providers-imap==2.0.1
apache-airflow-providers-jira==2.0.1
apache-airflow-providers-mysql==2.1.1
apache-airflow-providers-postgres==2.3.0
apache-airflow-providers-redis==2.0.1
apache-airflow-providers-sqlite==2.0.1
apache-airflow-providers-ssh==2.2.0

### Deployment

Other Docker-based deployment

### Deployment details

Dask executor, custom-built Docker images, postgres 12.7 backend

### What happened

I upgraded Airflow from 2.0.2 to 2.2.1, and some DAGs I have that used dateutils.relativedelta objects as schedule intervals stopped running

### What you expected to happen

The [code](https://github.com/apache/airflow/blob/2.2.1/airflow/models/dag.py#L101) for the schedule_interval parameter of the DAG constructor indicates that a relativedelta object is allowed, so I expected the DAG to be correctly parsed and scheduled.

### How to reproduce

Create a DAG that has a relativedelta object as its schedule interval, and it will not appear in the UI or be scheduled.

### Anything else

Here is the code that causes the failure within the PR where it was introduced: [link](https://github.com/apache/airflow/pull/17414/files#diff-ed37fe966e8247e0bfd8aa28bc2698febeec3807df5f5a00545ca80744f8aff6R267)

Here are the logs for the exception, found in the scheduler logs for the file that contains the offending DAG
<details><pre>
ERROR   | {dagbag.py:528} - 'relativedelta' object has no attribute 'total_seconds'
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/airflow/models/dagbag.py", line 515, in collect_dags
    found_dags = self.process_file(filepath, only_if_updated=only_if_updated, safe_mode=safe_mode)
  File "/usr/local/lib/python3.9/site-packages/airflow/models/dagbag.py", line 298, in process_file
    found_dags = self._process_modules(filepath, mods, file_last_changed_on_disk)
  File "/usr/local/lib/python3.9/site-packages/airflow/models/dagbag.py", line 401, in _process_modules
    dag.timetable.validate()
  File "/usr/local/lib/python3.9/site-packages/airflow/timetables/interval.py", line 274, in validate
    if self._delta.total_seconds() <= 0:
AttributeError: 'relativedelta' object has no attribute 'total_seconds'
</pre></details>

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:

Resolving patch:
diff --git a/airflow/timetables/interval.py b/airflow/timetables/interval.py
--- a/airflow/timetables/interval.py
+++ b/airflow/timetables/interval.py
@@ -271,8 +271,9 @@ def serialize(self) -> Dict[str, Any]:
         return {"delta": delta}
 
     def validate(self) -> None:
-        if self._delta.total_seconds() <= 0:
-            raise AirflowTimetableInvalid("schedule interval must be positive")
+        now = datetime.datetime.now()
+        if (now + self._delta) <= now:
+            raise AirflowTimetableInvalid(f"schedule interval must be positive, not {self._delta!r}")
 
     def _get_next(self, current: DateTime) -> DateTime:
         return convert_to_utc(current + self._delta)


Test patch:
diff --git a/tests/timetables/test_interval_timetable.py b/tests/timetables/test_interval_timetable.py
--- a/tests/timetables/test_interval_timetable.py
+++ b/tests/timetables/test_interval_timetable.py
@@ -18,10 +18,12 @@
 import datetime
 from typing import Optional
 
+import dateutil.relativedelta
 import freezegun
 import pendulum
 import pytest
 
+from airflow.exceptions import AirflowTimetableInvalid
 from airflow.settings import TIMEZONE
 from airflow.timetables.base import DagRunInfo, DataInterval, TimeRestriction, Timetable
 from airflow.timetables.interval import CronDataIntervalTimetable, DeltaDataIntervalTimetable
@@ -35,12 +37,17 @@
 CURRENT_TIME = pendulum.DateTime(2021, 9, 7, tzinfo=TIMEZONE)
 
 HOURLY_CRON_TIMETABLE = CronDataIntervalTimetable("@hourly", TIMEZONE)
-HOURLY_DELTA_TIMETABLE = DeltaDataIntervalTimetable(datetime.timedelta(hours=1))
+HOURLY_TIMEDELTA_TIMETABLE = DeltaDataIntervalTimetable(datetime.timedelta(hours=1))
+HOURLY_RELATIVEDELTA_TIMETABLE = DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta(hours=1))
 
 
 @pytest.mark.parametrize(
     "timetable",
-    [pytest.param(HOURLY_CRON_TIMETABLE, id="cron"), pytest.param(HOURLY_DELTA_TIMETABLE, id="delta")],
+    [
+        pytest.param(HOURLY_CRON_TIMETABLE, id="cron"),
+        pytest.param(HOURLY_TIMEDELTA_TIMETABLE, id="timedelta"),
+        pytest.param(HOURLY_RELATIVEDELTA_TIMETABLE, id="relativedelta"),
+    ],
 )
 @pytest.mark.parametrize(
     "last_automated_data_interval",
@@ -62,7 +69,11 @@ def test_no_catchup_next_info_starts_at_current_time(
 
 @pytest.mark.parametrize(
     "timetable",
-    [pytest.param(HOURLY_CRON_TIMETABLE, id="cron"), pytest.param(HOURLY_DELTA_TIMETABLE, id="delta")],
+    [
+        pytest.param(HOURLY_CRON_TIMETABLE, id="cron"),
+        pytest.param(HOURLY_TIMEDELTA_TIMETABLE, id="timedelta"),
+        pytest.param(HOURLY_RELATIVEDELTA_TIMETABLE, id="relativedelta"),
+    ],
 )
 def test_catchup_next_info_starts_at_previous_interval_end(timetable: Timetable) -> None:
     """If ``catchup=True``, the next interval starts at the previous's end."""
@@ -72,3 +83,52 @@ def test_catchup_next_info_starts_at_previous_interval_end(timetable: Timetable)
     )
     expected_end = PREV_DATA_INTERVAL_END + datetime.timedelta(hours=1)
     assert next_info == DagRunInfo.interval(start=PREV_DATA_INTERVAL_END, end=expected_end)
+
+
+@pytest.mark.parametrize(
+    "timetable",
+    [
+        pytest.param(HOURLY_CRON_TIMETABLE, id="cron"),
+        pytest.param(HOURLY_TIMEDELTA_TIMETABLE, id="timedelta"),
+        pytest.param(HOURLY_RELATIVEDELTA_TIMETABLE, id="relativedelta"),
+    ],
+)
+def test_validate_success(timetable: Timetable) -> None:
+    timetable.validate()
+
+
+@pytest.mark.parametrize(
+    "timetable, error_message",
+    [
+        pytest.param(
+            CronDataIntervalTimetable("0 0 1 13 0", TIMEZONE),
+            "[0 0 1 13 0] is not acceptable, out of range",
+            id="invalid-cron",
+        ),
+        pytest.param(
+            DeltaDataIntervalTimetable(datetime.timedelta()),
+            "schedule interval must be positive, not datetime.timedelta(0)",
+            id="zero-timedelta",
+        ),
+        pytest.param(
+            DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta()),
+            "schedule interval must be positive, not relativedelta()",
+            id="zero-relativedelta",
+        ),
+        pytest.param(
+            DeltaDataIntervalTimetable(datetime.timedelta(days=-1)),
+            # Dynamically formatted since different Python versions display timedelta differently.
+            f"schedule interval must be positive, not {datetime.timedelta(days=-1)!r}",
+            id="negative-timedelta",
+        ),
+        pytest.param(
+            DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta(days=-1)),
+            "schedule interval must be positive, not relativedelta(days=-1)",
+            id="negative-relativedelta",
+        ),
+    ],
+)
+def test_validate_failure(timetable: Timetable, error_message: str) -> None:
+    with pytest.raises(AirflowTimetableInvalid) as ctx:
+        timetable.validate()
+    assert str(ctx.value) == error_message