Skip to content

Commit f24497f

Browse files
committed
Merge branch 'datetime-testing'
1 parent 537ff4e commit f24497f

File tree

6 files changed

+155
-16
lines changed

6 files changed

+155
-16
lines changed

appdaemon/adapi.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
from pathlib import Path
1515
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
1616

17-
from appdaemon import dependency, utils
17+
from appdaemon import dependency
1818
from appdaemon import exceptions as ade
19+
from appdaemon import utils
1920
from appdaemon.appdaemon import AppDaemon
2021
from appdaemon.entity import Entity
2122
from appdaemon.events import EventCallback
@@ -2979,7 +2980,7 @@ async def run_at(
29792980
_, offset = utils.parse_time_str(start_str, now=now, location=self.AD.sched.location)
29802981
func = functools.partial(func, *args, repeat=True, offset=offset)
29812982
case _:
2982-
start = await self.AD.sched.parse_datetime(start)
2983+
start = await self.AD.sched.parse_datetime(start, aware=True)
29832984
func = functools.partial(
29842985
self.AD.sched.insert_schedule,
29852986
name=self.name,
@@ -3069,18 +3070,19 @@ async def run_daily(
30693070

30703071
now = await self.get_now() # type: ignore
30713072
_, offset = utils.parse_time_str(start_str, now=now, location=self.AD.sched.location)
3072-
func = functools.partial(func, *args, repeat=True, offset=offset)
3073+
func = functools.partial(func, callback, *args, repeat=True, offset=offset)
30733074
case _:
30743075
func = functools.partial(
30753076
self.run_every,
3076-
start=start,
3077-
interval=timedelta(days=1).total_seconds()
3077+
callback,
3078+
start,
3079+
timedelta(days=1).total_seconds(),
3080+
*args,
30783081
) # fmt: skip
30793082

3083+
# Add additional kwargs here
30803084
func = functools.partial(
30813085
func,
3082-
callback=callback,
3083-
*args,
30843086
random_start=random_start,
30853087
random_end=random_end,
30863088
pin=pin,

appdaemon/scheduler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,8 @@ async def loop(self): # noqa: C901
619619
time_to_run = timestamp <= self.now
620620
args = self.schedule.get(name, {}).get(uuid_, False)
621621
if time_to_run and args:
622-
self.logger.debug("Firing scheduled callback %s for '%s'", args["callback"].func.__name__, name)
622+
func = utils.unwrapped(args["callback"])
623+
self.logger.debug("Firing scheduled callback %s for '%s'", func.__name__, name)
623624
await self.exec_schedule(name, args, uuid_)
624625
case _:
625626
raise ValueError(f"Unknown entry format: {entry}")

appdaemon/utility_loop.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ async def _loop_iteration_context(self) -> AsyncGenerator[LoopTiming]:
265265
if self.AD.real_time and timing.timedelta("total") > self.AD.max_utility_skew:
266266
self.logger.warning(
267267
"Excessive time spent in utility loop: %s, %s in check_app_updates(), %s in other",
268-
timing.get_time_strs()
268+
*timing.get_time_strs()
269269
)
270270
if self.AD.check_app_updates_profile:
271271
self.logger.info("Profile information for Utility Loop")

appdaemon/utils.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
from logging import Logger
2323
from pathlib import Path
2424
from time import perf_counter
25-
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Literal, ParamSpec, Protocol, TypeVar
25+
from typing import (TYPE_CHECKING, Any, Callable, Coroutine, Literal,
26+
ParamSpec, Protocol, TypeVar)
2627

2728
import dateutil.parser
2829
import pytz
@@ -34,10 +35,8 @@
3435
from pydantic import BaseModel, ValidationError
3536
from pytz import BaseTzInfo
3637

37-
from appdaemon.version import (
38-
__version__, # noqa: F401
39-
__version_comments__, # noqa: F401
40-
)
38+
from appdaemon.version import __version__ # noqa: F401
39+
from appdaemon.version import __version_comments__ # noqa: F401
4140

4241
from . import exceptions as ade
4342

@@ -609,6 +608,7 @@ def parse_datetime(
609608
pass
610609
case time():
611610
result = datetime.combine(now.date(), result)
611+
result += timedelta(days=days_offset)
612612
case _:
613613
raise TypeError(f"Unsupported result type: {result}")
614614

@@ -638,6 +638,16 @@ def parse_datetime(
638638
return result
639639

640640

641+
def parse_offset(input_: str) -> timedelta:
642+
if m := re.search(r"\s+(?P<sign>[+-])\s+", input_):
643+
offset = parse_timedelta(input_[m.span()[1]:])
644+
match m.group("sign"):
645+
case "-":
646+
offset *= -1
647+
case "+":
648+
pass
649+
return offset
650+
641651
def now_is_between(
642652
now: datetime,
643653
start_time: str | time | datetime,

tests/unit/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
from functools import partial
66

77
import pytest
8-
from appdaemon import utils
98
from astral import LocationInfo
109
from astral.location import Location
1110
from pytz import BaseTzInfo, timezone
1211

12+
from appdaemon import utils
13+
1314

1415
@pytest.fixture
1516
def tz(location: Location) -> BaseTzInfo:

tests/unit/datetime/test_parse_datetime.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,140 @@
1+
import itertools
2+
import re
13
from datetime import date, datetime, timedelta
24
from functools import partial
5+
from typing import Literal
36

47
import pytest
5-
from appdaemon import utils
68
from astral import SunDirection
79
from astral.location import Location
810
from pytz import BaseTzInfo
911

12+
from appdaemon import utils
13+
1014
pytestmark = [
1115
pytest.mark.ci,
1216
pytest.mark.unit,
1317
]
1418

19+
hour_params = {
20+
"input_": [f"{i:02}:00" for i in range(0, 24, 6)],
21+
"aware": [True, False],
22+
"today": [True, False]
23+
}
24+
hour_params = (tuple(hour_params.keys()), list(itertools.product(*hour_params.values())))
25+
26+
27+
offsets = [str(utils.parse_timedelta(t)) for t in [1, timedelta(minutes=30), timedelta(hours=1)]]
28+
src = {
29+
"str_offset": offsets,
30+
"sign": [True, False],
31+
"today": [True, False],
32+
"aware": [True, False],
33+
}
34+
sunrise_params = list(itertools.product(*src.values()))
35+
sunrise_params = (
36+
(f"sunrise {'+' if sign else '-'} {offset}", today, aware)
37+
for offset, sign, today, aware in sunrise_params
38+
)
39+
sunrise_params = (("input_", "today", "aware"), list(sunrise_params))
40+
41+
offsets = [timedelta(seconds=1), timedelta(minutes=1), timedelta(hours=1.5)]
42+
offsets = [td.total_seconds() for td in offsets]
43+
offsets = [(n * s) for n, s in itertools.product(offsets, [1, -1])]
44+
offsets = sorted(offsets)
45+
46+
sun_params = itertools.product(["sunrise", "sunset"], offsets)
47+
sun_params = {
48+
"now_str": ["early", "midday", "late"],
49+
"when": ["today", "next"],
50+
"input_": [f'{type_} {'+' if str_offset > 0 else '-'} {utils.parse_timedelta(abs(str_offset))}' for type_, str_offset in sun_params],
51+
}
52+
53+
class TestParseDatetime:
54+
@pytest.mark.parametrize(*hour_params)
55+
def test_parse_hour(
56+
self,
57+
input_: str,
58+
aware: bool,
59+
today: bool,
60+
default_now: datetime,
61+
parser: partial[datetime],
62+
) -> None:
63+
result = parser(input_, aware=aware, today=today)
64+
if default_now.time() > result.time():
65+
if not today:
66+
assert result.date() == (default_now + timedelta(days=1)).date()
67+
return
68+
69+
assert result.date() == default_now.date()
70+
71+
@pytest.mark.parametrize(tuple(sun_params.keys()), itertools.product(*sun_params.values()))
72+
def test_parse_sun_offsets(
73+
self,
74+
now_str: str,
75+
input_: str,
76+
when: Literal["today", "next"],
77+
default_now: datetime,
78+
location: Location,
79+
parser: partial[datetime],
80+
) -> None:
81+
today_sunrise = location.sunrise(date=default_now.date(), local=True)
82+
assert today_sunrise.isoformat() == "2025-06-20T05:25:07.925165-04:00"
83+
84+
tomorrow_sunrise = location.sunrise(date=(default_now + timedelta(days=1)).date(), local=True)
85+
assert tomorrow_sunrise.isoformat() == "2025-06-21T05:25:20.585440-04:00"
86+
87+
today_sunset = location.sunset(date=default_now.date(), local=True)
88+
assert today_sunset.isoformat() == "2025-06-20T20:30:19.662056-04:00"
89+
90+
tomorrow_sunset = location.sunset(date=(default_now + timedelta(days=1)).date(), local=True)
91+
assert tomorrow_sunset.isoformat() == "2025-06-21T20:30:31.933561-04:00"
92+
93+
match now_str:
94+
case "early":
95+
now = default_now.replace(hour=3)
96+
case "midday":
97+
now = default_now.replace(hour=12)
98+
case "late":
99+
now = default_now.replace(hour=23)
100+
101+
parser.keywords["now"] = now
102+
103+
if when == "today":
104+
parser.keywords["today"] = True
105+
106+
result = parser(input_, location=location, aware=True)
107+
assert result.tzinfo is not None
108+
109+
type_ = input_.split()[0]
110+
offset = utils.parse_offset(input_)
111+
112+
match now_str, when, type_:
113+
case (_, "today", "sunrise"):
114+
assert result == (today_sunrise + offset)
115+
case (_, "today", "sunset"):
116+
assert result == (today_sunset + offset)
117+
118+
case ("early", _, "sunrise"):
119+
assert result == (today_sunrise + offset)
120+
case ("midday" | "late", _, "sunrise"):
121+
assert result == (tomorrow_sunrise + offset)
122+
123+
case ("early" | "midday", _, "sunset"):
124+
assert result == (today_sunset + offset)
125+
case ("late", "next", "sunset"):
126+
assert result == (tomorrow_sunset + offset)
127+
128+
case _:
129+
# This makes sure all the cases get handled.
130+
assert False
131+
132+
match when:
133+
case "today":
134+
assert result.date() == now.date()
135+
case "next":
136+
assert result > now
137+
15138

16139
def test_time_parse(default_now: datetime, parser: partial[datetime]) -> None:
17140
test_time = default_now.replace(hour=20)
@@ -210,3 +333,5 @@ def test_exact_sun_event(default_date: date, location: Location, tz: BaseTzInfo)
210333
today_sunset = location.sunset(date=default_date, local=True)
211334
next_sunset = parser("sunset", now=today_sunset)
212335
assert next_sunset.date() != default_date, "Next sunset should be tomorrow"
336+
assert next_sunset.date() != default_date, "Next sunset should be tomorrow"
337+
assert next_sunset.date() != default_date, "Next sunset should be tomorrow"

0 commit comments

Comments
 (0)