Skip to content

Commit 2d4caf1

Browse files
authored
Merge pull request #2527 from AppDaemon/start-immediate
Start-immediate
2 parents 971a9d6 + 5525d74 commit 2d4caf1

File tree

6 files changed

+110
-29
lines changed

6 files changed

+110
-29
lines changed

appdaemon/adapi.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3225,7 +3225,6 @@ async def run_every(
32253225
start: str | dt.time | dt.datetime | None = None,
32263226
interval: TimeDeltaLike = 0,
32273227
*args,
3228-
immediate: bool = False,
32293228
random_start: TimeDeltaLike | None = None,
32303229
random_end: TimeDeltaLike | None = None,
32313230
pin: bool | None = None,
@@ -3242,7 +3241,9 @@ async def run_every(
32423241
intervals will be calculated forward from the start time, and the first trigger will be the first
32433242
interval in the future.
32443243
3245-
- If this is a ``str`` it will be parsed with :meth:`~appdaemon.adapi.ADAPI.parse_time()`.
3244+
- If this is ``now`` (default), then the first trigger will be now + interval
3245+
- If this is ``immediate``, then the first trigger will happen immediately
3246+
- Other ``str`` types will be parsed with :meth:`~appdaemon.adapi.ADAPI.parse_time()`.
32463247
- If this is a ``datetime.time`` object, the current date will be assumed.
32473248
- If this is a ``datetime.datetime`` object, it will be used as is.
32483249
@@ -3258,8 +3259,6 @@ async def run_every(
32583259
- If this is a ``timedelta`` object, the current date will be assumed.
32593260
32603261
*args: Arbitrary positional arguments to be provided to the callback function when it is triggered.
3261-
immediate (bool, optional): Whether to immediately fire the callback or wait until the first interval if the
3262-
start time is now.
32633262
random_start (int, optional): Start of range of the random time.
32643263
random_end (int, optional): End of range of the random time.
32653264
pin (bool, optional): Optional setting to override the default thread pinning behavior. By default, this is
@@ -3313,7 +3312,7 @@ def timed_callback(self, **kwargs): ... # example callback
33133312
33143313
"""
33153314
interval = utils.parse_timedelta(interval)
3316-
next_period = await self.AD.sched.get_next_period(interval, start, immediate=immediate)
3315+
next_period = await self.AD.sched.get_next_period(interval, start)
33173316

33183317
self.logger.debug(
33193318
"Registering %s for run_every in %s intervals, starting %s",

appdaemon/scheduler.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -483,22 +483,31 @@ async def get_next_period(
483483
self,
484484
interval: TimeDeltaLike,
485485
start: time | datetime | str | None = None,
486-
*,
487-
immediate: bool = False,
488486
) -> datetime:
487+
"""Calculate the next execution time for a periodic interval.
488+
489+
If start is "immediate", returns the current time.
490+
Otherwise, calculates a start time (defaulting to "now") and advances by the
491+
interval until a future time is reached.
492+
"""
489493
interval = utils.parse_timedelta(interval)
490494
start = "now" if start is None else start
491495

492496
# Get "now" once and use it consistently to avoid timing races
493497
now = await self.get_now()
494-
aware_start = await self.parse_datetime(start, aware=True, now=now)
495-
assert isinstance(aware_start, datetime) and aware_start.tzinfo is not None
496-
497-
# Skip forward to the next period if start is in the past
498-
while aware_start < now or (immediate and aware_start <= now):
499-
aware_start += interval
500-
501-
return aware_start
498+
match start:
499+
case "immediate":
500+
return now
501+
case "now", _:
502+
aware_next = await self.parse_datetime(start, aware=True, now=now)
503+
# Skip forward to the next period if start is in the past
504+
# This makes the result in the first
505+
while aware_next <= now:
506+
aware_next += interval
507+
508+
assert isinstance(aware_next, datetime) and aware_next.tzinfo is not None, \
509+
"aware_start must be a timezone aware datetime"
510+
return aware_next
502511

503512
async def terminate_app(self, name: str):
504513
if app_sched := self.schedule.pop(name, False):

docs/HISTORY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
- Reload modified apps on SIGUSR2 - contributed by [chatziko](https://github.com/chatziko)
1010
- Using urlib to create endpoints from URLs - contributed by [cebtenzzre](https://github.com/cebtenzzre)
1111
- Added {py:meth}`~appdaemon.plugins.hass.hassapi.Hass.process_conversation` and {py:meth}`~appdaemon.plugins.hass.hassapi.Hass.reload_conversation` to the {ref}`Hass API <hass-api-usage>`.
12-
- Added `immediate` kwargs to `run_every` to control semantics around `start == "now"`
12+
- Added special value `immediate` to {py:meth}`~appdaemon.adapi.ADAPI.run_every` semantics for the `start` kwarg. See the method docs for more information.
1313

1414
**Fixes**
1515

tests/conf/apps/scheduler_test_app.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,22 @@ class SchedulerTestAppMode(str, Enum):
77
"""Enum for different modes of the SchedulerTestApp."""
88

99
RUN_EVERY = "run_every"
10+
RUN_IN = "run_in"
1011

1112

1213
class SchedulerTestApp(ADAPI):
14+
"""
15+
A test app to verify scheduler functionality.
16+
17+
Configuration Args:
18+
mode (str, optional): The mode of operation. Defaults to 'run_every'.
19+
register_delay (float, optional): Delay before setup in seconds. Defaults to 0.5.
20+
21+
RUN_EVERY:
22+
interval (int): Interval in seconds for run_every. Required.
23+
msg (str): Message to pass to callback. Required.
24+
start (str, optional): Start time description. Defaults to "now".
25+
"""
1326
def initialize(self):
1427
self.set_log_level("DEBUG")
1528
self.log("SchedulerTestApp initialized")
@@ -20,10 +33,14 @@ def setup_callback(self, **kwargs) -> None:
2033
self.log(f"Running in {self.mode} mode")
2134
match self.mode:
2235
case SchedulerTestAppMode.RUN_EVERY:
23-
start = self.args.get("start", "now")
24-
interval = self.args["interval"]
25-
msg = self.args["msg"]
26-
self.run_every(self.run_every_callback, start=start, interval=interval, msg=msg)
36+
match self.args:
37+
case {"interval": interval, "msg": str(msg)}:
38+
start = self.args.get("start", "now")
39+
self.run_every(self.run_every_callback, start=start, interval=interval, msg=msg)
40+
return
41+
case SchedulerTestAppMode.RUN_IN:
42+
pass
43+
raise ValueError(f"Invalid arguments for {self.mode}")
2744

2845
@property
2946
def mode(self) -> SchedulerTestAppMode:

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async def ad_obj(running_loop: asyncio.BaseEventLoop, ad_cfg: AppDaemonConfig, l
2828
for cfg in ad.logging.config.values():
2929
logger_ = logging.getLogger(cfg["name"])
3030
logger_.propagate = True
31-
logger_.setLevel("DEBUG")
31+
# logger_.setLevel("DEBUG")
3232

3333
await ad.app_management._process_import_paths()
3434
ad.app_management.dependency_manager = DependencyManager(python_files=list(), config_files=list())

tests/functional/test_run_every.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import logging
22
import re
33
import uuid
4-
from datetime import timedelta
4+
from datetime import datetime, timedelta
55
from functools import partial
66
from itertools import product
7+
from typing import cast
78

89
import pytest
10+
import pytz
911
from appdaemon.types import TimeDeltaLike
1012
from appdaemon.utils import parse_timedelta
1113

@@ -29,9 +31,12 @@ async def test_run_every(
2931
n: int = 2,
3032
) -> None:
3133
interval = parse_timedelta(interval)
32-
run_time = (interval * n) + timedelta(seconds=0.01)
33-
register_delay = 0.1
34-
run_time += timedelta(seconds=register_delay) # Accounts for the delay in registering the callback
34+
35+
# Calculate base runtime for 'n' occurrences plus a small buffer to account for the delay in registering the callback
36+
register_delay = timedelta(seconds=0.2)
37+
run_time = (interval * (n + 1)) + register_delay
38+
39+
# If start time is future "now + offset", add offset to ensure coverage
3540
if (parts := re.split(r"\s+[\+]\s+", start)) and len(parts) == 2:
3641
_, offset = parts
3742
run_time += parse_timedelta(offset)
@@ -41,11 +46,62 @@ async def test_run_every(
4146
app_args = dict(start=start, interval=interval, msg=test_id, register_delay=register_delay)
4247
async with run_app_for_time(app_name, run_time=run_time.total_seconds(), **app_args) as (ad, caplog):
4348
check_interval_partial = partial(check_interval, caplog, f"kwargs: {{'msg': '{test_id}',")
49+
check_interval_partial(n, interval)
4450

45-
if start.startswith("now -"):
46-
check_interval_partial(n, interval)
47-
else:
48-
check_interval_partial(n + 1, interval)
51+
cb_count = await ad.state.get_state('test', 'admin', f'app.{app_name}', 'instancecallbacks')
52+
assert cast(int, cb_count) >= n, "Callback didn't get called enough times."
4953

5054
# diffs = utils.time_diffs(utils.filter_caplog(caplog, test_id))
5155
# logger.debug(diffs)
56+
57+
58+
@pytest.mark.asyncio(loop_scope="session")
59+
@pytest.mark.parametrize("start", ["now", "immediate"])
60+
async def test_run_every_start_time(
61+
run_app_for_time: AsyncTempTest,
62+
start: str,
63+
) -> None:
64+
interval = timedelta(seconds=0.5)
65+
run_time = timedelta(seconds=1)
66+
register_delay = timedelta(seconds=0.1)
67+
68+
match start:
69+
case "now":
70+
n = 1
71+
case "immediate":
72+
n = 2
73+
74+
app_name = "scheduler_test_app"
75+
test_id = str(uuid.uuid4())
76+
app_args = dict(start=start, interval=interval, msg=test_id, register_delay=register_delay)
77+
async with run_app_for_time(app_name, run_time=run_time.total_seconds(), **app_args) as (ad, caplog):
78+
check_interval(
79+
caplog,
80+
f"kwargs: {{'msg': '{test_id}',",
81+
n=n,
82+
interval=interval
83+
)
84+
85+
cb_count = await ad.state.get_state('test', 'admin', f'app.{app_name}', 'instancecallbacks')
86+
assert cast(int, cb_count) >= (n + 1), "Callback didn't get called enough times."
87+
88+
now = datetime.now(pytz.utc)
89+
START_TIMES = ["now", now, now.time(), now.isoformat()]
90+
91+
@pytest.mark.asyncio(loop_scope="session")
92+
@pytest.mark.parametrize("start", START_TIMES)
93+
async def test_run_every_start_time_types(
94+
run_app_for_time: AsyncTempTest,
95+
start: str,
96+
) -> None:
97+
interval = timedelta(seconds=0.25)
98+
run_time = timedelta(seconds=1)
99+
register_delay = timedelta(seconds=0.1)
100+
n = 3
101+
102+
app_name = "scheduler_test_app"
103+
test_id = str(uuid.uuid4())
104+
app_args = dict(start=start, interval=interval, msg=test_id, register_delay=register_delay)
105+
async with run_app_for_time(app_name, run_time=run_time.total_seconds(), **app_args) as (ad, caplog):
106+
cb_count = await ad.state.get_state('test', 'admin', f'app.{app_name}', 'instancecallbacks')
107+
assert cast(int, cb_count) >= (n + 1), "Callback didn't get called enough times."

0 commit comments

Comments
 (0)