Files
cowork-local/core/cron.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## 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>
2026-08-31 05:15:13 +00:00

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)