Repository: ManiMozaffar/aioclock

Issue:
add `py.typed` marker in order to be compatible with PEP 561
Thanks for your great, well-written library.

Currently for people like me who have their Pylance's type checking mode set to strict, there is warning which says:

<img width="752" alt="image" src="https://github.com/ManiMozaffar/aioclock/assets/114881632/c1733080-8ec0-4cf0-87fb-a7a097d56568">

By adding a file named `py.typed` in the root of the project the problem will be gone:

From [PEP 561](https://peps.python.org/pep-0561/#packaging-type-information):

> Package maintainers who wish to support type checking of their code MUST add a marker file named `py.typed` to their package supporting typing.

I also think that this should be added in pyproject.toml file:

```
[tool.setuptools.package-data]
"aioclock" = ["py.typed"]
```

---

Also two tiny typos:

1. in line:

https://github.com/ManiMozaffar/aioclock/blob/main/aioclock/app.py#L229

it should be `task` actually, instead of `group`.

2. In the red note of the page https://manimozaffar.github.io/aioclock/latest/api/external_api/:

> But this is a bit tricky and implicit because then your code gets ignored and **"database"** is preferred over the **"database"**

the second one should be codebase I guess.

Discussion:

Patch:
diff --git a/aioclock/api.py b/aioclock/api.py
index 2b8413c..6582a78 100644
--- a/aioclock/api.py
+++ b/aioclock/api.py
@@ -7,7 +7,7 @@ Other tools and extension are written from this tool.
 !!! danger "Note when writing to aioclock API and changing its state."
     Right now the state of AioClock instance is on the memory level, so if you write an API and change a task's trigger time, it will not persist.
     In future we might store the state of AioClock instance in a database, so that it always remains same.
-    But this is a bit tricky and implicit because then your code gets ignored and database is preferred over the database.
+    But this is a bit tricky and implicit because then your code gets ignored and database is preferred over the codebase.
     For now you may consider it as a way to change something without redeploying the application, but it is not very recommended to write.
 """
 
diff --git a/aioclock/app.py b/aioclock/app.py
index db12aef..527621a 100644
--- a/aioclock/app.py
+++ b/aioclock/app.py
@@ -226,7 +226,7 @@ class AioClock:
             )
 
             await asyncio.gather(
-                *(group.run() for group in self._get_tasks()), return_exceptions=False
+                *(task.run() for task in self._get_tasks()), return_exceptions=False
             )
         finally:
             shutdown_tasks = self._get_shutdown_task()
diff --git a/aioclock/py.typed b/aioclock/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/aioclock/triggers.py b/aioclock/triggers.py
index 51df76a..e566bc2 100644
--- a/aioclock/triggers.py
+++ b/aioclock/triggers.py
@@ -20,13 +20,37 @@ from typing import Annotated, Generic, Literal, TypeVar, Union
 import zoneinfo
 from annotated_types import Interval
 from croniter import croniter
+from dateutil.relativedelta import relativedelta
 from pydantic import BaseModel, Field, PositiveInt, model_validator
 
-from aioclock.custom_types import EveryT, PositiveNumber, Triggers
+from aioclock.custom_types import PositiveNumber, Triggers
 
 TriggerTypeT = TypeVar("TriggerTypeT")
 
 
+WEEKDAY_MAPPER: dict[
+    Literal[
+        "every monday",
+        "every tuesday",
+        "every wednesday",
+        "every thursday",
+        "every friday",
+        "every saturday",
+        "every sunday",
+        "every day",
+    ],
+    int,
+] = {
+    "every monday": 0,
+    "every tuesday": 1,
+    "every wednesday": 2,
+    "every thursday": 3,
+    "every friday": 4,
+    "every saturday": 5,
+    "every sunday": 6,
+}
+
+
 class BaseTrigger(BaseModel, ABC, Generic[TriggerTypeT]):
     """
     Base class for all triggers.
@@ -414,44 +438,24 @@ class At(LoopController[Literal[Triggers.AT]]):
 
         return self
 
-    def _shift_to_week(self, target_time: datetime, tz_aware_now: datetime):
-        target_weekday: dict[EveryT, Union[int, None]] = {
-            "every monday": 0,
-            "every tuesday": 1,
-            "every wednesday": 2,
-            "every thursday": 3,
-            "every friday": 4,
-            "every saturday": 5,
-            "every sunday": 6,
-            "every day": None,
-        }[self.at]
-
-        if target_weekday is None:
-            if target_time < tz_aware_now:
-                target_time += timedelta(days=1)
-            return target_time
-
-        days_ahead = target_weekday - tz_aware_now.weekday()  # type: ignore
-        if days_ahead <= 0:
-            days_ahead += 7
-
+    def _shift_to_declared_weekday(self, target_time: datetime, tz_aware_now: datetime):
         if self.at == "every day":
-            target_time += timedelta(days=(1 if target_time < tz_aware_now else 0))
+            if tz_aware_now > target_time:  # if the time is already passed, then shift to next day
+                target_time += timedelta(days=1)
             return target_time
 
-        # 1 second error
-        error_margin = WEEK_TO_SECOND - 1
-        if days_ahead == 7 and target_time.timestamp() - tz_aware_now.timestamp() < error_margin:
-            # date is today, and event is about to be triggered today. so no need to shift to 7 days.
-            return target_time
+        target_weekday: int = WEEKDAY_MAPPER[self.at]
+        if tz_aware_now > target_time:  # if the time is already passed, then shift to next week
+            return target_time + relativedelta(weeks=1)
 
+        days_ahead = abs(target_weekday - tz_aware_now.weekday())
         return target_time + timedelta(days_ahead)
 
     def _get_next_ts(self, now: datetime) -> float:
         target_time = deepcopy(now).replace(
             hour=self.hour, minute=self.minute, second=self.second, microsecond=0
         )
-        target_time = self._shift_to_week(target_time, now)
+        target_time = self._shift_to_declared_weekday(target_time, now)
         return (target_time - now).total_seconds()
 
     async def get_waiting_time_till_next_trigger(self, now: Union[datetime, None] = None):
diff --git a/pyproject.toml b/pyproject.toml
index afd2e19..acef075 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,6 +55,8 @@ allow-direct-references = true
 [tool.hatch.build.targets.wheel]
 packages = ["aioclock"]
 
+[tool.setuptools.package-data]
+"aioclock" = ["py.typed"]
 
 [tool.ruff]
 


Test patch:
diff --git a/tests/test_triggers.py b/tests/test_triggers.py
index db70daa..7dddf48 100644
--- a/tests/test_triggers.py
+++ b/tests/test_triggers.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timedelta
 
 import pytest
 import zoneinfo
@@ -41,33 +41,43 @@ def test_at_trigger():
 
     # test every day
     trigger = At(at="every day", hour=14, second=59, tz="Europe/Istanbul")
-    val = trigger._get_next_ts(
-        datetime(
-            year=2024,
-            month=3,
-            day=31,
-            hour=14,
-            minute=0,
-            second=0,
-            tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"),
-        )
+    this_sunday = datetime(
+        year=2024,
+        month=3,
+        day=31,
+        hour=14,
+        minute=0,
+        second=0,
+        tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"),
     )
+    val = trigger._get_next_ts(this_sunday)
     assert val == 59
 
     # test next week
     trigger = At(at="every saturday", hour=14, second=0, tz="Europe/Istanbul")
-    val = trigger._get_next_ts(
-        datetime(
-            year=2024,
-            month=3,
-            day=31,
-            hour=14,
-            minute=0,
-            second=0,
-            tzinfo=zoneinfo.ZoneInfo("Europe/Istanbul"),
+    val = trigger._get_next_ts(this_sunday)
+    assert val == 86400
+
+    # right NOW
+    trigger = At(at="every sunday", hour=14, tz="Europe/Istanbul")
+    val = trigger._get_next_ts(this_sunday)
+    assert val == 0
+
+    # next week but 1h before than now
+    trigger = At(at="every sunday", hour=13, tz="Europe/Istanbul")
+    val = trigger._get_next_ts(this_sunday)
+    assert val == timedelta(days=7).total_seconds() - timedelta(hours=1).total_seconds()
+
+
+def test_cross_timezone():
+    # `NOW` is same day as the trigger, but trigger should be next week with different timezone
+    trigger = At(hour=3, tz="Europe/Berlin", at="every saturday")
+    assert (
+        trigger._get_next_ts(
+            datetime.fromtimestamp(1720296232, tz=zoneinfo.ZoneInfo("Europe/Istanbul"))
         )
+        == 532568
     )
-    assert val == 518400
 
 
 @pytest.mark.asyncio