Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 6d30f4678a test: guard DST cron ticker test on optional croniter engine
The subdirs CI shard installs praisonaiagents[knowledge] + wrapper extras
but not the optional croniter engine. The wall-clock DST assertion in
test_schedule_ticker_uses_timezone_across_dst then degrades to a coarse
interval (is_due -> False), producing `assert False is True`.

Guard the cron-dependent DST test with pytest.importorskip("croniter"),
matching the core test's own guard, without weakening the assertion.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-13 12:45:37 +00:00
praisonai-triage-agent[bot]andMervin Praison b595f04f62 fix: fail fast on invalid scheduler timezone and accept cron in validator
- validate scheduler.timezone in config_store (raise ValueError) so a bad
  default no longer silently disables cron jobs
- validate cron ScheduleTicker timezone at construction in shared.py before
  fallback handlers can hide the resolver error
- accept cron: values in validate_schedule_config, rejecting empty expressions
- scope croniter importorskip to DST tests only
- add coverage for invalid-tz fail-fast, cron config validation, and ticker

Co-authored-by: Mervin Praison <[email protected]>
2026-08-13 12:41:35 +00:00
5 changed files with 74 additions and 7 deletions

No files matched your search

@@ -340,9 +340,16 @@ class ConfigYamlScheduleStore:
self._default_timezone = None
scheduler_config = data.get("scheduler", {})
if isinstance(scheduler_config, dict):
self._default_timezone = (
configured_tz = (
scheduler_config.get("timezone") or scheduler_config.get("tz")
)
if configured_tz:
# Fail fast on a bad ``scheduler.timezone`` rather than let a
# cron job silently never fire when ``next_fire_time`` later
# raises the resolver's ValueError as a generic cron error.
from .due import resolve_schedule_timezone
resolve_schedule_timezone(configured_tz)
self._default_timezone = configured_tz
schedules = data.get("schedules", {})
if isinstance(schedules, dict):
for job_id, job_data in schedules.items():
@@ -350,6 +357,10 @@ class ConfigYamlScheduleStore:
job_data.setdefault("id", job_id)
job = ScheduleJob.from_dict(job_data)
self._jobs[job.id] = job
except ValueError:
# Surface an invalid ``scheduler.timezone`` fail-fast instead of
# degrading to a scheduler that starts but silently never fires.
raise
except Exception as e:
logger.warning("Failed to load schedules from %s: %s", self._path, e)
@@ -24,10 +24,8 @@ def _job(schedule, *, created_at=0.0, last_run_at=None):
)
pytest.importorskip("croniter")
def test_cron_keeps_local_hour_across_spring_dst_transition():
pytest.importorskip("croniter")
next_run = next_fire_time(
"0 8 * * *",
_epoch(2026, 3, 7, 13),
@@ -38,6 +36,7 @@ def test_cron_keeps_local_hour_across_spring_dst_transition():
def test_cron_keeps_local_hour_across_fall_dst_transition():
pytest.importorskip("croniter")
next_run = next_fire_time(
"0 8 * * *",
_epoch(2026, 10, 31, 12),
@@ -137,3 +136,18 @@ def test_config_store_uses_instance_default_timezone(tmp_path):
assert store.claim_due(_epoch(2026, 7, 1, 12, 59), "worker") == []
claimed = store.claim_due(_epoch(2026, 7, 1, 13, 0), "worker")
assert [item.id for item in claimed] == ["brief"]
def test_config_store_rejects_invalid_default_timezone(tmp_path):
import yaml
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
config = tmp_path / "config.yaml"
config.write_text(
yaml.safe_dump({"scheduler": {"timezone": "Mars/Base"}}),
encoding="utf-8",
)
with pytest.raises(ValueError, match="Unknown IANA timezone"):
ConfigYamlScheduleStore(str(config))
@@ -64,6 +64,15 @@ class ScheduleTicker:
# falls between this anchor and "now" is treated as missed → caught up.
self.created_at: float = time.time()
self._is_cron = self.schedule_expr.lower().startswith("cron:")
# Fail fast on a bad timezone for a cron ticker rather than let the
# later ``next_fire_time`` resolver error be swallowed by the fallback
# handlers (which would report a start yet never honour wall-clock time).
if self._is_cron and self.timezone:
try:
from praisonaiagents.scheduler.due import resolve_schedule_timezone
resolve_schedule_timezone(self.timezone)
except ImportError:
pass
# For plain intervals we reuse the existing integer-interval parse so
# behaviour is byte-for-byte identical to the fixed-interval loop.
self._interval: Optional[int] = None
@@ -198,13 +198,20 @@ def validate_schedule_config(schedule_config: Dict[str, Any]) -> None:
# Validate interval format
interval = schedule_config['interval']
valid_intervals = ['hourly', 'daily']
if interval not in valid_intervals:
# ``schedule.cron`` is normalised to a ``cron:<expr>`` interval upstream, so
# accept that form here and only reject an empty expression.
if isinstance(interval, str) and interval.lower().startswith('cron:'):
cron_expr = interval[len('cron:'):].strip()
if not cron_expr:
raise ValueError("Invalid cron expression: expression is empty")
elif interval not in valid_intervals:
# Check if it's a custom format (*/Nm, */Nh, */Ns, or plain number)
if not (interval.startswith('*/') or interval.isdigit()):
raise ValueError(
f"Invalid interval format: {interval}. "
f"Use 'hourly', 'daily', '*/30m', '*/6h', or seconds as number"
f"Use 'hourly', 'daily', '*/30m', '*/6h', "
f"'cron:M H * * *', or seconds as number"
)
# Validate max_retries
@@ -2,6 +2,8 @@
from datetime import datetime, timezone
import pytest
def _epoch(year, month, day, hour, minute=0):
return datetime(
@@ -10,6 +12,10 @@ def _epoch(year, month, day, hour, minute=0):
def test_schedule_ticker_uses_timezone_across_dst():
# Wall-clock cron timing needs the optional ``croniter`` engine; without it
# the ticker degrades to a coarse interval (is_due → False), so guard this
# DST assertion on the engine being installed rather than the wiring path.
pytest.importorskip("croniter")
from praisonai.scheduler.shared import ScheduleTicker
ticker = ScheduleTicker(
@@ -70,3 +76,23 @@ def test_schedule_add_cli_passes_timezone(monkeypatch):
assert result.exit_code == 0, result.stdout
assert captured["tz"] == "America/New_York"
def test_validate_schedule_config_accepts_cron():
import pytest
from praisonai.scheduler.yaml_loader import validate_schedule_config
validate_schedule_config({"interval": "cron:0 8 * * *"})
with pytest.raises(ValueError, match="empty"):
validate_schedule_config({"interval": "cron:"})
def test_schedule_ticker_rejects_invalid_timezone():
import pytest
from praisonai.scheduler.shared import ScheduleTicker
with pytest.raises(ValueError, match="Unknown IANA timezone"):
ScheduleTicker("cron:0 8 * * *", timezone="Mars/Base")