33
44from dateutil .relativedelta import relativedelta
55
6+ from ..exceptions import ServerError
7+
68
79class DateError (Exception ):
810 """Custom exception for date-related errors."""
911
1012 pass
1113
1214
15+ def date_check (date : str , allowed_values : list [str ]):
16+ """
17+ Process special match rules for DATE constraints.
18+
19+ :param date: Date to check, can be a single, list or range of dates (Mars date format)
20+ :param allowed_values: List of rules. All rules must be the same style:
21+
22+ Old style (e.g. ">30d", "<40d"):
23+ Each date must satisfy ALL rules (AND logic).
24+
25+ New style – Mars date strings (e.g. "-1/-5/-10", "-1/to/-20", "-4/to/-20/by/4"):
26+ Each individual date must match AT LEAST ONE rule (OR logic).
27+ """
28+ if not isinstance (allowed_values , list ):
29+ raise ServerError ("Allowed values must be a list" )
30+
31+ if not allowed_values :
32+ return True
33+
34+ if all (map (is_old_style_rule , allowed_values )):
35+ # Old-style: every rule must pass
36+ for rule in allowed_values :
37+ if not date_check_single_rule (date , rule ):
38+ return False
39+ return True
40+
41+ # New-style Mars date rules: each user date must match at least one rule
42+ user_dates = expand_mars_dates (date )
43+ for user_date in user_dates :
44+ if not any (date_in_mars_rule (user_date , rule ) for rule in allowed_values ):
45+ raise DateError (f"Date { user_date } does not match any allowed date rule: { allowed_values } " )
46+
47+ return True
48+
49+
1350def check_single_date (date , offset , offset_fmted , after = False ):
1451 # Date is relative (0 = now, -1 = one day ago)
1552 if str (date )[0 ] == "0" or str (date )[0 ] == "-" :
1653 date_offset = int (date )
1754 dt = datetime .today () + timedelta (days = date_offset )
1855
19- if after and dt >= offset :
56+ if after and dt > offset :
2057 raise DateError ("Date is too recent, expected < {}" .format (offset_fmted ))
2158 elif not after and dt < offset :
2259 raise DateError ("Date is too old, expected > {}" .format (offset_fmted ))
@@ -28,7 +65,7 @@ def check_single_date(date, offset, offset_fmted, after=False):
2865 dt = datetime .strptime (date , "%Y%m%d" )
2966 except ValueError :
3067 raise DateError ("Invalid date, expected real date in YYYYMMDD format" )
31- if after and dt >= offset :
68+ if after and dt > offset :
3269 raise DateError ("Date is too recent, expected < {}" .format (offset_fmted ))
3370 elif not after and dt < offset :
3471 raise DateError ("Date is too old, expected > {}" .format (offset_fmted ))
@@ -52,26 +89,125 @@ def parse_relativedelta(time_str):
5289 return relativedelta (days = time_dict ["d" ], hours = time_dict ["h" ], minutes = time_dict ["m" ])
5390
5491
55- def date_check (date , allowed_values : list ):
92+ def is_old_style_rule (rule : str ) -> bool :
93+ """Returns True if rule is old-style (starts with > or <)."""
94+ return rule .strip ()[0 ] in (">" , "<" )
95+
96+
97+ def parse_mars_date_token (token : str ) -> datetime :
98+ """Parse a single Mars date token to a datetime.
99+
100+ Supports:
101+ - Relative dates: 0 (today), -1 (yesterday), -10, etc.
102+ - Absolute YYYYMMDD: 20250125
103+ - Absolute YYYY-MM-DD: 2023-04-23
56104 """
57- Process special match rules for DATE constraints
105+ token = token .strip ()
106+ if not token :
107+ raise DateError ("Empty date token" )
108+ # Relative date: starts with '-' or '0' (matches existing check_single_date convention)
109+ if token [0 ] == "-" or token [0 ] == "0" :
110+ try :
111+ offset = int (token )
112+ return datetime .today ().replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 ) + timedelta (days = offset )
113+ except ValueError :
114+ pass
115+ # ISO format YYYY-MM-DD
116+ try :
117+ return datetime .strptime (token , "%Y-%m-%d" )
118+ except ValueError :
119+ pass
120+ # YYYYMMDD format
121+ try :
122+ return datetime .strptime (token , "%Y%m%d" )
123+ except ValueError :
124+ raise DateError (f"Invalid Mars date token: { token !r} " )
58125
59- :param date: Date to check, can be a string or list of strings
60- :param allowed_values: List of allowed values for the date in the format >1d, <2d, >1m, <2h, r"(\\ d+)([dhm])".
126+
127+ def expand_mars_dates (date_str : str ) -> list :
128+ """Expand a Mars date string to a list of date objects.
129+
130+ Handles single dates, lists, ranges, and ranges with step.
131+
132+ Examples:
133+ "-1" -> [date(-1)]
134+ "-1/-5/-10" -> [date(-1), date(-5), date(-10)]
135+ "-1/to/-20" -> [date(-1), date(-2), ..., date(-20)]
136+ "-4/to/-20/by/4" -> [date(-4), date(-8), date(-12), date(-16), date(-20)]
137+ "20250125/-5/2023-04-23" -> [date(20250125), date(-5), date(2023-04-23)]
138+ "2024-02-21/to/2025-03-01/by/10" -> dates every 10 days across the range
61139 """
62- if not isinstance (allowed_values , list ):
63- raise DateError ("Allowed values must be a list" )
140+ parts = date_str .split ("/" )
141+
142+ # Range syntax: second element is 'to'
143+ if len (parts ) >= 3 and parts [1 ].strip ().lower () == "to" :
144+ start_d = parse_mars_date_token (parts [0 ]).date ()
145+ end_d = parse_mars_date_token (parts [2 ]).date ()
146+ step = 1
147+ if len (parts ) == 5 :
148+ if parts [3 ].strip ().lower () != "by" :
149+ raise DateError (f"Invalid Mars date string: { date_str !r} " )
150+ step = abs (int (parts [4 ].strip ()))
151+ elif len (parts ) != 3 :
152+ raise DateError (f"Invalid Mars date string: { date_str !r} " )
153+
154+ dates = []
155+ if start_d <= end_d :
156+ current = start_d
157+ while current <= end_d :
158+ dates .append (current )
159+ current += timedelta (days = step )
160+ else :
161+ current = start_d
162+ while current >= end_d :
163+ dates .append (current )
164+ current -= timedelta (days = step )
165+ return dates
166+
167+ # List or single date
168+ return [parse_mars_date_token (p ).date () for p in parts ]
169+
64170
65- for allowed in allowed_values :
66- if not date_check_single_rule (date , allowed ):
171+ def date_in_mars_rule (date_d , rule_str : str ) -> bool :
172+ """Check whether a single date (a date object) is covered by a Mars date rule string.
173+
174+ The rule string follows the same Mars date syntax:
175+ - Single: '-1', '20250125', '2023-04-23'
176+ - List: '-1/-5/-10', '20250125/-5/2023-04-23'
177+ - Range: '-1/to/-20', '2024-02-21/to/2025-03-01'
178+ - Range+step: '-4/to/-20/by/4', '2024-02-21/to/2025-03-01/by/10'
179+ """
180+ parts = rule_str .split ("/" )
181+
182+ # Range syntax
183+ if len (parts ) >= 3 and parts [1 ].strip ().lower () == "to" :
184+ start_d = parse_mars_date_token (parts [0 ]).date ()
185+ end_d = parse_mars_date_token (parts [2 ]).date ()
186+ step = 1
187+ if len (parts ) == 5 :
188+ if parts [3 ].strip ().lower () != "by" :
189+ raise DateError (f"Invalid Mars date rule: { rule_str !r} " )
190+ step = abs (int (parts [4 ].strip ()))
191+ elif len (parts ) != 3 :
192+ raise DateError (f"Invalid Mars date rule: { rule_str !r} " )
193+
194+ min_d = min (start_d , end_d )
195+ max_d = max (start_d , end_d )
196+ if not (min_d <= date_d <= max_d ):
67197 return False
198+ # Date must fall on a step boundary from start
199+ return abs ((date_d - start_d ).days ) % step == 0
68200
69- return True
201+ # List or single: date must equal one of the listed tokens
202+ for part in parts :
203+ if parse_mars_date_token (part ).date () == date_d :
204+ return True
205+ return False
70206
71207
72208def date_check_single_rule (date , allowed_values : str ):
73209 """
74- Process special match rules for DATE constraints
210+ Process special match rules for DATE constraints (old-style rules only).
75211
76212 :param date: Date to check, can be a string or list of strings
77213 :param allowed_values: Allowed values for the date in the format >1d, <2d, >1m, <2h, r"(\\ d+)([dhm])".
@@ -89,7 +225,7 @@ def date_check_single_rule(date, allowed_values: str):
89225 elif comp == ">" :
90226 after = True
91227 else :
92- raise DateError (f"Invalid date comparison { comp } , expected < or >" )
228+ raise ServerError (f"Invalid date comparison { comp } , expected < or >" )
93229 now = datetime .today ()
94230 offset = now - parse_relativedelta (offset )
95231 offset_fmted = offset .strftime ("%Y%m%d" )
0 commit comments