Skip to content

Commit 5560cfa

Browse files
committed
feat(datasource): mars-style date matching
1 parent 70e9218 commit 5560cfa

File tree

3 files changed

+515
-24
lines changed

3 files changed

+515
-24
lines changed

docs/source/schemas/schema.json

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@
348348
},
349349
"match": {
350350
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match",
351-
"description": "filters requests based on presence of specific key=value pair in user request"
351+
"description": "filters requests based on request keys and values. Non-date keys accept scalar or list values. Date supports old-style relative rules (e.g. >30d, <40d) and MARS date rules (single/list/range/by-step)."
352352
},
353353
"patch": {
354354
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Patch",
@@ -455,7 +455,7 @@
455455
},
456456
"match": {
457457
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match",
458-
"description": "filters requests based on presence of specific key=value pair in user request"
458+
"description": "filters requests based on request keys and values. Non-date keys accept scalar or list values. Date supports old-style relative rules (e.g. >30d, <40d) and MARS date rules (single/list/range/by-step)."
459459
},
460460
"patch": {
461461
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Patch",
@@ -474,26 +474,77 @@
474474
},
475475
"DatasourcesConfig-Datasource-MARS-Match": {
476476
"type": "object",
477+
"description": "Datasource match rules keyed by request parameter. For non-date keys, scalar and list forms are supported. For date, old-style relative comparisons and MARS date-string rules are supported.",
477478
"properties": {
479+
"date": {
480+
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-DateValue",
481+
"description": "Date matching rules. Old-style (all rules must pass): >30d, <40d. MARS-style (each requested date must match at least one rule): -1/-5/-10, -1/to/-20, -4/to/-20/by/4, 2024-02-21/to/2025-03-01/by/10.",
482+
"examples": [
483+
">30d",
484+
[
485+
">30d",
486+
"<40d"
487+
],
488+
"-1/to/-20",
489+
[
490+
"-1/-5/-10",
491+
"-20/to/-30"
492+
]
493+
]
494+
},
478495
"key1": {
496+
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-Value",
497+
"description": "Example non-date match key. Request values must be a subset of the allowed values."
498+
},
499+
"key2": {
500+
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-Value"
501+
}
502+
},
503+
"preferredOrder": [
504+
"date",
505+
"key1",
506+
"key2"
507+
],
508+
"additionalProperties": {
509+
"$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-Value"
510+
}
511+
},
512+
"DatasourcesConfig-Datasource-MARS-Match-Value": {
513+
"oneOf": [
514+
{
515+
"type": "string"
516+
},
517+
{
518+
"type": "number"
519+
},
520+
{
479521
"type": "array",
480522
"items": {
481-
"type": "string"
523+
"oneOf": [
524+
{
525+
"type": "string"
526+
},
527+
{
528+
"type": "number"
529+
}
530+
]
482531
},
483-
"default": "[]",
484-
"description": "value matches any in list"
532+
"default": []
533+
}
534+
]
535+
},
536+
"DatasourcesConfig-Datasource-MARS-Match-DateValue": {
537+
"oneOf": [
538+
{
539+
"type": "string"
485540
},
486-
"key2": {
541+
{
487542
"type": "array",
488543
"items": {
489544
"type": "string"
490545
},
491-
"default": "[]"
546+
"default": []
492547
}
493-
},
494-
"preferredOrder": [
495-
"key1",
496-
"key2"
497548
]
498549
},
499550
"DatasourcesConfig-Datasource-MARS-Patch": {

polytope_server/common/datasource/date_check.py

Lines changed: 149 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,57 @@
33

44
from dateutil.relativedelta import relativedelta
55

6+
from ..exceptions import ServerError
7+
68

79
class 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+
1350
def 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

72208
def 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

Comments
 (0)