From 4e53c77ac63a1aff9cad7ecef8edcb10b957ce27 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 07:31:37 +0800 Subject: [PATCH] Add RFC 5545 recurrence-rule expansion The scheduler's cron is interval-style 5-field only and cannot express calendar rules like every 2nd Tuesday or the last weekday of the month. Add an RRULE parser and occurrence expander supporting FREQ/INTERVAL/ COUNT/UNTIL/BYDAY (with ordinals)/BYMONTHDAY/BYMONTH/BYSETPOS/WKST, with an injectable clock so next_occurrence is deterministic. Wired through the facade, AC_rrule_occurrences and AC_rrule_next executor commands, MCP tools and the Script Builder. --- README.md | 7 + README/README_zh-CN.md | 7 + README/README_zh-TW.md | 7 + .../Eng/doc/new_features/v66_features_doc.rst | 50 +++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v66_features_doc.rst | 44 +++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 23 ++ .../utils/executor/action_executor.py | 23 ++ .../utils/mcp_server/tools/_factories.py | 31 +- .../utils/mcp_server/tools/_handlers.py | 17 + je_auto_control/utils/recurrence/__init__.py | 6 + .../utils/recurrence/recurrence.py | 318 ++++++++++++++++++ .../headless/test_recurrence_batch.py | 118 +++++++ 15 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v66_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v66_features_doc.rst create mode 100644 je_auto_control/utils/recurrence/__init__.py create mode 100644 je_auto_control/utils/recurrence/recurrence.py create mode 100644 test/unit_test/headless/test_recurrence_batch.py diff --git a/README.md b/README.md index a9f23454..90dca585 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — Calendar Recurrence Rules (RRULE)](#whats-new-2026-06-21--calendar-recurrence-rules-rrule) - [What's new (2026-06-21) — Statistics & A/B Significance](#whats-new-2026-06-21--statistics--ab-significance) - [What's new (2026-06-21) — Full-Text Search (BM25)](#whats-new-2026-06-21--full-text-search-bm25) - [What's new (2026-06-21) — JSON Pointer, Patch & Merge Patch](#whats-new-2026-06-21--json-pointer-patch--merge-patch) @@ -118,6 +119,12 @@ --- +## What's new (2026-06-21) — Calendar Recurrence Rules (RRULE) + +Schedule "every 2nd Tuesday". Full reference: [`docs/source/Eng/doc/new_features/v66_features_doc.rst`](docs/source/Eng/doc/new_features/v66_features_doc.rst). + +- **`parse_rrule` / `occurrences` / `next_occurrence`** (`AC_rrule_occurrences`, `AC_rrule_next`): the scheduler's cron is 5-field interval-only — it can't express "every 2nd Tuesday", "the last weekday of the month", or "every weekday for 10 occurrences". This adds an RFC 5545 (iCalendar) RRULE parser + occurrence expander supporting `FREQ`/`INTERVAL`/`COUNT`/`UNTIL`/`BYDAY` (with ordinals like `2MO`/`-1FR`)/`BYMONTHDAY`/`BYMONTH`/`BYSETPOS`/`WKST`. Pure-stdlib `datetime`+`calendar`, injectable clock for deterministic `next_occurrence`. + ## What's new (2026-06-21) — Statistics & A/B Significance Decide whether a difference is real. Full reference: [`docs/source/Eng/doc/new_features/v65_features_doc.rst`](docs/source/Eng/doc/new_features/v65_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f9fd3cbf..f5a7bc83 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — 日历周期规则(RRULE)](#本次更新-2026-06-21--日历周期规则rrule) - [本次更新 (2026-06-21) — 统计与 A/B 显著性](#本次更新-2026-06-21--统计与-ab-显著性) - [本次更新 (2026-06-21) — 全文搜索(BM25)](#本次更新-2026-06-21--全文搜索bm25) - [本次更新 (2026-06-21) — JSON Pointer、Patch 与 Merge Patch](#本次更新-2026-06-21--json-pointerpatch-与-merge-patch) @@ -117,6 +118,12 @@ --- +## 本次更新 (2026-06-21) — 日历周期规则(RRULE) + +排程「每月第 2 个星期二」。完整参考:[`docs/source/Zh/doc/new_features/v66_features_doc.rst`](../docs/source/Zh/doc/new_features/v66_features_doc.rst)。 + +- **`parse_rrule` / `occurrences` / `next_occurrence`**(`AC_rrule_occurrences`、`AC_rrule_next`):排程器的 cron 只是 5 字段间隔式 —— 无法表达「每月第 2 个星期二」、「每月最后一个工作日」或「连续 10 次的每个工作日」。本功能补上 RFC 5545(iCalendar)RRULE 解析器 + 发生时刻展开器,支持 `FREQ`/`INTERVAL`/`COUNT`/`UNTIL`/`BYDAY`(含序数如 `2MO`/`-1FR`)/`BYMONTHDAY`/`BYMONTH`/`BYSETPOS`/`WKST`。纯标准库 `datetime`+`calendar`,时钟可注入使 `next_occurrence` 确定。 + ## 本次更新 (2026-06-21) — 统计与 A/B 显著性 判断差异是否为真。完整参考:[`docs/source/Zh/doc/new_features/v65_features_doc.rst`](../docs/source/Zh/doc/new_features/v65_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 707753fe..06bf9d46 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — 行事曆週期規則(RRULE)](#本次更新-2026-06-21--行事曆週期規則rrule) - [本次更新 (2026-06-21) — 統計與 A/B 顯著性](#本次更新-2026-06-21--統計與-ab-顯著性) - [本次更新 (2026-06-21) — 全文搜尋(BM25)](#本次更新-2026-06-21--全文搜尋bm25) - [本次更新 (2026-06-21) — JSON Pointer、Patch 與 Merge Patch](#本次更新-2026-06-21--json-pointerpatch-與-merge-patch) @@ -117,6 +118,12 @@ --- +## 本次更新 (2026-06-21) — 行事曆週期規則(RRULE) + +排程「每月第 2 個星期二」。完整參考:[`docs/source/Zh/doc/new_features/v66_features_doc.rst`](../docs/source/Zh/doc/new_features/v66_features_doc.rst)。 + +- **`parse_rrule` / `occurrences` / `next_occurrence`**(`AC_rrule_occurrences`、`AC_rrule_next`):排程器的 cron 只是 5 欄位間隔式 —— 無法表達「每月第 2 個星期二」、「每月最後一個工作日」或「連續 10 次的每個工作日」。本功能補上 RFC 5545(iCalendar)RRULE 解析器 + 發生時刻展開器,支援 `FREQ`/`INTERVAL`/`COUNT`/`UNTIL`/`BYDAY`(含序數如 `2MO`/`-1FR`)/`BYMONTHDAY`/`BYMONTH`/`BYSETPOS`/`WKST`。純標準函式庫 `datetime`+`calendar`,時鐘可注入使 `next_occurrence` 具決定性。 + ## 本次更新 (2026-06-21) — 統計與 A/B 顯著性 判斷差異是否為真。完整參考:[`docs/source/Zh/doc/new_features/v65_features_doc.rst`](../docs/source/Zh/doc/new_features/v65_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v66_features_doc.rst b/docs/source/Eng/doc/new_features/v66_features_doc.rst new file mode 100644 index 00000000..9b77a45a --- /dev/null +++ b/docs/source/Eng/doc/new_features/v66_features_doc.rst @@ -0,0 +1,50 @@ +Calendar Recurrence Rules (RRULE) +================================= + +The scheduler's cron is interval-style 5-field only — it cannot express "every +2nd Tuesday", "the last weekday of the month", or "every weekday for 10 +occurrences". This adds an RFC 5545 (iCalendar) **RRULE** parser and occurrence +expander, the calendar layer above cron. + +Supported rule parts: ``FREQ`` (DAILY/WEEKLY/MONTHLY/YEARLY), ``INTERVAL``, +``COUNT``, ``UNTIL``, ``BYDAY`` (incl. ordinals like ``2MO`` / ``-1FR``), +``BYMONTHDAY`` (incl. negatives), ``BYMONTH``, ``BYSETPOS`` and ``WKST``. +Time-level parts and BYWEEKNO/BYYEARDAY are out of scope. Pure standard library +(``datetime`` + ``calendar``); the clock is injectable so ``next_occurrence`` is +deterministic. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + import datetime + from je_auto_control import parse_rrule, occurrences, next_occurrence + + rule = parse_rrule("FREQ=MONTHLY;BYDAY=2TU") # every 2nd Tuesday + start = datetime.datetime(2026, 1, 1, 9, 0) + + for moment in occurrences(rule, start, count=3): + print(moment) # 2026-01-13 09:00, 2026-02-10 09:00, ... + + # "last weekday of the month" + last = parse_rrule("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1") + + # next fire at or after a given time (inject now for determinism) + nxt = next_occurrence(rule, start, now=datetime.datetime(2026, 3, 15)) + +``parse_rrule`` accepts the rule with or without the ``RRULE:`` prefix and +returns a frozen ``Recurrence``. ``occurrences`` yields datetimes anchored at +``dtstart`` (its time-of-day and timezone are applied to every occurrence), +bounded by ``COUNT`` / ``UNTIL`` (or the ``count=`` / ``until=`` overrides) and +a safety cap. A date-only ``UNTIL`` bounds the whole day inclusively. +``next_occurrence`` returns the first occurrence at or after ``now``. + +Executor commands +----------------- + +``AC_rrule_occurrences`` takes ``rule`` and an ISO ``dtstart`` (plus optional +``count``) and returns ``{occurrences}`` as ISO datetimes. ``AC_rrule_next`` +takes ``rule`` / ``dtstart`` / optional ``now`` and returns ``{next}``. Both are +exposed as MCP tools (``ac_rrule_occurrences`` / ``ac_rrule_next``) and as +Script Builder commands under **Flow**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index aedf32bf..887c6887 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -88,6 +88,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v63_features_doc doc/new_features/v64_features_doc doc/new_features/v65_features_doc + doc/new_features/v66_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v66_features_doc.rst b/docs/source/Zh/doc/new_features/v66_features_doc.rst new file mode 100644 index 00000000..442c3037 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v66_features_doc.rst @@ -0,0 +1,44 @@ +行事曆週期規則(RRULE) +===================== + +排程器的 cron 只是間隔式的 5 欄位 —— 無法表達「每月第 2 個星期二」、「每月最後一個工作日」或 +「連續 10 次的每個工作日」。本功能補上 RFC 5545(iCalendar)**RRULE** 解析器與發生時刻展開器, +即 cron 之上的行事曆層。 + +支援的規則部分:``FREQ``(DAILY/WEEKLY/MONTHLY/YEARLY)、``INTERVAL``、``COUNT``、``UNTIL``、 +``BYDAY``(含序數如 ``2MO`` / ``-1FR``)、``BYMONTHDAY``(含負數)、``BYMONTH``、``BYSETPOS`` +與 ``WKST``。時間層級部分以及 BYWEEKNO/BYYEARDAY 不在範圍內。純標準函式庫(``datetime`` + +``calendar``);時鐘可注入,因此 ``next_occurrence`` 具決定性。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + import datetime + from je_auto_control import parse_rrule, occurrences, next_occurrence + + rule = parse_rrule("FREQ=MONTHLY;BYDAY=2TU") # 每月第 2 個星期二 + start = datetime.datetime(2026, 1, 1, 9, 0) + + for moment in occurrences(rule, start, count=3): + print(moment) # 2026-01-13 09:00、2026-02-10 09:00、... + + # 「每月最後一個工作日」 + last = parse_rrule("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1") + + # 在指定時間當下或之後的下一次(注入 now 以取得決定性) + nxt = next_occurrence(rule, start, now=datetime.datetime(2026, 3, 15)) + +``parse_rrule`` 接受帶或不帶 ``RRULE:`` 前綴的規則,回傳凍結的 ``Recurrence``。``occurrences`` +產生以 ``dtstart`` 為錨點的 datetime(其時刻與時區會套用到每一次發生),受 ``COUNT`` / ``UNTIL`` +(或 ``count=`` / ``until=`` 覆寫)及安全上限約束。僅含日期的 ``UNTIL`` 會包含整天。 +``next_occurrence`` 回傳在 ``now`` 當下或之後的第一次發生。 + +執行器命令 +---------- + +``AC_rrule_occurrences`` 接受 ``rule`` 與 ISO ``dtstart``(及選用的 ``count``),回傳 +``{occurrences}`` 為 ISO datetime。``AC_rrule_next`` 接受 ``rule`` / ``dtstart`` / 選用的 +``now``,回傳 ``{next}``。兩者皆以 MCP 工具(``ac_rrule_occurrences`` / ``ac_rrule_next``)以及 +Script Builder 中 **Flow** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 9ad0e0cb..cb2de039 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -88,6 +88,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v63_features_doc doc/new_features/v64_features_doc doc/new_features/v65_features_doc + doc/new_features/v66_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 96548c3a..11eb757b 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -338,6 +338,10 @@ chi_square_2x2, cohens_d, describe, normal_cdf, percentile, two_proportion_z_test, welch_t_test, ) +# RFC 5545 recurrence-rule expansion (calendar scheduling above cron) +from je_auto_control.utils.recurrence import ( + Recurrence, next_occurrence, occurrences, parse_rrule, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -823,6 +827,7 @@ def start_autocontrol_gui(*args, **kwargs): "SearchHit", "SearchIndex", "search_documents", "tokenize", "chi_square_2x2", "cohens_d", "describe", "normal_cdf", "percentile", "two_proportion_z_test", "welch_t_test", + "Recurrence", "next_occurrence", "occurrences", "parse_rrule", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index ed26dcca..c0c92ec6 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1155,6 +1155,29 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Validate JSON against a JSON Schema; returns {ok, errors}.", )) + specs.append(CommandSpec( + "AC_rrule_occurrences", "Flow", "Recurrence: Expand (RRULE)", + fields=( + FieldSpec("rule", FieldType.STRING, + placeholder="FREQ=MONTHLY;BYDAY=2TU"), + FieldSpec("dtstart", FieldType.STRING, + placeholder="2026-01-01T09:00:00"), + FieldSpec("count", FieldType.INT, optional=True, default=10), + ), + description="Expand an RFC 5545 RRULE into ISO datetimes.", + )) + specs.append(CommandSpec( + "AC_rrule_next", "Flow", "Recurrence: Next Occurrence", + fields=( + FieldSpec("rule", FieldType.STRING, + placeholder="FREQ=WEEKLY;BYDAY=MO,WE,FR"), + FieldSpec("dtstart", FieldType.STRING, + placeholder="2026-01-01T09:00:00"), + FieldSpec("now", FieldType.STRING, optional=True, + placeholder="2026-03-15T00:00:00"), + ), + description="Next RRULE occurrence at/after now; returns {next}.", + )) specs.append(CommandSpec( "AC_describe_stats", "Data", "Describe Statistics", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 05d76e31..52153a71 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,27 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _rrule_occurrences(rule: str, dtstart: str, + count: int = 10) -> Dict[str, Any]: + """Adapter: expand an RRULE from an ISO dtstart into ISO datetimes.""" + import datetime as _dt + from je_auto_control.utils.recurrence import occurrences, parse_rrule + start = _dt.datetime.fromisoformat(dtstart) + moments = occurrences(parse_rrule(rule), start, count=int(count)) + return {"occurrences": [moment.isoformat() for moment in moments]} + + +def _rrule_next(rule: str, dtstart: str, + now: Optional[str] = None) -> Dict[str, Any]: + """Adapter: next RRULE occurrence at/after now (ISO in, ISO out).""" + import datetime as _dt + from je_auto_control.utils.recurrence import next_occurrence, parse_rrule + start = _dt.datetime.fromisoformat(dtstart) + when = _dt.datetime.fromisoformat(now) if now else None + moment = next_occurrence(parse_rrule(rule), start, now=when) + return {"next": moment.isoformat() if moment else None} + + def _describe_stats(values: Any) -> Dict[str, Any]: """Adapter: summary statistics + percentiles of a numeric list (or JSON).""" import json @@ -3811,6 +3832,8 @@ def __init__(self): "AC_search_documents": _search_documents, "AC_describe_stats": _describe_stats, "AC_ab_significance": _ab_significance, + "AC_rrule_occurrences": _rrule_occurrences, + "AC_rrule_next": _rrule_next, "AC_resolve_pointer": _resolve_pointer, "AC_apply_json_patch": _apply_json_patch, "AC_make_json_patch": _make_json_patch, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 7d15b38b..c1629936 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,35 @@ def rate_limit_tools() -> List[MCPTool]: ] +def recurrence_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_rrule_occurrences", + description=("Expand an RFC 5545 'rule' (RRULE) from an ISO " + "'dtstart' into the next 'count' ISO datetimes. " + "Returns {occurrences}."), + input_schema=schema( + {"rule": {"type": "string"}, "dtstart": {"type": "string"}, + "count": {"type": "integer"}}, + ["rule", "dtstart"]), + handler=h.rrule_occurrences, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_rrule_next", + description=("Next occurrence of an RRULE 'rule' at/after 'now' " + "(ISO; defaults to current time), anchored at ISO " + "'dtstart'. Returns {next}."), + input_schema=schema( + {"rule": {"type": "string"}, "dtstart": {"type": "string"}, + "now": {"type": "string"}}, + ["rule", "dtstart"]), + handler=h.rrule_next, + annotations=READ_ONLY, + ), + ] + + def stats_tools() -> List[MCPTool]: return [ MCPTool( @@ -4627,7 +4656,7 @@ def media_assert_tools() -> List[MCPTool]: process_mining_tools, asset_tools, events_tools, notify_channel_tools, jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, - search_index_tools, stats_tools, + search_index_tools, stats_tools, recurrence_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 3dff74cd..10aeb4c1 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1647,6 +1647,23 @@ def ab_significance(a_conv, a_n, b_conv, b_n): return two_proportion_z_test(int(a_conv), int(a_n), int(b_conv), int(b_n)) +def rrule_occurrences(rule, dtstart, count=10): + import datetime as _dt + from je_auto_control.utils.recurrence import occurrences, parse_rrule + start = _dt.datetime.fromisoformat(dtstart) + moments = occurrences(parse_rrule(rule), start, count=int(count)) + return {"occurrences": [moment.isoformat() for moment in moments]} + + +def rrule_next(rule, dtstart, now=None): + import datetime as _dt + from je_auto_control.utils.recurrence import next_occurrence, parse_rrule + start = _dt.datetime.fromisoformat(dtstart) + when = _dt.datetime.fromisoformat(now) if now else None + moment = next_occurrence(parse_rrule(rule), start, now=when) + return {"next": moment.isoformat() if moment else None} + + def run_saga(steps): from je_auto_control.utils.saga import run_saga as _run result = _run(steps) diff --git a/je_auto_control/utils/recurrence/__init__.py b/je_auto_control/utils/recurrence/__init__.py new file mode 100644 index 00000000..d56ac441 --- /dev/null +++ b/je_auto_control/utils/recurrence/__init__.py @@ -0,0 +1,6 @@ +"""RFC 5545 recurrence-rule parsing and occurrence expansion.""" +from je_auto_control.utils.recurrence.recurrence import ( + Recurrence, next_occurrence, occurrences, parse_rrule, +) + +__all__ = ["Recurrence", "next_occurrence", "occurrences", "parse_rrule"] diff --git a/je_auto_control/utils/recurrence/recurrence.py b/je_auto_control/utils/recurrence/recurrence.py new file mode 100644 index 00000000..280c10ab --- /dev/null +++ b/je_auto_control/utils/recurrence/recurrence.py @@ -0,0 +1,318 @@ +"""Expand RFC 5545 (iCalendar) recurrence rules into concrete datetimes. + +The scheduler's cron is interval-style 5-field only — it cannot express +"every 2nd Tuesday", "the last weekday of the month", or "every weekday for 10 +occurrences". This adds an RRULE parser and occurrence expander for the common +subset above cron. + +Supported rule parts: ``FREQ`` (DAILY/WEEKLY/MONTHLY/YEARLY), ``INTERVAL``, +``COUNT``, ``UNTIL``, ``BYDAY`` (incl. ordinals like ``2MO`` / ``-1FR``), +``BYMONTHDAY`` (incl. negatives), ``BYMONTH``, ``BYSETPOS`` and ``WKST``. +Time-level parts (BYHOUR/BYMINUTE/BYSECOND) and BYWEEKNO/BYYEARDAY are out of +scope. The clock is injectable so ``next_occurrence`` is deterministic. + +Pure standard library (``datetime`` + ``calendar``); imports no ``PySide6``. +""" +import datetime as _dt +from calendar import monthrange +from dataclasses import dataclass +from typing import Iterator, List, Optional, Tuple + +from je_auto_control.utils.exception.exceptions import AutoControlException + +_WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6} +_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} + +ByDay = Tuple[Optional[int], int] + + +@dataclass(frozen=True) +class Recurrence: # pylint: disable=too-many-instance-attributes + """A parsed RFC 5545 recurrence rule (supported subset). + + RFC 5545 RRULE has many independent parts; they are kept as flat fields to + mirror the specification. + """ + + freq: str + interval: int = 1 + count: Optional[int] = None + until: Optional[_dt.datetime] = None + by_day: Tuple[ByDay, ...] = () + by_month_day: Tuple[int, ...] = () + by_month: Tuple[int, ...] = () + by_set_pos: Tuple[int, ...] = () + wkst: int = 0 + + +# --- parsing --------------------------------------------------------------- + +def _parse_ints(value: str) -> Tuple[int, ...]: + return tuple(int(token) for token in value.split(",") if token.strip()) + + +def _parse_byday(value: str) -> Tuple[ByDay, ...]: + result: List[ByDay] = [] + for token in (t.strip().upper() for t in value.split(",") if t.strip()): + weekday = token[-2:] + if weekday not in _WEEKDAYS: + raise AutoControlException(f"invalid BYDAY token {token!r}") + prefix = token[:-2] + result.append((int(prefix) if prefix else None, _WEEKDAYS[weekday])) + return tuple(result) + + +def _parse_until(value: str) -> _dt.datetime: + text = value.strip() + is_utc = text.endswith("Z") + bare = text[:-1] if is_utc else text + if "T" in bare: + parsed = _dt.datetime.strptime(bare, "%Y%m%dT%H%M%S") + else: + # A date-only UNTIL bounds the whole day inclusively. + parsed = _dt.datetime.strptime(bare, "%Y%m%d").replace( + hour=23, minute=59, second=59) + return parsed.replace(tzinfo=_dt.timezone.utc) if is_utc else parsed + + +def parse_rrule(text: str) -> Recurrence: + """Parse an RRULE string (with or without the ``RRULE:`` prefix).""" + body = text.strip() + if body.upper().startswith("RRULE:"): + body = body[6:] + parts = {} + for token in body.split(";"): + if "=" in token: + key, value = token.split("=", 1) + parts[key.strip().upper()] = value.strip() + freq = parts.get("FREQ", "").upper() + if freq not in _FREQS: + raise AutoControlException(f"unsupported or missing FREQ {freq!r}") + return Recurrence( + freq=freq, + interval=int(parts.get("INTERVAL", "1")), + count=int(parts["COUNT"]) if "COUNT" in parts else None, + until=_parse_until(parts["UNTIL"]) if "UNTIL" in parts else None, + by_day=_parse_byday(parts.get("BYDAY", "")), + by_month_day=_parse_ints(parts.get("BYMONTHDAY", "")), + by_month=_parse_ints(parts.get("BYMONTH", "")), + by_set_pos=_parse_ints(parts.get("BYSETPOS", "")), + wkst=_WEEKDAYS.get(parts.get("WKST", "MO").upper(), 0), + ) + + +# --- candidate selection --------------------------------------------------- + +def _apply_time(day: _dt.date, dtstart: _dt.datetime) -> _dt.datetime: + return _dt.datetime(day.year, day.month, day.day, dtstart.hour, + dtstart.minute, dtstart.second, tzinfo=dtstart.tzinfo) + + +def _month_dates(year: int, month: int) -> List[_dt.date]: + return [_dt.date(year, month, d) + for d in range(1, monthrange(year, month)[1] + 1)] + + +def _monthday_ok(day: _dt.date, by_month_day: Tuple[int, ...]) -> bool: + total = monthrange(day.year, day.month)[1] + for value in by_month_day: + if value > 0 and day.day == value: + return True + if value < 0 and day.day == total + value + 1: + return True + return False + + +def _byday_ok(day: _dt.date, by_day: Tuple[ByDay, ...]) -> bool: + total = monthrange(day.year, day.month)[1] + pos = (day.day - 1) // 7 + 1 + neg = -((total - day.day) // 7 + 1) + for ordinal, weekday in by_day: + if day.weekday() == weekday and ordinal in (None, pos, neg): + return True + return False + + +def _setpos(items: List[_dt.date], by_set_pos: Tuple[int, ...]) -> List[_dt.date]: + if not by_set_pos: + return items + picked = [] + for pos in by_set_pos: + index = pos - 1 if pos > 0 else len(items) + pos + if 0 <= index < len(items): + picked.append(items[index]) + return sorted(set(picked)) + + +def _in_month_match(day: _dt.date, rule: Recurrence, has_md: bool, + has_day: bool) -> bool: + if has_md and has_day: + return (_monthday_ok(day, rule.by_month_day) + and _byday_ok(day, rule.by_day)) + if has_md: + return _monthday_ok(day, rule.by_month_day) + return _byday_ok(day, rule.by_day) + + +def _select_in_month(year: int, month: int, rule: Recurrence) -> List[_dt.date]: + has_md, has_day = bool(rule.by_month_day), bool(rule.by_day) + return [day for day in _month_dates(year, month) + if _in_month_match(day, rule, has_md, has_day)] + + +def _monthly_dates(year: int, month: int, dtstart: _dt.datetime, + rule: Recurrence) -> List[_dt.date]: + if rule.by_month and month not in rule.by_month: + return [] + if rule.by_month_day or rule.by_day: + chosen = _select_in_month(year, month, rule) + else: + chosen = [d for d in _month_dates(year, month) if d.day == dtstart.day] + return _setpos(sorted(set(chosen)), rule.by_set_pos) + + +def _weekday_set(dtstart: _dt.datetime, rule: Recurrence) -> set: + if rule.by_day: + return {weekday for _, weekday in rule.by_day} + return {dtstart.weekday()} + + +def _weekly_dates(week_start: _dt.date, dtstart: _dt.datetime, + rule: Recurrence) -> List[_dt.date]: + weekdays = _weekday_set(dtstart, rule) + days = [week_start + _dt.timedelta(days=offset) for offset in range(7)] + chosen = [d for d in days if d.weekday() in weekdays] + if rule.by_month: + chosen = [d for d in chosen if d.month in rule.by_month] + return _setpos(chosen, rule.by_set_pos) + + +def _daily_dates(day: _dt.date, rule: Recurrence) -> List[_dt.date]: + if rule.by_month and day.month not in rule.by_month: + return [] + if rule.by_month_day and not _monthday_ok(day, rule.by_month_day): + return [] + if rule.by_day and day.weekday() not in {wd for _, wd in rule.by_day}: + return [] + return [day] + + +def _safe_date(year: int, month: int, day: int) -> Optional[_dt.date]: + if 1 <= day <= monthrange(year, month)[1]: + return _dt.date(year, month, day) + return None + + +def _yearly_dates(year: int, dtstart: _dt.datetime, + rule: Recurrence) -> List[_dt.date]: + months = rule.by_month or (dtstart.month,) + chosen: List[_dt.date] = [] + for month in months: + if rule.by_month_day or rule.by_day: + chosen.extend(_select_in_month(year, month, rule)) + else: + day = _safe_date(year, month, dtstart.day) + if day is not None: + chosen.append(day) + return _setpos(sorted(set(chosen)), rule.by_set_pos) + + +# --- period series (unbounded; the caller applies limits) ------------------ + +def _add_months(year: int, month: int, delta: int) -> Tuple[int, int]: + index = year * 12 + (month - 1) + delta + return index // 12, index % 12 + 1 + + +def _daily_series(rule: Recurrence, + dtstart: _dt.datetime) -> Iterator[_dt.datetime]: + cursor = dtstart.date() + step = _dt.timedelta(days=rule.interval) + while True: + for day in _daily_dates(cursor, rule): + yield _apply_time(day, dtstart) + cursor += step + + +def _weekly_series(rule: Recurrence, + dtstart: _dt.datetime) -> Iterator[_dt.datetime]: + offset = (dtstart.weekday() - rule.wkst) % 7 + cursor = dtstart.date() - _dt.timedelta(days=offset) + step = _dt.timedelta(weeks=rule.interval) + while True: + for day in _weekly_dates(cursor, dtstart, rule): + yield _apply_time(day, dtstart) + cursor += step + + +def _monthly_series(rule: Recurrence, + dtstart: _dt.datetime) -> Iterator[_dt.datetime]: + year, month = dtstart.year, dtstart.month + while True: + for day in _monthly_dates(year, month, dtstart, rule): + yield _apply_time(day, dtstart) + year, month = _add_months(year, month, rule.interval) + + +def _yearly_series(rule: Recurrence, + dtstart: _dt.datetime) -> Iterator[_dt.datetime]: + year = dtstart.year + while True: + for day in _yearly_dates(year, dtstart, rule): + yield _apply_time(day, dtstart) + year += rule.interval + + +_SERIES = { + "DAILY": _daily_series, "WEEKLY": _weekly_series, + "MONTHLY": _monthly_series, "YEARLY": _yearly_series, +} + + +# --- public expansion ------------------------------------------------------ + +def _normalize_until(until: Optional[_dt.datetime], + dtstart: _dt.datetime) -> Optional[_dt.datetime]: + if until is None: + return None + aware_start = dtstart.tzinfo is not None + aware_until = until.tzinfo is not None + if aware_start and not aware_until: + return until.replace(tzinfo=dtstart.tzinfo) + if not aware_start and aware_until: + return until.replace(tzinfo=None) + return until + + +def _after_until(moment: _dt.datetime, + limit_until: Optional[_dt.datetime]) -> bool: + return limit_until is not None and moment > limit_until + + +def occurrences(rule: Recurrence, dtstart: _dt.datetime, *, + count: Optional[int] = None, until: Optional[_dt.datetime] = None, + max_iter: int = 100000) -> Iterator[_dt.datetime]: + """Yield occurrence datetimes for ``rule`` anchored at ``dtstart``.""" + limit_count = rule.count if count is None else count + limit_until = _normalize_until(rule.until if until is None else until, + dtstart) + emitted = 0 + for index, moment in enumerate(_SERIES[rule.freq](rule, dtstart)): + if index >= max_iter or _after_until(moment, limit_until): + return + if moment < dtstart: + continue + yield moment + emitted += 1 + if limit_count is not None and emitted >= limit_count: + return + + +def next_occurrence(rule: Recurrence, dtstart: _dt.datetime, *, + now: Optional[_dt.datetime] = None) -> Optional[_dt.datetime]: + """Return the first occurrence at or after ``now`` (or ``None``).""" + moment = now if now is not None else _dt.datetime.now(dtstart.tzinfo) + for occurrence in occurrences(rule, dtstart): + if occurrence >= moment: + return occurrence + return None diff --git a/test/unit_test/headless/test_recurrence_batch.py b/test/unit_test/headless/test_recurrence_batch.py new file mode 100644 index 00000000..6ddc3cb9 --- /dev/null +++ b/test/unit_test/headless/test_recurrence_batch.py @@ -0,0 +1,118 @@ +"""Headless tests for RFC 5545 recurrence expansion. Pure stdlib, no Qt.""" +import datetime as dt + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import AutoControlException +from je_auto_control.utils.recurrence import ( + Recurrence, next_occurrence, occurrences, parse_rrule) + +START = dt.datetime(2026, 1, 1, 9, 0) # a Thursday + + +def _dates(rule_text, count): + rule = parse_rrule(rule_text) + return [m.strftime("%Y-%m-%d") for m in occurrences(rule, START, count=count)] + + +def test_parse_rrule_fields(): + rule = parse_rrule("RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=2TU,-1FR;COUNT=5") + assert isinstance(rule, Recurrence) + assert rule.freq == "MONTHLY" and rule.interval == 2 and rule.count == 5 + assert rule.by_day == ((2, 1), (-1, 4)) + + +def test_invalid_freq_and_byday(): + with pytest.raises(AutoControlException): + parse_rrule("FREQ=SECONDLY") + with pytest.raises(AutoControlException): + parse_rrule("FREQ=DAILY;BYDAY=XX") + + +def test_daily_interval(): + assert _dates("FREQ=DAILY;INTERVAL=2", 3) == \ + ["2026-01-01", "2026-01-03", "2026-01-05"] + + +def test_weekly_byday(): + assert _dates("FREQ=WEEKLY;BYDAY=MO,WE,FR", 4) == \ + ["2026-01-02", "2026-01-05", "2026-01-07", "2026-01-09"] + + +def test_monthly_ordinal_weekday(): + assert _dates("FREQ=MONTHLY;BYDAY=2TU", 3) == \ + ["2026-01-13", "2026-02-10", "2026-03-10"] + + +def test_monthly_last_weekday_via_setpos(): + assert _dates("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", 3) == \ + ["2026-01-30", "2026-02-27", "2026-03-31"] + + +def test_monthly_negative_monthday(): + assert _dates("FREQ=MONTHLY;BYMONTHDAY=-1", 3) == \ + ["2026-01-31", "2026-02-28", "2026-03-31"] + + +def test_yearly_multiple_months(): + assert _dates("FREQ=YEARLY;BYMONTH=1,7;BYMONTHDAY=15", 4) == \ + ["2026-01-15", "2026-07-15", "2027-01-15", "2027-07-15"] + + +def test_until_is_inclusive_of_date(): + rule = parse_rrule("FREQ=DAILY;UNTIL=20260103") + got = [m.strftime("%Y-%m-%d") for m in occurrences(rule, START)] + assert got == ["2026-01-01", "2026-01-02", "2026-01-03"] + + +def test_count_overrides_via_param(): + rule = parse_rrule("FREQ=WEEKLY;BYDAY=MO") + assert len(list(occurrences(rule, START, count=5))) == 5 + + +def test_next_occurrence(): + rule = parse_rrule("FREQ=MONTHLY;BYDAY=1MO") # first Monday + nxt = next_occurrence(rule, START, now=dt.datetime(2026, 3, 15)) + assert nxt.strftime("%Y-%m-%d") == "2026-04-06" + + +def test_next_occurrence_none_when_exhausted(): + rule = parse_rrule("FREQ=DAILY;COUNT=2") + assert next_occurrence(rule, START, now=dt.datetime(2030, 1, 1)) is None + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_rrule_occurrences", + {"rule": "FREQ=MONTHLY;BYDAY=2TU", "dtstart": "2026-01-01T09:00:00", + "count": 2}, + ]]) + out = next(v for v in rec.values() if isinstance(v, dict))["occurrences"] + assert out[0].startswith("2026-01-13") + + rec2 = ac.execute_action([[ + "AC_rrule_next", + {"rule": "FREQ=MONTHLY;BYDAY=1MO", "dtstart": "2026-01-01T09:00:00", + "now": "2026-03-15T00:00:00"}, + ]]) + nxt = next(v for v in rec2.values() if isinstance(v, dict))["next"] + assert nxt.startswith("2026-04-06") + + +def test_wiring(): + assert {"AC_rrule_occurrences", "AC_rrule_next"} <= ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_rrule_occurrences", "ac_rrule_next"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_rrule_occurrences", "AC_rrule_next"} <= cmds + + +def test_facade_exports(): + for attr in ("Recurrence", "parse_rrule", "occurrences", "next_occurrence"): + assert hasattr(ac, attr) + assert attr in ac.__all__