From c649bd55506d7b4eeb4e263cf2474fa3bffb14da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 14:03:36 +0200 Subject: [PATCH] feat(scheduling): @scheduled(zone=...) time-zone-aware cron + bump v26.06.44 @scheduled(cron=, zone='America/New_York') evaluates cron in the given IANA time zone (Spring @Scheduled(zone=)); defaults to UTC. CronExpression gained a zone arg + computes fire times via a zone-aware _now(); the task scheduler threads zone -> CronExpression. Tests: tests/scheduling/test_cron_timezone.py (4). Gates: mypy --strict (619), ruff + format, full suite 3833 passed. --- CHANGELOG.md | 8 ++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/scheduling/cron.py | 19 ++++++--- src/pyfly/scheduling/decorators.py | 6 ++- src/pyfly/scheduling/task_scheduler.py | 10 +++-- tests/scheduling/test_cron_timezone.py | 55 ++++++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 tests/scheduling/test_cron_timezone.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ae56cf..cacbf8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.44 (2026-06-07) + +### Added (scheduling — @scheduled time zones) + +- **`@scheduled(cron=..., zone="America/New_York")`** — cron expressions are now evaluated + in the given IANA time zone (Spring's `@Scheduled(zone=...)`); defaults to UTC. + `CronExpression` gained a `zone` argument and computes fire times in that zone. + ## v26.06.43 (2026-06-07) ### Added (resilience — tuning) diff --git a/README.md b/README.md index 570a1c9b..2aa79f47 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.43 + Version: 26.06.44 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 17e1f44b..ce3472f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.43" +version = "26.6.44" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 32929725..96adb4cf 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.43" +__version__ = "26.06.44" diff --git a/src/pyfly/scheduling/cron.py b/src/pyfly/scheduling/cron.py index bbd6765d..3053e613 100644 --- a/src/pyfly/scheduling/cron.py +++ b/src/pyfly/scheduling/cron.py @@ -18,6 +18,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any, cast +from zoneinfo import ZoneInfo from croniter import croniter # type: ignore[import-untyped] @@ -27,45 +28,53 @@ class CronExpression: """Wraps a cron expression string for next-fire-time calculations.""" expression: str # 5-field "min hour dom month dow" or Spring 6-field "sec min hour dom month dow" + zone: str | None = None # IANA time zone (e.g. "America/New_York"); None = UTC _normalized: str = field(init=False, default="", repr=False, compare=False) _seconds_first: bool = field(init=False, default=False, repr=False, compare=False) + _tz: Any = field(init=False, default=None, repr=False, compare=False) def __post_init__(self) -> None: """Validate and normalize the cron expression. Accepts Spring-style 6-field (seconds-first) cron and the ``?`` day - placeholder, not just standard 5-field cron (audit #185). + placeholder, not just standard 5-field cron (audit #185). When *zone* is set, + fire times are computed in that IANA time zone (Spring ``@Scheduled(zone=...)``). """ normalized = self.expression.replace("?", "*").strip() six_field = len(normalized.split()) == 6 object.__setattr__(self, "_normalized", normalized) object.__setattr__(self, "_seconds_first", six_field) + object.__setattr__(self, "_tz", ZoneInfo(self.zone) if self.zone else None) try: croniter(normalized, datetime.now(UTC), second_at_beginning=six_field) except (ValueError, KeyError) as exc: raise ValueError(f"Invalid cron expression: {self.expression}") from exc + def _now(self) -> datetime: + """Current time in the configured zone (UTC when no zone is set).""" + return datetime.now(self._tz) if self._tz is not None else datetime.now(UTC) + def _cron(self, base: datetime) -> Any: return croniter(self._normalized, base, second_at_beginning=self._seconds_first) def next_fire_time(self, after: datetime | None = None) -> datetime: """Return the next fire time after the given datetime (default: now).""" - base = after or datetime.now(UTC) + base = after or self._now() return cast(datetime, self._cron(base).get_next(datetime)) def previous_fire_time(self, before: datetime | None = None) -> datetime: """Return the previous fire time before the given datetime.""" - base = before or datetime.now(UTC) + base = before or self._now() return cast(datetime, self._cron(base).get_prev(datetime)) def next_n_fire_times(self, n: int, after: datetime | None = None) -> list[datetime]: """Return the next N fire times.""" - base = after or datetime.now(UTC) + base = after or self._now() cron = self._cron(base) return [cron.get_next(datetime) for _ in range(n)] def seconds_until_next(self, after: datetime | None = None) -> float: """Return seconds until the next fire time.""" - now = after or datetime.now(UTC) + now = after or self._now() next_time = self.next_fire_time(now) return (next_time - now).total_seconds() diff --git a/src/pyfly/scheduling/decorators.py b/src/pyfly/scheduling/decorators.py index 9963998b..d66b8eae 100644 --- a/src/pyfly/scheduling/decorators.py +++ b/src/pyfly/scheduling/decorators.py @@ -26,15 +26,18 @@ def scheduled( fixed_rate: timedelta | None = None, fixed_delay: timedelta | None = None, initial_delay: timedelta | None = None, + zone: str | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Mark a method to be scheduled for periodic execution. Exactly one of cron, fixed_rate, or fixed_delay must be provided. - - cron: 5-field cron expression (e.g., "0 0 * * *" for midnight) + - cron: 5- or 6-field cron expression (e.g., "0 0 * * *" for midnight) - fixed_rate: Run at fixed intervals regardless of execution time - fixed_delay: Wait delay after previous run completes - initial_delay: Optional delay before first execution + - zone: IANA time zone for ``cron`` evaluation (e.g. "America/New_York"); + defaults to UTC. Spring's ``@Scheduled(zone=...)``. """ triggers = sum(x is not None for x in (cron, fixed_rate, fixed_delay)) if triggers != 1: @@ -46,6 +49,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: func.__pyfly_scheduled_fixed_rate__ = fixed_rate # type: ignore[attr-defined] func.__pyfly_scheduled_fixed_delay__ = fixed_delay # type: ignore[attr-defined] func.__pyfly_scheduled_initial_delay__ = initial_delay # type: ignore[attr-defined] + func.__pyfly_scheduled_zone__ = zone # type: ignore[attr-defined] return func return decorator diff --git a/src/pyfly/scheduling/task_scheduler.py b/src/pyfly/scheduling/task_scheduler.py index d1606e3a..4b08dc01 100644 --- a/src/pyfly/scheduling/task_scheduler.py +++ b/src/pyfly/scheduling/task_scheduler.py @@ -41,6 +41,7 @@ class _ScheduledEntry: fixed_rate: timedelta | None = None fixed_delay: timedelta | None = None initial_delay: timedelta | None = None + zone: str | None = None class TaskScheduler: @@ -94,6 +95,7 @@ def discover(self, beans: list[Any]) -> int: fixed_rate=getattr(attr, "__pyfly_scheduled_fixed_rate__", None), fixed_delay=getattr(attr, "__pyfly_scheduled_fixed_delay__", None), initial_delay=getattr(attr, "__pyfly_scheduled_initial_delay__", None), + zone=getattr(attr, "__pyfly_scheduled_zone__", None), ) self._entries.append(entry) count += 1 @@ -109,7 +111,7 @@ async def start(self) -> None: self._running = True for entry in self._entries: if entry.cron is not None: - task = asyncio.create_task(self._run_cron_loop(entry.bean, entry.method, entry.cron)) + task = asyncio.create_task(self._run_cron_loop(entry.bean, entry.method, entry.cron, entry.zone)) elif entry.fixed_rate is not None: task = asyncio.create_task( self._run_fixed_rate_loop(entry.bean, entry.method, entry.fixed_rate, entry.initial_delay) @@ -152,9 +154,11 @@ def _loop_done_callback(task: asyncio.Task[Any]) -> None: # Private loop methods # ------------------------------------------------------------------ - async def _run_cron_loop(self, bean: Any, method: Callable[..., Any], cron_expr: str) -> None: + async def _run_cron_loop( + self, bean: Any, method: Callable[..., Any], cron_expr: str, zone: str | None = None + ) -> None: """Loop: sleep until next cron fire time, execute method, repeat.""" - cron = CronExpression(cron_expr) + cron = CronExpression(cron_expr, zone=zone) while self._running: delay = cron.seconds_until_next() await asyncio.sleep(delay) diff --git a/tests/scheduling/test_cron_timezone.py b/tests/scheduling/test_cron_timezone.py new file mode 100644 index 00000000..ad81ae3b --- /dev/null +++ b/tests/scheduling/test_cron_timezone.py @@ -0,0 +1,55 @@ +# Copyright 2026 Firefly Software Foundation. +# +# Licensed 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. +"""@scheduled(zone=...) — time-zone-aware cron (v26.06.44).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from zoneinfo import ZoneInfo + +from pyfly.scheduling.cron import CronExpression +from pyfly.scheduling.decorators import scheduled + + +def test_cron_without_zone_is_utc() -> None: + cron = CronExpression("0 0 * * *") # daily midnight + after = datetime(2026, 6, 7, 15, 0, tzinfo=UTC) + nxt = cron.next_fire_time(after) + assert nxt.hour == 0 and nxt.minute == 0 # UTC midnight + + +def test_cron_zone_fires_at_zone_midnight() -> None: + ny = ZoneInfo("America/New_York") + cron = CronExpression("0 0 * * *", zone="America/New_York") + after = datetime(2026, 6, 7, 15, 0, tzinfo=ny) # 3pm in New York + nxt = cron.next_fire_time(after) + assert nxt.hour == 0 and nxt.minute == 0 # midnight in New York + assert str(nxt.tzinfo) == "America/New_York" + + +def test_zone_changes_the_utc_instant() -> None: + # The same "midnight" cron resolves to different UTC instants per zone. + utc_next = CronExpression("0 0 * * *").next_fire_time(datetime(2026, 6, 7, 15, 0, tzinfo=UTC)) + ny_next = CronExpression("0 0 * * *", zone="America/New_York").next_fire_time( + datetime(2026, 6, 7, 15, 0, tzinfo=ZoneInfo("America/New_York")) + ) + assert utc_next.astimezone(UTC) != ny_next.astimezone(UTC) + + +def test_scheduled_decorator_records_zone() -> None: + @scheduled(cron="0 0 * * *", zone="America/New_York") + def nightly_job() -> None: + pass + + assert nightly_job.__pyfly_scheduled_zone__ == "America/New_York" # type: ignore[attr-defined] diff --git a/uv.lock b/uv.lock index 381f0c7e..dd3f4d3d 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.43" +version = "26.6.44" source = { editable = "." } dependencies = [ { name = "pydantic" },