blob: 596c274cf7152c06260fb30f2cb66dde857a91b1 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import datetime
import dateutil.relativedelta
import pendulum
import pytest
import time_machine
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
START_DATE = pendulum.DateTime(2021, 9, 4, tzinfo=TIMEZONE)
PREV_DATA_INTERVAL_START = START_DATE
PREV_DATA_INTERVAL_END = START_DATE + datetime.timedelta(days=1)
PREV_DATA_INTERVAL = DataInterval(start=PREV_DATA_INTERVAL_START, end=PREV_DATA_INTERVAL_END)
PREV_DATA_INTERVAL_EXACT = DataInterval.exact(PREV_DATA_INTERVAL_END)
CURRENT_TIME = pendulum.DateTime(2021, 9, 7, tzinfo=TIMEZONE)
YESTERDAY = CURRENT_TIME - datetime.timedelta(days=1)
OLD_INTERVAL = DataInterval(start=YESTERDAY, end=CURRENT_TIME)
HOURLY_CRON_TIMETABLE = CronDataIntervalTimetable("@hourly", TIMEZONE)
HOURLY_TIMEDELTA_TIMETABLE = DeltaDataIntervalTimetable(datetime.timedelta(hours=1))
HOURLY_RELATIVEDELTA_TIMETABLE = DeltaDataIntervalTimetable(dateutil.relativedelta.relativedelta(hours=1))
CRON_TIMETABLE = CronDataIntervalTimetable("30 16 * * *", TIMEZONE)
DELTA_FROM_MIDNIGHT = datetime.timedelta(minutes=30, hours=16)
@pytest.mark.parametrize(
"last_automated_data_interval",
[pytest.param(None, id="first-run"), pytest.param(PREV_DATA_INTERVAL, id="subsequent")],
)
@time_machine.travel(CURRENT_TIME)
def test_no_catchup_first_starts_at_current_time(
last_automated_data_interval: DataInterval | None,
) -> None:
"""If ``catchup=False`` and start_date is a day before"""
next_info = CRON_TIMETABLE.next_dagrun_info(
last_automated_data_interval=last_automated_data_interval,
restriction=TimeRestriction(earliest=YESTERDAY, latest=None, catchup=False),
)
expected_start = YESTERDAY + DELTA_FROM_MIDNIGHT
assert next_info == DagRunInfo.interval(start=expected_start, end=CURRENT_TIME + DELTA_FROM_MIDNIGHT)
@pytest.mark.parametrize(
"earliest",
[pytest.param(None, id="none"), pytest.param(START_DATE, id="start_date")],
)
@pytest.mark.parametrize(
"catchup",
[pytest.param(True, id="catchup_true"), pytest.param(False, id="catchup_false")],
)
@time_machine.travel(CURRENT_TIME)
def test_new_schedule_interval_next_info_starts_at_new_time(
earliest: pendulum.DateTime | None,
catchup: bool,
) -> None:
"""First run after DAG has new schedule interval."""
next_info = CRON_TIMETABLE.next_dagrun_info(
last_automated_data_interval=OLD_INTERVAL,
restriction=TimeRestriction(earliest=earliest, latest=None, catchup=catchup),
)
expected_start = YESTERDAY + datetime.timedelta(hours=16, minutes=30)
expected_end = CURRENT_TIME + datetime.timedelta(hours=16, minutes=30)
assert next_info == DagRunInfo.interval(start=expected_start, 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"),
],
)
@pytest.mark.parametrize(
"last_automated_data_interval",
[pytest.param(None, id="first-run"), pytest.param(PREV_DATA_INTERVAL, id="subsequent")],
)
@time_machine.travel(CURRENT_TIME)
def test_no_catchup_next_info_starts_at_current_time(
timetable: Timetable,
last_automated_data_interval: DataInterval | None,
) -> None:
"""If ``catchup=False``, the next data interval ends at the current time."""
next_info = timetable.next_dagrun_info(
last_automated_data_interval=last_automated_data_interval,
restriction=TimeRestriction(earliest=START_DATE, latest=None, catchup=False),
)
expected_start = CURRENT_TIME - datetime.timedelta(hours=1)
assert next_info == DagRunInfo.interval(start=expected_start, end=CURRENT_TIME)
@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_catchup_next_info_starts_at_previous_interval_end(timetable: Timetable) -> None:
"""If ``catchup=True``, the next interval starts at the previous's end."""
next_info = timetable.next_dagrun_info(
last_automated_data_interval=PREV_DATA_INTERVAL,
restriction=TimeRestriction(earliest=START_DATE, latest=None, catchup=True),
)
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
def test_cron_interval_timezone_from_string():
timetable = CronDataIntervalTimetable("@hourly", "UTC")
assert timetable.serialize()["timezone"] == "UTC"
@pytest.mark.parametrize(
"trigger_at, expected_interval",
[
# Arbitrary trigger time.
pytest.param(
pendulum.DateTime(2022, 8, 8, 1, tzinfo=TIMEZONE),
DataInterval(
pendulum.DateTime(2022, 8, 7, tzinfo=TIMEZONE),
pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
),
id="adhoc",
),
# Trigger time falls exactly on interval boundary.
pytest.param(
pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
DataInterval(
pendulum.DateTime(2022, 8, 7, tzinfo=TIMEZONE),
pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
),
id="exact",
),
],
)
def test_cron_infer_manual_data_interval_alignment(
trigger_at: pendulum.DateTime,
expected_interval: DataInterval,
) -> None:
timetable = CronDataIntervalTimetable("@daily", TIMEZONE)
assert timetable.infer_manual_data_interval(run_after=trigger_at) == expected_interval
@pytest.mark.parametrize(
"last_data_interval, expected_info",
[
pytest.param(
DataInterval(
pendulum.DateTime(2022, 8, 7, tzinfo=TIMEZONE),
pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
),
DagRunInfo.interval(
pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
pendulum.DateTime(2022, 8, 9, tzinfo=TIMEZONE),
),
id="exact",
),
pytest.param(
# Previous data interval does not align with the current timetable.
# This is possible if the user edits a DAG with existing runs.
DataInterval(
pendulum.DateTime(2022, 8, 7, 1, tzinfo=TIMEZONE),
pendulum.DateTime(2022, 8, 8, 1, tzinfo=TIMEZONE),
),
DagRunInfo.interval(
pendulum.DateTime(2022, 8, 8, tzinfo=TIMEZONE),
pendulum.DateTime(2022, 8, 9, tzinfo=TIMEZONE),
),
id="changed",
),
],
)
def test_cron_next_dagrun_info_alignment(last_data_interval: DataInterval, expected_info: DagRunInfo):
timetable = CronDataIntervalTimetable("@daily", TIMEZONE)
info = timetable.next_dagrun_info(
last_automated_data_interval=last_data_interval,
restriction=TimeRestriction(None, None, True),
)
assert info == expected_info