Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.43-brightgreen" alt="Version: 26.06.43"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.44-brightgreen" alt="Version: 26.06.44"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.43"
__version__ = "26.06.44"
19 changes: 14 additions & 5 deletions src/pyfly/scheduling/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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()
6 changes: 5 additions & 1 deletion src/pyfly/scheduling/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/pyfly/scheduling/task_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions tests/scheduling/test_cron_timezone.py
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading