CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""Minimal 5-field cron expression support for Schedule Task.
|
|
|
|
``minute hour day-of-month month day-of-week`` with ``*``, lists (``1,15``),
|
|
ranges (``8-18``) and steps (``*/15``, ``8-18/2``). Day-of-week: 0 or 7 =
|
|
Sunday. Standard cron OR-semantics between day-of-month and day-of-week when
|
|
both are restricted. No external dependency, minute granularity — plenty for a
|
|
desktop scheduler that ticks every 30s.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Set
|
|
|
|
_SEARCH_DAYS = 366 * 2 # give up after two years (an expression that never fires)
|
|
|
|
|
|
class CronError(ValueError):
|
|
"""Biểu thức cron sai cú pháp."""
|
|
pass
|
|
|
|
|
|
def _parse_field(spec: str, lo: int, hi: int) -> Set[int]:
|
|
"""Đọc một trường cron thành tập giá trị: hỗ trợ ``*``, danh sách ``a,b``,
|
|
khoảng ``a-b`` và bước ``*/n``.
|
|
"""
|
|
values: Set[int] = set()
|
|
for part in spec.split(","):
|
|
part = part.strip()
|
|
step = 1
|
|
if "/" in part:
|
|
part, step_str = part.split("/", 1)
|
|
try:
|
|
step = int(step_str)
|
|
except ValueError as exc:
|
|
raise CronError(f"Bad step in cron field: {spec!r}") from exc
|
|
if step < 1:
|
|
raise CronError(f"Step must be >= 1 in: {spec!r}")
|
|
if part in ("*", ""):
|
|
start, end = lo, hi
|
|
elif "-" in part:
|
|
a, b = part.split("-", 1)
|
|
try:
|
|
start, end = int(a), int(b)
|
|
except ValueError as exc:
|
|
raise CronError(f"Bad range in cron field: {spec!r}") from exc
|
|
else:
|
|
try:
|
|
start = end = int(part)
|
|
except ValueError as exc:
|
|
raise CronError(f"Bad value in cron field: {spec!r}") from exc
|
|
if start > end or start < lo or end > hi + (1 if hi == 6 else 0):
|
|
# dow allows 7 (=Sunday), normalized below
|
|
raise CronError(f"Out-of-range cron field: {spec!r}")
|
|
for v in range(start, end + 1, step):
|
|
values.add(0 if (hi == 6 and v == 7) else v)
|
|
if not values:
|
|
raise CronError(f"Empty cron field: {spec!r}")
|
|
return values
|
|
|
|
|
|
class Cron:
|
|
"""Biểu thức cron 5 trường (phút, giờ, ngày, tháng, thứ)."""
|
|
def __init__(self, expression: str):
|
|
"""Phân tích một biểu thức cron 5 trường.
|
|
|
|
Sai số trường là ném ``CronError`` ngay tại đây chứ không đợi tới lúc chạy:
|
|
lịch sai giờ khó phát hiện hơn nhiều so với một lỗi lúc nhập.
|
|
"""
|
|
fields = (expression or "").split()
|
|
if len(fields) != 5:
|
|
raise CronError("Cron expression needs exactly 5 fields: "
|
|
"minute hour day-of-month month day-of-week")
|
|
self.minutes = _parse_field(fields[0], 0, 59)
|
|
self.hours = _parse_field(fields[1], 0, 23)
|
|
self.dom = _parse_field(fields[2], 1, 31)
|
|
self.months = _parse_field(fields[3], 1, 12)
|
|
self.dow = _parse_field(fields[4], 0, 6)
|
|
self._dom_star = fields[2].strip() == "*"
|
|
self._dow_star = fields[4].strip() == "*"
|
|
|
|
def _day_matches(self, dt: datetime) -> bool:
|
|
"""Ngày này có khớp biểu thức không.
|
|
|
|
Theo chuẩn cron: khi cả trường NGÀY và trường THỨ đều được đặt cụ thể thì
|
|
khớp một trong hai là đủ (OR), chứ không phải cả hai (AND).
|
|
"""
|
|
if dt.month not in self.months:
|
|
return False
|
|
cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0 → cron Sun=0
|
|
dom_ok = dt.day in self.dom
|
|
dow_ok = cron_dow in self.dow
|
|
if self._dom_star and self._dow_star:
|
|
return True
|
|
if self._dom_star:
|
|
return dow_ok
|
|
if self._dow_star:
|
|
return dom_ok
|
|
return dom_ok or dow_ok # both restricted → standard OR
|
|
|
|
def next_after(self, after: datetime) -> Optional[datetime]:
|
|
"""The first matching time strictly after ``after`` (or None if the
|
|
expression never fires within two years)."""
|
|
hours = sorted(self.hours)
|
|
minutes = sorted(self.minutes)
|
|
day = after.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
for offset in range(_SEARCH_DAYS):
|
|
probe_day = day + timedelta(days=offset)
|
|
if not self._day_matches(probe_day):
|
|
continue
|
|
for h in hours:
|
|
for m in minutes:
|
|
candidate = probe_day.replace(hour=h, minute=m)
|
|
if candidate > after:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def validate(expression: str) -> Optional[str]:
|
|
"""None if the expression parses, else a human error message."""
|
|
try:
|
|
Cron(expression)
|
|
return None
|
|
except CronError as exc:
|
|
return str(exc)
|