diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76ae56c..cacbf8e 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 570a1c9..2aa79f4 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 17e1f44..ce3472f 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 3292972..96adb4c 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 bbd6765..3053e61 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 9963998..d66b8ea 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 d1606e3..4b08dc0 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 0000000..ad81ae3
--- /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 381f0c7..dd3f4d3 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" },