-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_recurrence.py
More file actions
259 lines (215 loc) · 8.36 KB
/
Copy path_recurrence.py
File metadata and controls
259 lines (215 loc) · 8.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
"""Recurrence pattern parsing using Recurrent and Pendulum."""
from __future__ import annotations
import re
from typing import cast
import pendulum
import recurrent # type: ignore[import-untyped]
from dateutil.rrule import rrulestr
from pendulum import DateTime
def next_occurrence(pattern: str, base_date: str) -> tuple[str, str | None]:
"""Calculate next occurrence from recurrence pattern.
Strategy:
1. Try simple/taskmark patterns first (2weeks, 15th, etc.)
2. Try recurrent for natural language (every 2 weeks, second tuesday of month)
3. Return warning if unrecognized
Args:
pattern: Recurrence pattern (e.g., "weekly", "2nd-tuesday", "2weeks")
base_date: Base date as ISO string (YYYY-MM-DD)
Returns:
Tuple of (next_date, warning):
- next_date: Next occurrence as ISO string
- warning: None if successful, message if pattern unrecognized
Note:
Follows taskmark's warning pattern - returns warning message
instead of raising, allowing caller to accumulate warnings.
"""
pattern_lower = pattern.lower().strip()
base = cast(DateTime, pendulum.parse(base_date)).date()
# Try simple/taskmark patterns first
# This handles: daily, weekly, monthly, yearly, 2weeks, 3days, 15th, 2nd-tuesday, etc.
result = _parse_simple_pattern(pattern_lower, base)
if result:
return result.to_date_string(), None
# Try Recurrent for natural language patterns
# This handles: "every 2 weeks", "second tuesday of every month", etc.
try:
parsed = recurrent.parse(pattern_lower)
if parsed is not None and isinstance(parsed, str):
# Got RRULE string - use dateutil to calculate next
# rrulestr requires datetime, not Date, so convert
base_dt = pendulum.datetime(base.year, base.month, base.day)
rule = rrulestr(parsed, dtstart=base_dt)
next_dt = rule.after(base_dt)
if next_dt:
return next_dt.date().isoformat(), None
except Exception: # nosec B110 - intentional fallthrough to warning
# Recurrent parsing failed - fall through to return warning below
pass
# Unknown pattern - return base unchanged with warning
return base_date, f"unrecognized recurrence pattern: {pattern}"
# Weekday name to pendulum weekday constant
WEEKDAYS = {
"monday": pendulum.MONDAY,
"tuesday": pendulum.TUESDAY,
"wednesday": pendulum.WEDNESDAY,
"thursday": pendulum.THURSDAY,
"friday": pendulum.FRIDAY,
"saturday": pendulum.SATURDAY,
"sunday": pendulum.SUNDAY,
# Short forms
"mon": pendulum.MONDAY,
"tue": pendulum.TUESDAY,
"wed": pendulum.WEDNESDAY,
"thu": pendulum.THURSDAY,
"fri": pendulum.FRIDAY,
"sat": pendulum.SATURDAY,
"sun": pendulum.SUNDAY,
}
# Ordinal to number
ORDINALS = {
"1st": 1,
"2nd": 2,
"3rd": 3,
"4th": 4,
"5th": 5,
"first": 1,
"second": 2,
"third": 3,
"fourth": 4,
"fifth": 5,
"last": -1,
}
def _parse_simple_pattern(pattern: str, base: pendulum.Date) -> pendulum.Date | None:
"""Parse simple and complex taskmark patterns.
Supports:
- daily, weekly, monthly, yearly
- Nd, Ndays, Nw, Nweeks, Nm, Nmonths, Ny, Nyears (e.g., 2weeks, 3days)
- every Nd/Nw/Nm/Ny (legacy format)
- Nth (day of month): 15th, 1st, 31st
- Nth-weekday: 2nd-tuesday, last-friday, 1st-monday
Returns:
Next date if pattern recognized, None otherwise
"""
# Named patterns
if pattern == "daily":
return base.add(days=1)
if pattern == "weekly":
return base.add(weeks=1)
if pattern == "monthly":
return base.add(months=1)
if pattern == "yearly":
return base.add(years=1)
# Day of month: 15th, 1st, 31st
match = re.match(r"^(\d+)(?:st|nd|rd|th)$", pattern)
if match:
day = int(match.group(1))
return _next_day_of_month(base, day)
# Nth weekday: 2nd-tuesday, last-friday, 1st-monday
match = re.match(
r"^(1st|2nd|3rd|4th|5th|first|second|third|fourth|fifth|last)[- ](\w+)$", pattern
)
if match:
ordinal = ORDINALS.get(match.group(1))
weekday = WEEKDAYS.get(match.group(2))
if ordinal is not None and weekday is not None:
return _next_nth_weekday(base, ordinal, weekday)
# Multiplied patterns: 2weeks, 3days, 2months, 2years
# Also handles: 2d, 2w, 2m, 2y (short form)
match = re.match(r"^(\d+)(d|day|days|w|week|weeks|m|month|months|y|year|years)$", pattern)
if match:
n = int(match.group(1))
unit = match.group(2)
if unit in ("d", "day", "days"):
return base.add(days=n)
if unit in ("w", "week", "weeks"):
return base.add(weeks=n)
if unit in ("m", "month", "months"):
return base.add(months=n)
if unit in ("y", "year", "years"):
return base.add(years=n)
# Legacy "every Nx" format
if pattern.startswith("every "):
rest = pattern[6:].strip()
if rest and rest[-1] in "dwmy":
try:
n = int(rest[:-1])
unit = rest[-1]
if unit == "d":
return base.add(days=n)
if unit == "w":
return base.add(weeks=n)
if unit == "m":
return base.add(months=n)
if unit == "y":
return base.add(years=n)
except ValueError:
pass
return None
def _next_day_of_month(base: pendulum.Date, day: int) -> pendulum.Date:
"""Get next occurrence of a specific day of month.
If day is past in current month, returns that day next month.
Handles month-end clamping (31st in February → 28/29).
"""
import calendar
# Try current month first
if base.day < day:
# Day is still ahead in this month
max_day = calendar.monthrange(base.year, base.month)[1]
actual_day = min(day, max_day)
return base.set(day=actual_day)
# Move to next month
next_month = base.add(months=1)
max_day = calendar.monthrange(next_month.year, next_month.month)[1]
actual_day = min(day, max_day)
return next_month.set(day=actual_day)
def _next_nth_weekday(base: pendulum.Date, nth: int, weekday: int) -> pendulum.Date:
"""Get next occurrence of nth weekday of month.
Args:
base: Base date to calculate from
nth: Which occurrence (1-5 for 1st-5th, -1 for last)
weekday: Pendulum weekday constant (0=Monday, 6=Sunday)
Returns:
Next occurrence of that nth weekday
"""
# Find the nth weekday in current month
current_nth = _nth_weekday_of_month(base.year, base.month, nth, weekday)
if current_nth and current_nth > base:
return current_nth
# Search forward until we find a month with the nth weekday
# (5th weekday doesn't exist in every month)
search_date = base.add(months=1)
for _ in range(12): # At most 12 months to find a valid occurrence
result = _nth_weekday_of_month(search_date.year, search_date.month, nth, weekday)
if result is not None:
return result
search_date = search_date.add(months=1)
# Fallback (shouldn't happen for valid nth values)
return base
def _nth_weekday_of_month(year: int, month: int, nth: int, weekday: int) -> pendulum.Date | None:
"""Get the nth weekday of a specific month.
Args:
year: Year
month: Month (1-12)
nth: Which occurrence (1-5 for 1st-5th, -1 for last)
weekday: Pendulum weekday constant (0=Monday, 6=Sunday)
Returns:
The date of that nth weekday, or None if it doesn't exist (e.g., 5th Monday)
"""
import calendar
# Get first day of month
first_day = pendulum.date(year, month, 1)
if nth > 0:
# Find first occurrence of weekday in month
days_until = (weekday - first_day.day_of_week) % 7
first_occurrence = first_day.add(days=days_until)
# Add (nth-1) weeks
result = first_occurrence.add(weeks=nth - 1)
# Verify still in same month
if result.month != month:
return None
return result
else:
# Last weekday of month
last_day = pendulum.date(year, month, calendar.monthrange(year, month)[1])
days_back = (last_day.day_of_week - weekday) % 7
return last_day.subtract(days=days_back)