diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eed7728..b902619 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,8 @@ Changelog ================== - Improves os process control, fix graceful exit +- CRON tasks now has OR logic for calendar and week days +- Periodic/cron graceful cancellation 1.8.0 (2026-02-26) diff --git a/microagent/__init__.py b/microagent/__init__.py index fbd3ddc..77a01f1 100644 --- a/microagent/__init__.py +++ b/microagent/__init__.py @@ -193,11 +193,11 @@ def cron(spec: str, timeout: int | float = 1) -> abc.Callable[[PeriodicFunc], Pe .. code-block:: python - @periodic('0 */4 * * *') + @cron('0 */4 * * *') async def handler_1(self): log.info('Called handler 1') - @periodic('*/15 * * * *', timeout=10) + @cron('*/15 * * * *', timeout=10) async def handler_2(self): log.info('Called handler 2') ''' diff --git a/microagent/agent.py b/microagent/agent.py index 9f33c8a..d48f495 100644 --- a/microagent/agent.py +++ b/microagent/agent.py @@ -204,6 +204,10 @@ async def start(self, await self.run_servers(self.hook.servers) async def stop(self) -> None: + for periodic_task in self.periodic_tasks: + periodic_task.cancel() + for cron_task in self.cron_tasks: + cron_task.cancel() await self.hook.pre_stop() @staticmethod @@ -220,7 +224,7 @@ def run_periodic_tasks(self, periodic_tasks: Iterable[PeriodicTask], start_at = datetime.now(tz=timezone.utc) + timedelta(seconds=start_after) self.log.debug('Set %s at %s', task, f'{start_at:%H:%M:%S}') else: - self.log.debug('Set %s after %d sec', task, start_after) + self.log.debug('Set %s after %s sec', task, start_after) task.start(start_after) diff --git a/microagent/timer.py b/microagent/timer.py index 4edf327..c771b46 100644 --- a/microagent/timer.py +++ b/microagent/timer.py @@ -36,7 +36,7 @@ async def cron_handler(self): RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7)) DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) -MAX_DIFF = 2 * 356 * 24 * 60 * 60 +MAX_DIFF = 2 * 365 * 24 * 60 * 60 class CRON(NamedTuple): @@ -58,6 +58,8 @@ class PeriodicMixin: agent: 'MicroAgent' handler: Callable timeout: float + _timer_handle: asyncio.TimerHandle | None = None + _running_task: asyncio.Task | None = None async def call(self) -> None: try: @@ -73,7 +75,13 @@ async def call(self) -> None: self.agent.log.exception(f'Periodic Exception: {exc}') def start(self, start_after: float) -> None: - asyncio.get_running_loop().call_later(start_after, _periodic, self) # type: ignore[arg-type] + self._timer_handle = asyncio.get_running_loop().call_later(start_after, _periodic, self) # type: ignore[arg-type] + + def cancel(self) -> None: + if self._timer_handle is not None: + self._timer_handle.cancel() + if self._running_task is not None and not self._running_task.done(): + self._running_task.cancel() class PeriodicArgs(TypedDict): @@ -123,17 +131,14 @@ def start_after(self) -> float: @property def period(self) -> float: - ''' - *period* property of **CRONTask** object is a next value of - generator behind facade. Be carefully with manual manipulation with it. - ''' - self.agent.log.debug('Run %r', self) return self.cron.next().timestamp() - time.time() # next step delay def _periodic(task: PeriodicTask | CRONTask) -> asyncio.Task: - asyncio.get_running_loop().call_later(task.period, _periodic, task) - return asyncio.create_task(task.call()) + task._timer_handle = asyncio.get_running_loop().call_later(task.period, _periodic, task) + running = asyncio.create_task(task.call()) + task._running_task = running + return running def cron_parser(spec: str) -> CRON: @@ -201,7 +206,23 @@ def next_moment(cron: CRON, now: datetime) -> datetime: return next_moment(cron, now) - if now.day not in cron.days or now.weekday() not in cron.weekdays: + # calculating whether a task needs to be started on that day + days_all = set(cron.days) == set(range(1, 32)) # run every calendar day + wdays_all = set(cron.weekdays) >= set(range(7)) # run every week day + + day_ok = now.day in cron.days # run today + wday_ok = now.weekday() in cron.weekdays # run today + + if not days_all and not wdays_all: + ok = day_ok or wday_ok # POSIX: both restricted → OR + elif not days_all: + ok = day_ok + elif not wdays_all: + ok = wday_ok + else: # if run every day + ok = True + + if not ok: now += timedelta(days=1) now = datetime(year=now.year, month=now.month, day=now.day, tzinfo=timezone.utc) return next_moment(cron, now)