From 5560cfaf99889a5e4e37d04b9d5f49366fb2772b Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Tue, 10 Mar 2026 10:43:03 +0000 Subject: [PATCH 01/14] feat(datasource): mars-style date matching --- docs/source/schemas/schema.json | 73 ++++- .../common/datasource/date_check.py | 162 +++++++++- tests/unit/test_date_check.py | 304 ++++++++++++++++++ 3 files changed, 515 insertions(+), 24 deletions(-) create mode 100644 tests/unit/test_date_check.py diff --git a/docs/source/schemas/schema.json b/docs/source/schemas/schema.json index dedea1d6..0e63fd75 100644 --- a/docs/source/schemas/schema.json +++ b/docs/source/schemas/schema.json @@ -348,7 +348,7 @@ }, "match": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match", - "description": "filters requests based on presence of specific key=value pair in user request" + "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)." }, "patch": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Patch", @@ -455,7 +455,7 @@ }, "match": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match", - "description": "filters requests based on presence of specific key=value pair in user request" + "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)." }, "patch": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Patch", @@ -474,26 +474,77 @@ }, "DatasourcesConfig-Datasource-MARS-Match": { "type": "object", + "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.", "properties": { + "date": { + "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-DateValue", + "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.", + "examples": [ + ">30d", + [ + ">30d", + "<40d" + ], + "-1/to/-20", + [ + "-1/-5/-10", + "-20/to/-30" + ] + ] + }, "key1": { + "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-Value", + "description": "Example non-date match key. Request values must be a subset of the allowed values." + }, + "key2": { + "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-Value" + } + }, + "preferredOrder": [ + "date", + "key1", + "key2" + ], + "additionalProperties": { + "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-Value" + } + }, + "DatasourcesConfig-Datasource-MARS-Match-Value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] }, - "default": "[]", - "description": "value matches any in list" + "default": [] + } + ] + }, + "DatasourcesConfig-Datasource-MARS-Match-DateValue": { + "oneOf": [ + { + "type": "string" }, - "key2": { + { "type": "array", "items": { "type": "string" }, - "default": "[]" + "default": [] } - }, - "preferredOrder": [ - "key1", - "key2" ] }, "DatasourcesConfig-Datasource-MARS-Patch": { diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index f8531524..e4d7f406 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -3,6 +3,8 @@ from dateutil.relativedelta import relativedelta +from ..exceptions import ServerError + class DateError(Exception): """Custom exception for date-related errors.""" @@ -10,13 +12,48 @@ class DateError(Exception): pass +def date_check(date: str, allowed_values: list[str]): + """ + Process special match rules for DATE constraints. + + :param date: Date to check, can be a single, list or range of dates (Mars date format) + :param allowed_values: List of rules. All rules must be the same style: + + Old style (e.g. ">30d", "<40d"): + Each date must satisfy ALL rules (AND logic). + + New style – Mars date strings (e.g. "-1/-5/-10", "-1/to/-20", "-4/to/-20/by/4"): + Each individual date must match AT LEAST ONE rule (OR logic). + """ + if not isinstance(allowed_values, list): + raise ServerError("Allowed values must be a list") + + if not allowed_values: + return True + + if all(map(is_old_style_rule, allowed_values)): + # Old-style: every rule must pass + for rule in allowed_values: + if not date_check_single_rule(date, rule): + return False + return True + + # New-style Mars date rules: each user date must match at least one rule + user_dates = expand_mars_dates(date) + for user_date in user_dates: + if not any(date_in_mars_rule(user_date, rule) for rule in allowed_values): + raise DateError(f"Date {user_date} does not match any allowed date rule: {allowed_values}") + + return True + + def check_single_date(date, offset, offset_fmted, after=False): # Date is relative (0 = now, -1 = one day ago) if str(date)[0] == "0" or str(date)[0] == "-": date_offset = int(date) dt = datetime.today() + timedelta(days=date_offset) - if after and dt >= offset: + if after and dt > offset: raise DateError("Date is too recent, expected < {}".format(offset_fmted)) elif not after and dt < offset: raise DateError("Date is too old, expected > {}".format(offset_fmted)) @@ -28,7 +65,7 @@ def check_single_date(date, offset, offset_fmted, after=False): dt = datetime.strptime(date, "%Y%m%d") except ValueError: raise DateError("Invalid date, expected real date in YYYYMMDD format") - if after and dt >= offset: + if after and dt > offset: raise DateError("Date is too recent, expected < {}".format(offset_fmted)) elif not after and dt < offset: raise DateError("Date is too old, expected > {}".format(offset_fmted)) @@ -52,26 +89,125 @@ def parse_relativedelta(time_str): return relativedelta(days=time_dict["d"], hours=time_dict["h"], minutes=time_dict["m"]) -def date_check(date, allowed_values: list): +def is_old_style_rule(rule: str) -> bool: + """Returns True if rule is old-style (starts with > or <).""" + return rule.strip()[0] in (">", "<") + + +def parse_mars_date_token(token: str) -> datetime: + """Parse a single Mars date token to a datetime. + + Supports: + - Relative dates: 0 (today), -1 (yesterday), -10, etc. + - Absolute YYYYMMDD: 20250125 + - Absolute YYYY-MM-DD: 2023-04-23 """ - Process special match rules for DATE constraints + token = token.strip() + if not token: + raise DateError("Empty date token") + # Relative date: starts with '-' or '0' (matches existing check_single_date convention) + if token[0] == "-" or token[0] == "0": + try: + offset = int(token) + return datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=offset) + except ValueError: + pass + # ISO format YYYY-MM-DD + try: + return datetime.strptime(token, "%Y-%m-%d") + except ValueError: + pass + # YYYYMMDD format + try: + return datetime.strptime(token, "%Y%m%d") + except ValueError: + raise DateError(f"Invalid Mars date token: {token!r}") - :param date: Date to check, can be a string or list of strings - :param allowed_values: List of allowed values for the date in the format >1d, <2d, >1m, <2h, r"(\\d+)([dhm])". + +def expand_mars_dates(date_str: str) -> list: + """Expand a Mars date string to a list of date objects. + + Handles single dates, lists, ranges, and ranges with step. + + Examples: + "-1" -> [date(-1)] + "-1/-5/-10" -> [date(-1), date(-5), date(-10)] + "-1/to/-20" -> [date(-1), date(-2), ..., date(-20)] + "-4/to/-20/by/4" -> [date(-4), date(-8), date(-12), date(-16), date(-20)] + "20250125/-5/2023-04-23" -> [date(20250125), date(-5), date(2023-04-23)] + "2024-02-21/to/2025-03-01/by/10" -> dates every 10 days across the range """ - if not isinstance(allowed_values, list): - raise DateError("Allowed values must be a list") + parts = date_str.split("/") + + # Range syntax: second element is 'to' + if len(parts) >= 3 and parts[1].strip().lower() == "to": + start_d = parse_mars_date_token(parts[0]).date() + end_d = parse_mars_date_token(parts[2]).date() + step = 1 + if len(parts) == 5: + if parts[3].strip().lower() != "by": + raise DateError(f"Invalid Mars date string: {date_str!r}") + step = abs(int(parts[4].strip())) + elif len(parts) != 3: + raise DateError(f"Invalid Mars date string: {date_str!r}") + + dates = [] + if start_d <= end_d: + current = start_d + while current <= end_d: + dates.append(current) + current += timedelta(days=step) + else: + current = start_d + while current >= end_d: + dates.append(current) + current -= timedelta(days=step) + return dates + + # List or single date + return [parse_mars_date_token(p).date() for p in parts] + - for allowed in allowed_values: - if not date_check_single_rule(date, allowed): +def date_in_mars_rule(date_d, rule_str: str) -> bool: + """Check whether a single date (a date object) is covered by a Mars date rule string. + + The rule string follows the same Mars date syntax: + - Single: '-1', '20250125', '2023-04-23' + - List: '-1/-5/-10', '20250125/-5/2023-04-23' + - Range: '-1/to/-20', '2024-02-21/to/2025-03-01' + - Range+step: '-4/to/-20/by/4', '2024-02-21/to/2025-03-01/by/10' + """ + parts = rule_str.split("/") + + # Range syntax + if len(parts) >= 3 and parts[1].strip().lower() == "to": + start_d = parse_mars_date_token(parts[0]).date() + end_d = parse_mars_date_token(parts[2]).date() + step = 1 + if len(parts) == 5: + if parts[3].strip().lower() != "by": + raise DateError(f"Invalid Mars date rule: {rule_str!r}") + step = abs(int(parts[4].strip())) + elif len(parts) != 3: + raise DateError(f"Invalid Mars date rule: {rule_str!r}") + + min_d = min(start_d, end_d) + max_d = max(start_d, end_d) + if not (min_d <= date_d <= max_d): return False + # Date must fall on a step boundary from start + return abs((date_d - start_d).days) % step == 0 - return True + # List or single: date must equal one of the listed tokens + for part in parts: + if parse_mars_date_token(part).date() == date_d: + return True + return False def date_check_single_rule(date, allowed_values: str): """ - Process special match rules for DATE constraints + Process special match rules for DATE constraints (old-style rules only). :param date: Date to check, can be a string or list of strings :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): elif comp == ">": after = True else: - raise DateError(f"Invalid date comparison {comp}, expected < or >") + raise ServerError(f"Invalid date comparison {comp}, expected < or >") now = datetime.today() offset = now - parse_relativedelta(offset) offset_fmted = offset.strftime("%Y%m%d") diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py new file mode 100644 index 00000000..6678b0c7 --- /dev/null +++ b/tests/unit/test_date_check.py @@ -0,0 +1,304 @@ +# +# Copyright 2022 European Centre for Medium-Range Weather Forecasts (ECMWF) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In applying this licence, ECMWF does not waive the privileges and immunities +# granted to it by virtue of its status as an intergovernmental organisation nor +# does it submit to any jurisdiction. +# + +from datetime import date, datetime, timedelta + +import pytest + +from polytope_server.common.datasource.date_check import ( + DateError, + date_check, + date_in_mars_rule, + expand_mars_dates, + parse_mars_date_token, +) + + +def d(offset): + """Return the date object for today + offset days.""" + return (datetime.today() + timedelta(days=offset)).date() + + +def ds(offset): + """Return YYYYMMDD string for today + offset days.""" + return (datetime.today() + timedelta(days=offset)).strftime("%Y%m%d") + + +# --------------------------------------------------------------------------- +# parse_mars_date_token +# --------------------------------------------------------------------------- + + +class TestParseMarsDateToken: + def test_relative_negative_one(self): + assert parse_mars_date_token("-1").date() == d(-1) + + def test_relative_negative_large(self): + assert parse_mars_date_token("-365").date() == d(-365) + + def test_relative_zero(self): + assert parse_mars_date_token("0").date() == d(0) + + def test_absolute_yyyymmdd(self): + assert parse_mars_date_token("20250125").date() == date(2025, 1, 25) + + def test_absolute_iso(self): + assert parse_mars_date_token("2023-04-23").date() == date(2023, 4, 23) + + def test_absolute_iso_leap_day(self): + assert parse_mars_date_token("2024-02-29").date() == date(2024, 2, 29) + + def test_invalid_string(self): + with pytest.raises(DateError): + parse_mars_date_token("notadate") + + def test_empty_string(self): + with pytest.raises(DateError): + parse_mars_date_token("") + + # Edge case: positive integer — NOT treated as relative (no leading 0 or -) + # "1" is an invalid YYYYMMDD and not a relative token, so it should fail. + def test_positive_integer_not_supported_as_relative(self): + with pytest.raises(DateError): + parse_mars_date_token("1") + + +# --------------------------------------------------------------------------- +# expand_mars_dates +# --------------------------------------------------------------------------- + + +class TestExpandMarsDates: + # Single dates + def test_single_relative(self): + assert expand_mars_dates("-1") == [d(-1)] + assert expand_mars_dates("0") == [d(0)] + + def test_single_absolute(self): + assert expand_mars_dates("20250125") == [date(2025, 1, 25)] + assert expand_mars_dates("2023-04-23") == [date(2023, 4, 23)] + + # Lists + def test_list_relative(self): + assert expand_mars_dates("-1/-5/-10") == [d(-1), d(-5), d(-10)] + + def test_list_mixed_formats(self): + # from the spec: "20250125/-5/2023-04-23" + result = expand_mars_dates("20250125/-5/2023-04-23") + assert result == [date(2025, 1, 25), d(-5), date(2023, 4, 23)] + + def test_list_two_elements(self): + # Two-element list must NOT be mistaken for a range + result = expand_mars_dates("-1/-20") + assert result == [d(-1), d(-20)] + assert len(result) == 2 + + # Ranges + def test_range(self): + # Ascending range + result = expand_mars_dates("2025-01-01/to/2025-01-05") + assert result == [date(2025, 1, i) for i in range(1, 6)] + # Descending range is valid: start > end in calendar terms + result = expand_mars_dates("2025-01-05/to/2025-01-01") + assert result == [date(2025, 1, i) for i in range(5, 0, -1)] + # Relative descending: -1/to/-20 + result = expand_mars_dates("-1/to/-20") + assert result[0] == d(-1) + assert result[-1] == d(-20) + assert len(result) == 20 + + # Ranges with step + def test_stepped_range(self): + # Relative: -4/to/-20/by/4 -> -4, -8, -12, -16, -20 + result = expand_mars_dates("-4/to/-20/by/4") + assert result == [d(-4), d(-8), d(-12), d(-16), d(-20)] + # Absolute: 2024-02-21/to/2025-03-01/by/10 + result = expand_mars_dates("2024-02-21/to/2025-03-01/by/10") + assert result[0] == date(2024, 2, 21) + assert all((r - date(2024, 2, 21)).days % 10 == 0 for r in result) + # Symmetric: forward and backward should produce same set + fwd = set(expand_mars_dates("-4/to/-20/by/4")) + rev = set(expand_mars_dates("-20/to/-4/by/4")) + assert fwd == rev + # Uneven step: stops before reaching exact end + result = expand_mars_dates("-4/to/-21/by/4") + assert d(-20) in result + assert d(-21) not in result + + def test_case_insensitive_to(self): + assert expand_mars_dates("-1/TO/-3") == expand_mars_dates("-1/to/-3") + + def test_case_insensitive_by(self): + assert expand_mars_dates("-4/to/-20/BY/4") == expand_mars_dates("-4/to/-20/by/4") + + +# --------------------------------------------------------------------------- +# date_in_mars_rule +# --------------------------------------------------------------------------- + + +class TestDateInMarsRule: + # Single date rules + def test_single_date(self): + assert date_in_mars_rule(d(-1), "-1") is True + assert date_in_mars_rule(d(-2), "-1") is False + assert date_in_mars_rule(date(2025, 1, 25), "20250125") is True + + # List rules + def test_list(self): + assert date_in_mars_rule(d(-1), "-1/-5/-10") is True + assert date_in_mars_rule(d(-5), "-1/-5/-10") is True + assert date_in_mars_rule(d(-10), "-1/-5/-10") is True + assert date_in_mars_rule(d(-3), "-1/-5/-10") is False + + def test_list_mixed_formats(self): + rule = "20250125/-5/2023-04-23" + assert date_in_mars_rule(date(2025, 1, 25), rule) is True + assert date_in_mars_rule(d(-5), rule) is True + assert date_in_mars_rule(date(2023, 4, 23), rule) is True + assert date_in_mars_rule(date(2025, 1, 26), rule) is False + + # Range rules + def test_range(self): + assert date_in_mars_rule(d(-1), "-1/to/-20") is True + assert date_in_mars_rule(d(-10), "-1/to/-20") is True + assert date_in_mars_rule(d(-20), "-1/to/-20") is True + assert date_in_mars_rule(d(0), "-1/to/-20") is False + assert date_in_mars_rule(d(-21), "-1/to/-20") is False + # Inverted range covers same dates + assert date_in_mars_rule(d(-10), "-20/to/-1") is True + + # Stepped range rules + def test_stepped_range(self): + # On step: -4, -8, -12, -16, -20 + assert date_in_mars_rule(d(-4), "-4/to/-20/by/4") is True + assert date_in_mars_rule(d(-8), "-4/to/-20/by/4") is True + assert date_in_mars_rule(d(-20), "-4/to/-20/by/4") is True + # Off step or outside range + assert date_in_mars_rule(d(-5), "-4/to/-20/by/4") is False + assert date_in_mars_rule(d(-3), "-4/to/-20/by/4") is False + + def test_stepped_range_absolute(self): + # 2024-02-21/to/2025-03-01/by/10 + start = date(2024, 2, 21) + rule = "2024-02-21/to/2025-03-01/by/10" + assert date_in_mars_rule(start, rule) is True + assert date_in_mars_rule(start + timedelta(days=10), rule) is True + assert date_in_mars_rule(start + timedelta(days=11), rule) is False + + +# --------------------------------------------------------------------------- +# date_check — from the user's examples +# --------------------------------------------------------------------------- + + +class TestDateCheckNewStyle: + def test_single_relative(self): + assert date_check(ds(-1), ["-1"]) is True + with pytest.raises(DateError): + date_check(ds(-2), ["-1"]) + + def test_list_rule(self): + assert date_check(ds(-1), ["-1/-5/-10"]) is True + assert date_check(ds(-5), ["-1/-5/-10"]) is True + with pytest.raises(DateError): + date_check(ds(-3), ["-1/-5/-10"]) + + def test_range_rule(self): + assert date_check(ds(-1), ["-1/to/-20"]) is True + assert date_check(ds(-10), ["-1/to/-20"]) is True + with pytest.raises(DateError): + date_check(ds(-21), ["-1/to/-20"]) + + def test_stepped_range_rule(self): + assert date_check(ds(-4), ["-4/to/-20/by/4"]) is True + assert date_check(ds(-8), ["-4/to/-20/by/4"]) is True + with pytest.raises(DateError): + date_check(ds(-5), ["-4/to/-20/by/4"]) + + def test_mixed_formats(self): + rule = ["20250125/-5/2023-04-23"] + assert date_check("20250125", rule) is True + assert date_check(ds(-5), rule) is True + with pytest.raises(DateError): + date_check("20250126", rule) + + # --- Multiple rules: OR logic --- + + def test_or_logic(self): + # Matches first rule + assert date_check(ds(-1), ["-1/-5/-10", "-20/to/-30"]) is True + # Matches second rule + assert date_check(ds(-25), ["-1/-5/-10", "-20/to/-30"]) is True + # Matches no rule + with pytest.raises(DateError): + date_check(ds(-15), ["-1/-5/-10", "-20/to/-30"]) + + # --- User date as Mars string (range/list) --- + + def test_user_date_range(self): + # All within rule + assert date_check(f"{ds(-5)}/to/{ds(-10)}", ["-1/to/-20"]) is True + # Partially outside rule + with pytest.raises(DateError): + date_check(f"{ds(-1)}/to/{ds(-25)}", ["-1/to/-20"]) + + def test_user_date_list(self): + # All match + assert date_check(f"{ds(-1)}/{ds(-5)}/{ds(-10)}", ["-1/to/-20"]) is True + # One outside + with pytest.raises(DateError): + date_check(f"{ds(-1)}/{ds(-5)}/{ds(-25)}", ["-1/to/-20"]) + + def test_user_date_stepped_range(self): + # Exact match + assert date_check(f"{ds(-4)}/to/{ds(-20)}/by/4", ["-4/to/-20/by/4"]) is True + # Subset of allowed range + assert date_check(f"{ds(-4)}/to/{ds(-20)}/by/4", ["-1/to/-20"]) is True + # Exceeds rule + with pytest.raises(DateError): + date_check(f"{ds(-4)}/to/{ds(-24)}/by/4", ["-1/to/-20"]) + + def test_empty_allowed_values(self): + assert date_check(ds(-1), []) is True + + +# --------------------------------------------------------------------------- +# date_check — old-style rules: backward compatibility +# --------------------------------------------------------------------------- + + +class TestDateCheckOldStyle: + def test_single(self): + assert date_check(ds(-32), [">30d"]) is True + with pytest.raises(DateError): + date_check(ds(-5), [">30d"]) + + def test_multiple_and_logic(self): + # Both conditions must be satisfied + assert date_check(ds(-32), [">30d", "<40d"]) is True + with pytest.raises(DateError): + date_check(ds(-32), [">30d", "<20d"]) + + def test_range(self): + # All dates in range must satisfy rule + assert date_check(f"{ds(-60)}/to/{ds(-40)}", [">30d"]) is True + with pytest.raises(DateError): + date_check(f"{ds(-60)}/to/{ds(-25)}", [">30d"]) From 6f91d09e0a3e5acd76704defdae081afc3e81647 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Wed, 11 Mar 2026 07:15:01 +0000 Subject: [PATCH 02/14] fix(date_check): enforce rule style matching --- polytope_server/common/datasource/date_check.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index e4d7f406..9e15e030 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -31,13 +31,17 @@ def date_check(date: str, allowed_values: list[str]): if not allowed_values: return True - if all(map(is_old_style_rule, allowed_values)): + are_old_rules = set(is_old_style_rule(rule) for rule in allowed_values) + if all(are_old_rules): # Old-style: every rule must pass for rule in allowed_values: if not date_check_single_rule(date, rule): return False return True + if any(are_old_rules): + raise ServerError("Cannot mix old-style and new-style date rules in a single match.") + # New-style Mars date rules: each user date must match at least one rule user_dates = expand_mars_dates(date) for user_date in user_dates: From 3b83ad0bc456d7f0f2424dd3c14eea89bb87dcf3 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Wed, 11 Mar 2026 13:21:56 +0000 Subject: [PATCH 03/14] feat(date_check): cleaner date matching --- .../common/datasource/date_check.py | 134 ++++++++++-------- tests/unit/test_date_check.py | 58 ++++---- 2 files changed, 102 insertions(+), 90 deletions(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index 9e15e030..d18430d1 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -12,41 +12,56 @@ class DateError(Exception): pass -def date_check(date: str, allowed_values: list[str]): +def date_check(date: str, rules: list[str]): """ Process special match rules for DATE constraints. :param date: Date to check, can be a single, list or range of dates (Mars date format) - :param allowed_values: List of rules. All rules must be the same style: + :param rules: List of rules. All rules must be the same style: - Old style (e.g. ">30d", "<40d"): + - Comparative (e.g. ">30d", "<40d"): Each date must satisfy ALL rules (AND logic). - New style – Mars date strings (e.g. "-1/-5/-10", "-1/to/-20", "-4/to/-20/by/4"): - Each individual date must match AT LEAST ONE rule (OR logic). + - Mars date strings (e.g. "-1/-5/-10", "-1/to/-20" but without "by" in rules): + Each user date or date range must match AT LEAST ONE rule (OR logic). """ - if not isinstance(allowed_values, list): + if not isinstance(rules, list): raise ServerError("Allowed values must be a list") - if not allowed_values: + if not rules: return True - are_old_rules = set(is_old_style_rule(rule) for rule in allowed_values) - if all(are_old_rules): - # Old-style: every rule must pass - for rule in allowed_values: - if not date_check_single_rule(date, rule): + are_comparative_rules = set(is_comparative_rule(rule) for rule in rules) + if all(are_comparative_rules): + # Comparative: every rule must pass + for rule in rules: + if not date_check_comparative_rule(date, rule): return False return True - if any(are_old_rules): - raise ServerError("Cannot mix old-style and new-style date rules in a single match.") - - # New-style Mars date rules: each user date must match at least one rule - user_dates = expand_mars_dates(date) - for user_date in user_dates: - if not any(date_in_mars_rule(user_date, rule) for rule in allowed_values): - raise DateError(f"Date {user_date} does not match any allowed date rule: {allowed_values}") + if any(are_comparative_rules): + raise ServerError("Cannot mix comparative and new-style date rules in a single match.") + + # New-style Mars date rules. + date_parts = date.split("/") + if len(date_parts) >= 3 and date_parts[1].strip().lower() == "to": + # Range: both boundaries must be covered by the same rule. + start_d = parse_mars_date_token(date_parts[0]).date() + end_d = parse_mars_date_token(date_parts[2]).date() + if len(date_parts) == 5: + if date_parts[3].strip().lower() != "by": + raise DateError(f"Invalid Mars date string: {date!r}") + elif len(date_parts) != 3: + raise DateError(f"Invalid Mars date string: {date!r}") + if not any(date_in_mars_rule(start_d, rule) and date_in_mars_rule(end_d, rule) for rule in rules): + raise DateError( + f"Date range {start_d} to {end_d} is not fully covered by any single allowed date rule: {rules}" + ) + else: + # List or single: each date must match at least one rule (OR logic). + for user_date in [parse_mars_date_token(p).date() for p in date_parts]: + if not any(date_in_mars_rule(user_date, rule) for rule in rules): + raise DateError(f"Date {user_date} does not match any allowed date rule: {rules}") return True @@ -93,8 +108,8 @@ def parse_relativedelta(time_str): return relativedelta(days=time_dict["d"], hours=time_dict["h"], minutes=time_dict["m"]) -def is_old_style_rule(rule: str) -> bool: - """Returns True if rule is old-style (starts with > or <).""" +def is_comparative_rule(rule: str) -> bool: + """Returns True if rule is comparative (starts with > or <).""" return rule.strip()[0] in (">", "<") @@ -131,7 +146,7 @@ def parse_mars_date_token(token: str) -> datetime: def expand_mars_dates(date_str: str) -> list: """Expand a Mars date string to a list of date objects. - Handles single dates, lists, ranges, and ranges with step. + Handles single dates, lists, ranges, and ranges with "by". Examples: "-1" -> [date(-1)] @@ -141,18 +156,20 @@ def expand_mars_dates(date_str: str) -> list: "20250125/-5/2023-04-23" -> [date(20250125), date(-5), date(2023-04-23)] "2024-02-21/to/2025-03-01/by/10" -> dates every 10 days across the range """ - parts = date_str.split("/") + date_parts = date_str.split("/") # Range syntax: second element is 'to' - if len(parts) >= 3 and parts[1].strip().lower() == "to": - start_d = parse_mars_date_token(parts[0]).date() - end_d = parse_mars_date_token(parts[2]).date() - step = 1 - if len(parts) == 5: - if parts[3].strip().lower() != "by": + if len(date_parts) >= 3 and date_parts[1].strip().lower() == "to": + start_d = parse_mars_date_token(date_parts[0]).date() + end_d = parse_mars_date_token(date_parts[2]).date() + by = 1 + if len(date_parts) == 5: + if date_parts[3].strip().lower() != "by": raise DateError(f"Invalid Mars date string: {date_str!r}") - step = abs(int(parts[4].strip())) - elif len(parts) != 3: + by = abs(int(date_parts[4].strip())) + if by == 0: + raise DateError(f"By value cannot be zero in Mars date string: {date_str!r}") + elif len(date_parts) != 3: raise DateError(f"Invalid Mars date string: {date_str!r}") dates = [] @@ -160,61 +177,52 @@ def expand_mars_dates(date_str: str) -> list: current = start_d while current <= end_d: dates.append(current) - current += timedelta(days=step) + current += timedelta(days=by) else: current = start_d while current >= end_d: dates.append(current) - current -= timedelta(days=step) + current -= timedelta(days=by) return dates # List or single date - return [parse_mars_date_token(p).date() for p in parts] + return [parse_mars_date_token(p).date() for p in date_parts] def date_in_mars_rule(date_d, rule_str: str) -> bool: """Check whether a single date (a date object) is covered by a Mars date rule string. The rule string follows the same Mars date syntax: - - Single: '-1', '20250125', '2023-04-23' - - List: '-1/-5/-10', '20250125/-5/2023-04-23' - - Range: '-1/to/-20', '2024-02-21/to/2025-03-01' - - Range+step: '-4/to/-20/by/4', '2024-02-21/to/2025-03-01/by/10' + - Single: '-1', '20250125', '2023-04-23' + - List: '-1/-5/-10', '20250125/-5/2023-04-23' + - Range: '-1/to/-20', '2024-02-21/to/2025-03-01' + + Note: 'by' is not supported in rules. Use it only in user-supplied dates. """ - parts = rule_str.split("/") + rule_parts = rule_str.split("/") # Range syntax - if len(parts) >= 3 and parts[1].strip().lower() == "to": - start_d = parse_mars_date_token(parts[0]).date() - end_d = parse_mars_date_token(parts[2]).date() - step = 1 - if len(parts) == 5: - if parts[3].strip().lower() != "by": - raise DateError(f"Invalid Mars date rule: {rule_str!r}") - step = abs(int(parts[4].strip())) - elif len(parts) != 3: - raise DateError(f"Invalid Mars date rule: {rule_str!r}") - - min_d = min(start_d, end_d) - max_d = max(start_d, end_d) - if not (min_d <= date_d <= max_d): - return False - # Date must fall on a step boundary from start - return abs((date_d - start_d).days) % step == 0 + if len(rule_parts) >= 3 and rule_parts[1].strip().lower() == "to": + if len(rule_parts) != 3: + raise DateError(f"'by' is not supported in date rules: {rule_str!r}") + start_d = parse_mars_date_token(rule_parts[0]).date() + end_d = parse_mars_date_token(rule_parts[2]).date() + return min(start_d, end_d) <= date_d <= max(start_d, end_d) # List or single: date must equal one of the listed tokens - for part in parts: + for part in rule_parts: if parse_mars_date_token(part).date() == date_d: return True return False -def date_check_single_rule(date, allowed_values: str): +def date_check_comparative_rule(date, comp_rule: str): """ - Process special match rules for DATE constraints (old-style rules only). + Process special match rules for DATE constraints (comparative rules only). :param date: Date to check, can be a string or list of strings - :param allowed_values: Allowed values for the date in the format >1d, <2d, >1m, <2h, r"(\\d+)([dhm])". + :param comp_rule: Comparative rule for the date in the format >1d, <2d, >1m, <2h, r"(\\d+)([dhm])". + """ # if type of date is list if isinstance(date, list): @@ -222,8 +230,8 @@ def date_check_single_rule(date, allowed_values: str): date = str(date) # Parse allowed values - comp = allowed_values[0] - offset = allowed_values[1:].strip() + comp = comp_rule[0] + offset = comp_rule[1:].strip() if comp == "<": after = False elif comp == ">": diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py index 6678b0c7..8c91a207 100644 --- a/tests/unit/test_date_check.py +++ b/tests/unit/test_date_check.py @@ -186,22 +186,9 @@ def test_range(self): assert date_in_mars_rule(d(-10), "-20/to/-1") is True # Stepped range rules - def test_stepped_range(self): - # On step: -4, -8, -12, -16, -20 - assert date_in_mars_rule(d(-4), "-4/to/-20/by/4") is True - assert date_in_mars_rule(d(-8), "-4/to/-20/by/4") is True - assert date_in_mars_rule(d(-20), "-4/to/-20/by/4") is True - # Off step or outside range - assert date_in_mars_rule(d(-5), "-4/to/-20/by/4") is False - assert date_in_mars_rule(d(-3), "-4/to/-20/by/4") is False - - def test_stepped_range_absolute(self): - # 2024-02-21/to/2025-03-01/by/10 - start = date(2024, 2, 21) - rule = "2024-02-21/to/2025-03-01/by/10" - assert date_in_mars_rule(start, rule) is True - assert date_in_mars_rule(start + timedelta(days=10), rule) is True - assert date_in_mars_rule(start + timedelta(days=11), rule) is False + def test_stepped_range_in_rule_raises(self): + with pytest.raises(DateError): + date_in_mars_rule(d(-4), "-4/to/-20/by/4") # --------------------------------------------------------------------------- @@ -227,12 +214,6 @@ def test_range_rule(self): with pytest.raises(DateError): date_check(ds(-21), ["-1/to/-20"]) - def test_stepped_range_rule(self): - assert date_check(ds(-4), ["-4/to/-20/by/4"]) is True - assert date_check(ds(-8), ["-4/to/-20/by/4"]) is True - with pytest.raises(DateError): - date_check(ds(-5), ["-4/to/-20/by/4"]) - def test_mixed_formats(self): rule = ["20250125/-5/2023-04-23"] assert date_check("20250125", rule) is True @@ -268,24 +249,40 @@ def test_user_date_list(self): date_check(f"{ds(-1)}/{ds(-5)}/{ds(-25)}", ["-1/to/-20"]) def test_user_date_stepped_range(self): - # Exact match - assert date_check(f"{ds(-4)}/to/{ds(-20)}/by/4", ["-4/to/-20/by/4"]) is True - # Subset of allowed range + # Subset of allowed range: only boundaries are checked assert date_check(f"{ds(-4)}/to/{ds(-20)}/by/4", ["-1/to/-20"]) is True # Exceeds rule with pytest.raises(DateError): date_check(f"{ds(-4)}/to/{ds(-24)}/by/4", ["-1/to/-20"]) + def test_user_date_range_or_logic(self): + # Matches first rule + assert date_check(f"{ds(-5)}/to/{ds(-10)}", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) is True + # Matches second rule + assert date_check("2024-01-01/to/2024-08-31", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) is True + # Start matches one rule, end another + with pytest.raises(DateError): + date_check(f"{ds(-5)}/to/2024-08-31", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) + # Matches no rule + with pytest.raises(DateError): + date_check(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) + + # start matches one rule, but end matches another + with pytest.raises(DateError): + date_check(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "-30"]) + + date_check("-5/-30", ["-1/to/-20", "-30"]) is True + def test_empty_allowed_values(self): assert date_check(ds(-1), []) is True # --------------------------------------------------------------------------- -# date_check — old-style rules: backward compatibility +# date_check — comparative rules: backward compatibility # --------------------------------------------------------------------------- -class TestDateCheckOldStyle: +class TestDateCheckComparative: def test_single(self): assert date_check(ds(-32), [">30d"]) is True with pytest.raises(DateError): @@ -302,3 +299,10 @@ def test_range(self): assert date_check(f"{ds(-60)}/to/{ds(-40)}", [">30d"]) is True with pytest.raises(DateError): date_check(f"{ds(-60)}/to/{ds(-25)}", [">30d"]) + + def test_edges(self): + # Edge cases: exactly 30 days ago should NOT satisfy ">30d" + with pytest.raises(DateError): + date_check(ds(-29), [">30d"]) + # Exactly 31 days ago should satisfy ">30d" + assert date_check(ds(-30), [">30d"]) is True From c9af09a17f95e22a71c9db63a01ac7ebf623fe54 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Wed, 11 Mar 2026 13:40:01 +0000 Subject: [PATCH 04/14] chore(date_check): docstrings, type hints, funciton names and visibility --- .../common/datasource/date_check.py | 146 +++++++++--------- tests/unit/test_date_check.py | 70 --------- 2 files changed, 70 insertions(+), 146 deletions(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index d18430d1..0557e618 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -1,5 +1,5 @@ import re -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta @@ -12,18 +12,27 @@ class DateError(Exception): pass -def date_check(date: str, rules: list[str]): +def date_check(date: str, rules: list[str]) -> bool: """ - Process special match rules for DATE constraints. + Check a Mars-format date string against a list of allowed date rules. - :param date: Date to check, can be a single, list or range of dates (Mars date format) + :param date: Date to check. Accepts a single date, a slash-separated list, or a + range in Mars date format (e.g. ``-1``, ``-1/-5/-10``, + ``20250101/to/20250131``, ``20250101/to/20250131/by/7``). :param rules: List of rules. All rules must be the same style: - - Comparative (e.g. ">30d", "<40d"): + - Comparative (e.g. ``>30d``, ``<40d``, ``>1h``, ``<2m``): Each date must satisfy ALL rules (AND logic). - - Mars date strings (e.g. "-1/-5/-10", "-1/to/-20" but without "by" in rules): - Each user date or date range must match AT LEAST ONE rule (OR logic). + - Mars date strings (e.g. ``-1/-5/-10``, ``-1/to/-20``; ``by`` is not + supported in rules): + Each user date or date range must be fully covered by AT LEAST ONE + rule (OR logic). + + :returns: ``True`` if the date passes all checks. + :raises ServerError: If ``rules`` is not a list, or if comparative and + Mars-style rules are mixed. + :raises DateError: If the date does not satisfy the rules. """ if not isinstance(rules, list): raise ServerError("Allowed values must be a list") @@ -31,7 +40,7 @@ def date_check(date: str, rules: list[str]): if not rules: return True - are_comparative_rules = set(is_comparative_rule(rule) for rule in rules) + are_comparative_rules = set(_is_comparative_rule(rule) for rule in rules) if all(are_comparative_rules): # Comparative: every rule must pass for rule in rules: @@ -66,7 +75,18 @@ def date_check(date: str, rules: list[str]): return True -def check_single_date(date, offset, offset_fmted, after=False): +def _check_single_date_comparative_rule(date: str, offset: datetime, offset_fmted: str, after: bool = False) -> None: + """ + Check that a single Mars date token satisfies a comparative offset constraint. + + :param date: A single date token — either a relative integer string (e.g. ``"0"``, + ``"-1"``) or an absolute date in ``YYYYMMDD`` format. + :param offset: The cutoff ``datetime`` to compare against. + :param offset_fmted: Human-readable string of ``offset`` used in error messages. + :param after: If ``True``, the date must be *before* ``offset`` (``<`` semantics); + if ``False``, the date must be *after* ``offset`` (``>`` semantics). + :raises DateError: If the date falls outside the allowed range or is invalid. + """ # Date is relative (0 = now, -1 = one day ago) if str(date)[0] == "0" or str(date)[0] == "-": date_offset = int(date) @@ -79,7 +99,7 @@ def check_single_date(date, offset, offset_fmted, after=False): else: return - # Absolute date YYYMMDD + # Absolute date YYYYMMDD try: dt = datetime.strptime(date, "%Y%m%d") except ValueError: @@ -92,7 +112,16 @@ def check_single_date(date, offset, offset_fmted, after=False): return -def parse_relativedelta(time_str): +def _parse_relativedelta(time_str: str) -> relativedelta: + """ + Parse a duration string into a :class:`relativedelta`. + + Supports days (``d``), hours (``h``), and minutes (``m``), which may be + combined (e.g. ``"1d2h30m"``). + + :param time_str: Duration string such as ``"30d"``, ``"2h"``, ``"1d12h"``. + :returns: A :class:`relativedelta` representing the parsed duration. + """ pattern = r"(\d+)([dhm])" time_dict = {"d": 0, "h": 0, "m": 0} matches = re.findall(pattern, time_str) @@ -108,7 +137,7 @@ def parse_relativedelta(time_str): return relativedelta(days=time_dict["d"], hours=time_dict["h"], minutes=time_dict["m"]) -def is_comparative_rule(rule: str) -> bool: +def _is_comparative_rule(rule: str) -> bool: """Returns True if rule is comparative (starts with > or <).""" return rule.strip()[0] in (">", "<") @@ -143,68 +172,28 @@ def parse_mars_date_token(token: str) -> datetime: raise DateError(f"Invalid Mars date token: {token!r}") -def expand_mars_dates(date_str: str) -> list: - """Expand a Mars date string to a list of date objects. - - Handles single dates, lists, ranges, and ranges with "by". - - Examples: - "-1" -> [date(-1)] - "-1/-5/-10" -> [date(-1), date(-5), date(-10)] - "-1/to/-20" -> [date(-1), date(-2), ..., date(-20)] - "-4/to/-20/by/4" -> [date(-4), date(-8), date(-12), date(-16), date(-20)] - "20250125/-5/2023-04-23" -> [date(20250125), date(-5), date(2023-04-23)] - "2024-02-21/to/2025-03-01/by/10" -> dates every 10 days across the range - """ - date_parts = date_str.split("/") - - # Range syntax: second element is 'to' - if len(date_parts) >= 3 and date_parts[1].strip().lower() == "to": - start_d = parse_mars_date_token(date_parts[0]).date() - end_d = parse_mars_date_token(date_parts[2]).date() - by = 1 - if len(date_parts) == 5: - if date_parts[3].strip().lower() != "by": - raise DateError(f"Invalid Mars date string: {date_str!r}") - by = abs(int(date_parts[4].strip())) - if by == 0: - raise DateError(f"By value cannot be zero in Mars date string: {date_str!r}") - elif len(date_parts) != 3: - raise DateError(f"Invalid Mars date string: {date_str!r}") - - dates = [] - if start_d <= end_d: - current = start_d - while current <= end_d: - dates.append(current) - current += timedelta(days=by) - else: - current = start_d - while current >= end_d: - dates.append(current) - current -= timedelta(days=by) - return dates - - # List or single date - return [parse_mars_date_token(p).date() for p in date_parts] - - -def date_in_mars_rule(date_d, rule_str: str) -> bool: - """Check whether a single date (a date object) is covered by a Mars date rule string. +def date_in_mars_rule(date_d: date, rule_str: str) -> bool: + """Check whether a single date is covered by a Mars date rule string. The rule string follows the same Mars date syntax: - - Single: '-1', '20250125', '2023-04-23' - - List: '-1/-5/-10', '20250125/-5/2023-04-23' - - Range: '-1/to/-20', '2024-02-21/to/2025-03-01' - Note: 'by' is not supported in rules. Use it only in user-supplied dates. + - Single: ``'-1'``, ``'20250125'``, ``'2023-04-23'`` + - List: ``'-1/-5/-10'``, ``'20250125/-5/2023-04-23'`` + - Range: ``'-1/to/-20'``, ``'2024-02-21/to/2025-03-01'`` + + :param date_d: The :class:`~datetime.date` to test. + :param rule_str: A Mars-format allowed-date rule string. ``by`` is not + supported in rules — use it only in user-supplied date strings. + :returns: ``True`` if ``date_d`` falls within the rule. + :raises ServerError: If the rule contains ``by`` or is otherwise malformed. + :raises DateError: If the date does not match the rule. """ rule_parts = rule_str.split("/") # Range syntax if len(rule_parts) >= 3 and rule_parts[1].strip().lower() == "to": if len(rule_parts) != 3: - raise DateError(f"'by' is not supported in date rules: {rule_str!r}") + raise ServerError(f"'by' is not supported in date rules: {rule_str!r}") start_d = parse_mars_date_token(rule_parts[0]).date() end_d = parse_mars_date_token(rule_parts[2]).date() return min(start_d, end_d) <= date_d <= max(start_d, end_d) @@ -216,13 +205,18 @@ def date_in_mars_rule(date_d, rule_str: str) -> bool: return False -def date_check_comparative_rule(date, comp_rule: str): +def date_check_comparative_rule(date: str | list[str], comp_rule: str) -> bool: """ - Process special match rules for DATE constraints (comparative rules only). - - :param date: Date to check, can be a string or list of strings - :param comp_rule: Comparative rule for the date in the format >1d, <2d, >1m, <2h, r"(\\d+)([dhm])". - + Check a date (or list/range of dates) against a single comparative rule. + + :param date: Date to check. Either a single date string or a list of date strings, + each in Mars format (relative integer, ``YYYYMMDD``, or ``YYYYMMDD/to/YYYYMMDD``). + :param comp_rule: A comparative rule in the form ``>Nd``, ``Nh``, or + ``30d"``, ``"<2h"``. + :returns: ``True`` if all dates in ``date`` satisfy the rule. + :raises DateError: If a date is invalid or falls outside the allowed range. + :raises ServerError: If the comparison operator is not ``<`` or ``>``. """ # if type of date is list if isinstance(date, list): @@ -239,14 +233,14 @@ def date_check_comparative_rule(date, comp_rule: str): else: raise ServerError(f"Invalid date comparison {comp}, expected < or >") now = datetime.today() - offset = now - parse_relativedelta(offset) + offset = now - _parse_relativedelta(offset) offset_fmted = offset.strftime("%Y%m%d") split = date.split("/") # YYYYMMDD if len(split) == 1: - check_single_date(split[0], offset, offset_fmted, after) + _check_single_date_comparative_rule(split[0], offset, offset_fmted, after) return True # YYYYMMDD/to/YYYYMMDD -- check end and start date @@ -256,12 +250,12 @@ def date_check_comparative_rule(date, comp_rule: str): if len(split) == 5 and split[3].casefold() != "by".casefold(): raise DateError("Invalid date range") - check_single_date(split[0], offset, offset_fmted, after) - check_single_date(split[2], offset, offset_fmted, after) + _check_single_date_comparative_rule(split[0], offset, offset_fmted, after) + _check_single_date_comparative_rule(split[2], offset, offset_fmted, after) return True # YYYYMMDD/YYYYMMDD/YYYYMMDD/... -- check each date for s in split: - check_single_date(s, offset, offset_fmted, after) + _check_single_date_comparative_rule(s, offset, offset_fmted, after) return True diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py index 8c91a207..ac2a96b9 100644 --- a/tests/unit/test_date_check.py +++ b/tests/unit/test_date_check.py @@ -26,7 +26,6 @@ DateError, date_check, date_in_mars_rule, - expand_mars_dates, parse_mars_date_token, ) @@ -80,75 +79,6 @@ def test_positive_integer_not_supported_as_relative(self): parse_mars_date_token("1") -# --------------------------------------------------------------------------- -# expand_mars_dates -# --------------------------------------------------------------------------- - - -class TestExpandMarsDates: - # Single dates - def test_single_relative(self): - assert expand_mars_dates("-1") == [d(-1)] - assert expand_mars_dates("0") == [d(0)] - - def test_single_absolute(self): - assert expand_mars_dates("20250125") == [date(2025, 1, 25)] - assert expand_mars_dates("2023-04-23") == [date(2023, 4, 23)] - - # Lists - def test_list_relative(self): - assert expand_mars_dates("-1/-5/-10") == [d(-1), d(-5), d(-10)] - - def test_list_mixed_formats(self): - # from the spec: "20250125/-5/2023-04-23" - result = expand_mars_dates("20250125/-5/2023-04-23") - assert result == [date(2025, 1, 25), d(-5), date(2023, 4, 23)] - - def test_list_two_elements(self): - # Two-element list must NOT be mistaken for a range - result = expand_mars_dates("-1/-20") - assert result == [d(-1), d(-20)] - assert len(result) == 2 - - # Ranges - def test_range(self): - # Ascending range - result = expand_mars_dates("2025-01-01/to/2025-01-05") - assert result == [date(2025, 1, i) for i in range(1, 6)] - # Descending range is valid: start > end in calendar terms - result = expand_mars_dates("2025-01-05/to/2025-01-01") - assert result == [date(2025, 1, i) for i in range(5, 0, -1)] - # Relative descending: -1/to/-20 - result = expand_mars_dates("-1/to/-20") - assert result[0] == d(-1) - assert result[-1] == d(-20) - assert len(result) == 20 - - # Ranges with step - def test_stepped_range(self): - # Relative: -4/to/-20/by/4 -> -4, -8, -12, -16, -20 - result = expand_mars_dates("-4/to/-20/by/4") - assert result == [d(-4), d(-8), d(-12), d(-16), d(-20)] - # Absolute: 2024-02-21/to/2025-03-01/by/10 - result = expand_mars_dates("2024-02-21/to/2025-03-01/by/10") - assert result[0] == date(2024, 2, 21) - assert all((r - date(2024, 2, 21)).days % 10 == 0 for r in result) - # Symmetric: forward and backward should produce same set - fwd = set(expand_mars_dates("-4/to/-20/by/4")) - rev = set(expand_mars_dates("-20/to/-4/by/4")) - assert fwd == rev - # Uneven step: stops before reaching exact end - result = expand_mars_dates("-4/to/-21/by/4") - assert d(-20) in result - assert d(-21) not in result - - def test_case_insensitive_to(self): - assert expand_mars_dates("-1/TO/-3") == expand_mars_dates("-1/to/-3") - - def test_case_insensitive_by(self): - assert expand_mars_dates("-4/to/-20/BY/4") == expand_mars_dates("-4/to/-20/by/4") - - # --------------------------------------------------------------------------- # date_in_mars_rule # --------------------------------------------------------------------------- From 54c318529b7eeb1845fc1d11f6bfaedebcb7c545 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 06:28:56 +0000 Subject: [PATCH 05/14] fix(date_check): rule issues raise ServerError --- .../common/datasource/date_check.py | 18 +++-- tests/unit/test_datasource_matching.py | 77 +----------------- tests/unit/test_date_check.py | 79 ++++++++++++++++++- 3 files changed, 92 insertions(+), 82 deletions(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index 0557e618..bf0cd1ea 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -194,15 +194,19 @@ def date_in_mars_rule(date_d: date, rule_str: str) -> bool: if len(rule_parts) >= 3 and rule_parts[1].strip().lower() == "to": if len(rule_parts) != 3: raise ServerError(f"'by' is not supported in date rules: {rule_str!r}") - start_d = parse_mars_date_token(rule_parts[0]).date() - end_d = parse_mars_date_token(rule_parts[2]).date() + try: + start_d = parse_mars_date_token(rule_parts[0]).date() + end_d = parse_mars_date_token(rule_parts[2]).date() + except DateError as e: + raise ServerError(f"Invalid date token in rule {rule_str!r}: {e}") from e return min(start_d, end_d) <= date_d <= max(start_d, end_d) - # List or single: date must equal one of the listed tokens - for part in rule_parts: - if parse_mars_date_token(part).date() == date_d: - return True - return False + # List or single: validate all tokens first (malformed rule = ServerError), then match + try: + parsed_rule_parts = [parse_mars_date_token(part).date() for part in rule_parts] + except DateError as e: + raise ServerError(f"Invalid date token in rule {rule_str!r}: {e}") from e + return date_d in parsed_rule_parts def date_check_comparative_rule(date: str | list[str], comp_rule: str) -> bool: diff --git a/tests/unit/test_datasource_matching.py b/tests/unit/test_datasource_matching.py index 192a76f1..998fabc2 100644 --- a/tests/unit/test_datasource_matching.py +++ b/tests/unit/test_datasource_matching.py @@ -53,62 +53,13 @@ def setup_method(self): def _mock_auth(self, monkeypatch): monkeypatch.setattr("polytope_server.common.user.User.has_access", lambda *args, **kwargs: True) - def test_mars_created_correctly(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - assert "success" == DataSource.match(self.mars_config, user_request, None) - def test_mars_match_date(self, monkeypatch, user_request): + # Smoke test: date routing through DataSource.match works for pass and fail self._mock_auth(monkeypatch) + assert "success" == DataSource.match(self.mars_config, user_request, None) req = set_request_date(user_request, -5) assert "success" != DataSource.match(self.mars_config, req, None) - def test_mars_match_date_range(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date_range(user_request, -60, -40) - assert "success" == DataSource.match(self.mars_config, req, None) - req = set_request_date_range(user_request, -60, -25) - assert "success" != DataSource.match(self.mars_config, req, None) - - def test_mars_match_date_list2(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date_list(user_request, -60, -40) - assert "success" == DataSource.match(self.mars_config, req, None) - req = set_request_date_list(user_request, -60, -25) - assert "success" != DataSource.match(self.mars_config, req, None) - - def test_mars_match_date_list3(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date_list(user_request, -60, -40, -35) - assert "success" == DataSource.match(self.mars_config, req, None) - req = set_request_date_list(user_request, -60, -25, -35) - assert "success" != DataSource.match(self.mars_config, req, None) - - def test_mars_match_date_list4(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date_list(user_request, -60, -40, -35, -36) - assert "success" == DataSource.match(self.mars_config, req, None) - req = set_request_date_list(user_request, -60, -25, -35, -36) - assert "success" != DataSource.match(self.mars_config, req, None) - - def test_mars_match_date_list5(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date_list(user_request, -60, -40, -35, -36, -37) - assert "success" == DataSource.match(self.mars_config, req, None) - req = set_request_date_list(user_request, -60, -25, -35, -36, -37) - assert "success" != DataSource.match(self.mars_config, req, None) - - def test_mars_match_date_future(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date(user_request, 365 * 1000) - assert "success" != DataSource.match(self.mars_config, req, None) - - def test_mars_match_inverse_date_range_step(self, monkeypatch, user_request): - self._mock_auth(monkeypatch) - req = set_request_date_range(user_request, -40, -60) - assert "success" == DataSource.match(self.mars_config, req, None) - req = set_request_date_range(user_request, -10, -45) - assert "success" != DataSource.match(self.mars_config, req, None) - def test_mars_match_two_lists(self, monkeypatch, user_request): self._mock_auth(monkeypatch) req = user_request @@ -140,27 +91,5 @@ def test_mars_match_rule_formatting(self, monkeypatch, user_request): def set_request_date(user_request, days_offset): date = datetime.today() + timedelta(days=days_offset) - datefmted = date.strftime("%Y%m%d") - user_request["date"] = datefmted - return user_request - - -def set_request_date_range(user_request, days_offset, days_end_offset, step=1): - date = datetime.today() + timedelta(days=days_offset) - datefmted = date.strftime("%Y%m%d") - date_end = datetime.today() + timedelta(days=days_end_offset) - date_endfmted = date_end.strftime("%Y%m%d") - step_string = "" - if step != 1: - step_string = "/by/" + str(step) - user_request["date"] = datefmted + "/to/" + date_endfmted + step_string - return user_request - - -def set_request_date_list(user_request, *days_offset): - date_string = "" - for i in days_offset: - date = datetime.today() + timedelta(days=i) - date_string += date.strftime("%Y%m%d") + "/" - user_request["date"] = date_string[:-1] + user_request["date"] = date.strftime("%Y%m%d") return user_request diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py index ac2a96b9..ca0b9420 100644 --- a/tests/unit/test_date_check.py +++ b/tests/unit/test_date_check.py @@ -25,9 +25,11 @@ from polytope_server.common.datasource.date_check import ( DateError, date_check, + date_check_comparative_rule, date_in_mars_rule, parse_mars_date_token, ) +from polytope_server.common.exceptions import ServerError def d(offset): @@ -117,9 +119,18 @@ def test_range(self): # Stepped range rules def test_stepped_range_in_rule_raises(self): - with pytest.raises(DateError): + with pytest.raises(ServerError): date_in_mars_rule(d(-4), "-4/to/-20/by/4") + # Malformed rule tokens + def test_invalid_token_in_range_rule_raises_server_error(self): + with pytest.raises(ServerError): + date_in_mars_rule(d(-1), "notadate/to/-20") + + def test_invalid_token_in_list_rule_raises_server_error(self): + with pytest.raises(ServerError): + date_in_mars_rule(d(-1), "-1/notadate/-10") + # --------------------------------------------------------------------------- # date_check — from the user's examples @@ -206,6 +217,24 @@ def test_user_date_range_or_logic(self): def test_empty_allowed_values(self): assert date_check(ds(-1), []) is True + # --- ServerError cases --- + + def test_rules_not_a_list_raises_server_error(self): + with pytest.raises(ServerError): + date_check(ds(-1), ">30d") + + def test_mixed_rule_styles_raises_server_error(self): + with pytest.raises(ServerError): + date_check(ds(-1), [">30d", "-1/to/-20"]) + + def test_invalid_token_in_rule_raises_server_error(self): + with pytest.raises(ServerError): + date_check(ds(-1), ["notadate"]) + + def test_invalid_token_in_range_rule_raises_server_error(self): + with pytest.raises(ServerError): + date_check(ds(-1), ["notadate/to/-20"]) + # --------------------------------------------------------------------------- # date_check — comparative rules: backward compatibility @@ -236,3 +265,51 @@ def test_edges(self): date_check(ds(-29), [">30d"]) # Exactly 31 days ago should satisfy ">30d" assert date_check(ds(-30), [">30d"]) is True + + def test_date_list(self): + assert date_check(f"{ds(-32)}/{ds(-40)}/{ds(-50)}", [">30d"]) is True + with pytest.raises(DateError): + date_check(f"{ds(-32)}/{ds(-40)}/{ds(-5)}", [">30d"]) + + def test_invalid_date_in_input_raises_date_error(self): + with pytest.raises(DateError): + date_check("notadate", [">30d"]) + + +# --------------------------------------------------------------------------- +# date_check_comparative_rule — direct tests +# --------------------------------------------------------------------------- + + +class TestDateCheckComparativeRule: + def test_single_pass(self): + assert date_check_comparative_rule(ds(-32), ">30d") is True + + def test_single_fail(self): + with pytest.raises(DateError): + date_check_comparative_rule(ds(-5), ">30d") + + def test_range_pass(self): + assert date_check_comparative_rule(f"{ds(-60)}/to/{ds(-40)}", ">30d") is True + + def test_range_fail(self): + with pytest.raises(DateError): + date_check_comparative_rule(f"{ds(-60)}/to/{ds(-25)}", ">30d") + + def test_stepped_range_pass(self): + assert date_check_comparative_rule(f"{ds(-60)}/to/{ds(-40)}/by/7", ">30d") is True + + def test_stepped_range_fail(self): + with pytest.raises(DateError): + date_check_comparative_rule(f"{ds(-60)}/to/{ds(-25)}/by/7", ">30d") + + def test_list_pass(self): + assert date_check_comparative_rule(f"{ds(-32)}/{ds(-40)}/{ds(-50)}", ">30d") is True + + def test_list_fail(self): + with pytest.raises(DateError): + date_check_comparative_rule(f"{ds(-32)}/{ds(-40)}/{ds(-5)}", ">30d") + + def test_invalid_operator_raises_server_error(self): + with pytest.raises(ServerError): + date_check_comparative_rule(ds(-32), "=30d") From 0adfdc0f9a106db384b1493d5b2ab1b5fef5e8b2 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 07:35:20 +0000 Subject: [PATCH 06/14] test(date_check): list rules incorrectly allow ranges --- tests/unit/test_date_check.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py index ca0b9420..ee81a97f 100644 --- a/tests/unit/test_date_check.py +++ b/tests/unit/test_date_check.py @@ -149,6 +149,10 @@ def test_list_rule(self): with pytest.raises(DateError): date_check(ds(-3), ["-1/-5/-10"]) + def test_list_rule_range_input(self): + with pytest.raises(DateError): + date_check(f"{ds(-1)}/to/{ds(-10)}", ["-1/-5/-10"]) + def test_range_rule(self): assert date_check(ds(-1), ["-1/to/-20"]) is True assert date_check(ds(-10), ["-1/to/-20"]) is True From 574adc6bf212452152eed5b8c2d9e297a2e0a3c7 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 07:48:10 +0000 Subject: [PATCH 07/14] fix(date_check): list rules no longer allow range matches --- polytope_server/common/datasource/date_check.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index bf0cd1ea..a4dfa562 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -54,7 +54,8 @@ def date_check(date: str, rules: list[str]) -> bool: # New-style Mars date rules. date_parts = date.split("/") if len(date_parts) >= 3 and date_parts[1].strip().lower() == "to": - # Range: both boundaries must be covered by the same rule. + # Range: both boundaries must be covered by the same *range* rule. + # A list rule cannot cover a continuous range (intermediate dates would be unaccounted for). start_d = parse_mars_date_token(date_parts[0]).date() end_d = parse_mars_date_token(date_parts[2]).date() if len(date_parts) == 5: @@ -62,7 +63,10 @@ def date_check(date: str, rules: list[str]) -> bool: raise DateError(f"Invalid Mars date string: {date!r}") elif len(date_parts) != 3: raise DateError(f"Invalid Mars date string: {date!r}") - if not any(date_in_mars_rule(start_d, rule) and date_in_mars_rule(end_d, rule) for rule in rules): + if not any( + _is_mars_range_rule(rule) and date_in_mars_rule(start_d, rule) and date_in_mars_rule(end_d, rule) + for rule in rules + ): raise DateError( f"Date range {start_d} to {end_d} is not fully covered by any single allowed date rule: {rules}" ) @@ -142,6 +146,12 @@ def _is_comparative_rule(rule: str) -> bool: return rule.strip()[0] in (">", "<") +def _is_mars_range_rule(rule_str: str) -> bool: + """Returns True if the Mars rule is a range (A/to/B).""" + parts = rule_str.split("/") + return len(parts) >= 2 and parts[1].strip().lower() == "to" + + def parse_mars_date_token(token: str) -> datetime: """Parse a single Mars date token to a datetime. From 2c944b52a8af40d8696bbd0d80f01f518aa234b6 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 08:11:35 +0000 Subject: [PATCH 08/14] fix(test): add missed assert Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/unit/test_date_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py index ee81a97f..80442ec1 100644 --- a/tests/unit/test_date_check.py +++ b/tests/unit/test_date_check.py @@ -216,7 +216,7 @@ def test_user_date_range_or_logic(self): with pytest.raises(DateError): date_check(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "-30"]) - date_check("-5/-30", ["-1/to/-20", "-30"]) is True + assert date_check("-5/-30", ["-1/to/-20", "-30"]) is True def test_empty_allowed_values(self): assert date_check(ds(-1), []) is True From 8443461cff4ea523648f74d982dcac2e2b30fce7 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 08:08:12 +0000 Subject: [PATCH 09/14] chore: update schema --- docs/source/schemas/schema.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/source/schemas/schema.json b/docs/source/schemas/schema.json index 0e63fd75..f5e3f72f 100644 --- a/docs/source/schemas/schema.json +++ b/docs/source/schemas/schema.json @@ -348,7 +348,7 @@ }, "match": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match", - "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)." + "description": "filters requests based on request keys and values. Non-date keys accept scalar or list values. Date supports comparative rules (e.g. >30d, <40d) or MARS date rules (single/list/range). MARS rules do not support by-step." }, "patch": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Patch", @@ -455,7 +455,7 @@ }, "match": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match", - "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)." + "description": "filters requests based on request keys and values. Non-date keys accept scalar or list values. Date supports comparative rules (e.g. >30d, <40d) or MARS date rules (single/list/range). MARS rules do not support by-step." }, "patch": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Patch", @@ -474,11 +474,11 @@ }, "DatasourcesConfig-Datasource-MARS-Match": { "type": "object", - "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.", + "description": "Datasource match rules keyed by request parameter. For non-date keys, scalar and list forms are supported. For date, use either comparative rules (all rules must pass) or MARS date rules (no mixing with comparative rules).", "properties": { "date": { "$ref": "#/definitions/DatasourcesConfig-Datasource-MARS-Match-DateValue", - "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.", + "description": "Date matching rules. Comparative style (all rules must pass): >30d, <40d. MARS style (no mixing with comparative rules): single/list/range rules without by-step, e.g. -1/-5/-10 or -1/to/-20. User-supplied date ranges may include /by/N.", "examples": [ ">30d", [ @@ -489,6 +489,10 @@ [ "-1/-5/-10", "-20/to/-30" + ], + [ + "2024-02-21/to/2025-03-01", + "-1/-5/-10" ] ] }, @@ -1290,7 +1294,7 @@ "default": 6379 }, "db": { - "type": "interger", + "type": "integer", "description": "id of the database to use", "default": 0 } From 5df11a9dae125e19eee741ac22f9744736324dbe Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 08:24:59 +0000 Subject: [PATCH 10/14] chore(date_check): consistently raise/pass instead of return true/false --- .../common/datasource/datasource.py | 4 +- .../common/datasource/date_check.py | 21 ++-- tests/unit/test_date_check.py | 108 +++++++++--------- 3 files changed, 66 insertions(+), 67 deletions(-) diff --git a/polytope_server/common/datasource/datasource.py b/polytope_server/common/datasource/datasource.py index 78800b29..fc2be092 100644 --- a/polytope_server/common/datasource/datasource.py +++ b/polytope_server/common/datasource/datasource.py @@ -28,7 +28,7 @@ from ..config import polytope_config from ..request import PolytopeRequest, Verb from ..user import User -from .date_check import DateError, date_check +from .date_check import DateError, validate_date_match ####################################################### @@ -112,7 +112,7 @@ def match(ds_config, coerced_ur: Dict[str, Any], user: User) -> str: # Process date rules if rule_key == "date": try: - date_check(coerced_ur_copy["date"], allowed_values) + validate_date_match(coerced_ur_copy["date"], allowed_values) except DateError as e: return f"Skipping datasource {DataSource.repr(ds_config)}: {e}." except Exception as e: diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index a4dfa562..5fd358b9 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -12,7 +12,7 @@ class DateError(Exception): pass -def date_check(date: str, rules: list[str]) -> bool: +def validate_date_match(date: str, rules: list[str]) -> None: """ Check a Mars-format date string against a list of allowed date rules. @@ -29,7 +29,7 @@ def date_check(date: str, rules: list[str]) -> bool: Each user date or date range must be fully covered by AT LEAST ONE rule (OR logic). - :returns: ``True`` if the date passes all checks. + :returns: ``None``. Returns normally when the date passes all checks. :raises ServerError: If ``rules`` is not a list, or if comparative and Mars-style rules are mixed. :raises DateError: If the date does not satisfy the rules. @@ -44,9 +44,8 @@ def date_check(date: str, rules: list[str]) -> bool: if all(are_comparative_rules): # Comparative: every rule must pass for rule in rules: - if not date_check_comparative_rule(date, rule): - return False - return True + validate_comparative_date_rule(date, rule) + return if any(are_comparative_rules): raise ServerError("Cannot mix comparative and new-style date rules in a single match.") @@ -76,7 +75,7 @@ def date_check(date: str, rules: list[str]) -> bool: if not any(date_in_mars_rule(user_date, rule) for rule in rules): raise DateError(f"Date {user_date} does not match any allowed date rule: {rules}") - return True + return def _check_single_date_comparative_rule(date: str, offset: datetime, offset_fmted: str, after: bool = False) -> None: @@ -219,7 +218,7 @@ def date_in_mars_rule(date_d: date, rule_str: str) -> bool: return date_d in parsed_rule_parts -def date_check_comparative_rule(date: str | list[str], comp_rule: str) -> bool: +def validate_comparative_date_rule(date: str | list[str], comp_rule: str) -> None: """ Check a date (or list/range of dates) against a single comparative rule. @@ -228,7 +227,7 @@ def date_check_comparative_rule(date: str | list[str], comp_rule: str) -> bool: :param comp_rule: A comparative rule in the form ``>Nd``, ``Nh``, or ``30d"``, ``"<2h"``. - :returns: ``True`` if all dates in ``date`` satisfy the rule. + :returns: ``None``. Returns normally if all dates in ``date`` satisfy the rule. :raises DateError: If a date is invalid or falls outside the allowed range. :raises ServerError: If the comparison operator is not ``<`` or ``>``. """ @@ -255,7 +254,7 @@ def date_check_comparative_rule(date: str | list[str], comp_rule: str) -> bool: # YYYYMMDD if len(split) == 1: _check_single_date_comparative_rule(split[0], offset, offset_fmted, after) - return True + return # YYYYMMDD/to/YYYYMMDD -- check end and start date # YYYYMMDD/to/YYYYMMDD/by/N -- check end and start date @@ -266,10 +265,10 @@ def date_check_comparative_rule(date: str | list[str], comp_rule: str) -> bool: _check_single_date_comparative_rule(split[0], offset, offset_fmted, after) _check_single_date_comparative_rule(split[2], offset, offset_fmted, after) - return True + return # YYYYMMDD/YYYYMMDD/YYYYMMDD/... -- check each date for s in split: _check_single_date_comparative_rule(s, offset, offset_fmted, after) - return True + return diff --git a/tests/unit/test_date_check.py b/tests/unit/test_date_check.py index 80442ec1..c71996ef 100644 --- a/tests/unit/test_date_check.py +++ b/tests/unit/test_date_check.py @@ -24,10 +24,10 @@ from polytope_server.common.datasource.date_check import ( DateError, - date_check, - date_check_comparative_rule, date_in_mars_rule, parse_mars_date_token, + validate_comparative_date_rule, + validate_date_match, ) from polytope_server.common.exceptions import ServerError @@ -139,105 +139,105 @@ def test_invalid_token_in_list_rule_raises_server_error(self): class TestDateCheckNewStyle: def test_single_relative(self): - assert date_check(ds(-1), ["-1"]) is True + validate_date_match(ds(-1), ["-1"]) with pytest.raises(DateError): - date_check(ds(-2), ["-1"]) + validate_date_match(ds(-2), ["-1"]) def test_list_rule(self): - assert date_check(ds(-1), ["-1/-5/-10"]) is True - assert date_check(ds(-5), ["-1/-5/-10"]) is True + validate_date_match(ds(-1), ["-1/-5/-10"]) + validate_date_match(ds(-5), ["-1/-5/-10"]) with pytest.raises(DateError): - date_check(ds(-3), ["-1/-5/-10"]) + validate_date_match(ds(-3), ["-1/-5/-10"]) def test_list_rule_range_input(self): with pytest.raises(DateError): - date_check(f"{ds(-1)}/to/{ds(-10)}", ["-1/-5/-10"]) + validate_date_match(f"{ds(-1)}/to/{ds(-10)}", ["-1/-5/-10"]) def test_range_rule(self): - assert date_check(ds(-1), ["-1/to/-20"]) is True - assert date_check(ds(-10), ["-1/to/-20"]) is True + validate_date_match(ds(-1), ["-1/to/-20"]) + validate_date_match(ds(-10), ["-1/to/-20"]) with pytest.raises(DateError): - date_check(ds(-21), ["-1/to/-20"]) + validate_date_match(ds(-21), ["-1/to/-20"]) def test_mixed_formats(self): rule = ["20250125/-5/2023-04-23"] - assert date_check("20250125", rule) is True - assert date_check(ds(-5), rule) is True + validate_date_match("20250125", rule) + validate_date_match(ds(-5), rule) with pytest.raises(DateError): - date_check("20250126", rule) + validate_date_match("20250126", rule) # --- Multiple rules: OR logic --- def test_or_logic(self): # Matches first rule - assert date_check(ds(-1), ["-1/-5/-10", "-20/to/-30"]) is True + validate_date_match(ds(-1), ["-1/-5/-10", "-20/to/-30"]) # Matches second rule - assert date_check(ds(-25), ["-1/-5/-10", "-20/to/-30"]) is True + validate_date_match(ds(-25), ["-1/-5/-10", "-20/to/-30"]) # Matches no rule with pytest.raises(DateError): - date_check(ds(-15), ["-1/-5/-10", "-20/to/-30"]) + validate_date_match(ds(-15), ["-1/-5/-10", "-20/to/-30"]) # --- User date as Mars string (range/list) --- def test_user_date_range(self): # All within rule - assert date_check(f"{ds(-5)}/to/{ds(-10)}", ["-1/to/-20"]) is True + validate_date_match(f"{ds(-5)}/to/{ds(-10)}", ["-1/to/-20"]) # Partially outside rule with pytest.raises(DateError): - date_check(f"{ds(-1)}/to/{ds(-25)}", ["-1/to/-20"]) + validate_date_match(f"{ds(-1)}/to/{ds(-25)}", ["-1/to/-20"]) def test_user_date_list(self): # All match - assert date_check(f"{ds(-1)}/{ds(-5)}/{ds(-10)}", ["-1/to/-20"]) is True + validate_date_match(f"{ds(-1)}/{ds(-5)}/{ds(-10)}", ["-1/to/-20"]) # One outside with pytest.raises(DateError): - date_check(f"{ds(-1)}/{ds(-5)}/{ds(-25)}", ["-1/to/-20"]) + validate_date_match(f"{ds(-1)}/{ds(-5)}/{ds(-25)}", ["-1/to/-20"]) def test_user_date_stepped_range(self): # Subset of allowed range: only boundaries are checked - assert date_check(f"{ds(-4)}/to/{ds(-20)}/by/4", ["-1/to/-20"]) is True + validate_date_match(f"{ds(-4)}/to/{ds(-20)}/by/4", ["-1/to/-20"]) # Exceeds rule with pytest.raises(DateError): - date_check(f"{ds(-4)}/to/{ds(-24)}/by/4", ["-1/to/-20"]) + validate_date_match(f"{ds(-4)}/to/{ds(-24)}/by/4", ["-1/to/-20"]) def test_user_date_range_or_logic(self): # Matches first rule - assert date_check(f"{ds(-5)}/to/{ds(-10)}", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) is True + validate_date_match(f"{ds(-5)}/to/{ds(-10)}", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) # Matches second rule - assert date_check("2024-01-01/to/2024-08-31", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) is True + validate_date_match("2024-01-01/to/2024-08-31", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) # Start matches one rule, end another with pytest.raises(DateError): - date_check(f"{ds(-5)}/to/2024-08-31", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) + validate_date_match(f"{ds(-5)}/to/2024-08-31", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) # Matches no rule with pytest.raises(DateError): - date_check(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) + validate_date_match(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "2024-01-01/to/2024-12-31"]) # start matches one rule, but end matches another with pytest.raises(DateError): - date_check(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "-30"]) + validate_date_match(f"{ds(-5)}/to/{ds(30)}", ["-1/to/-20", "-30"]) - assert date_check("-5/-30", ["-1/to/-20", "-30"]) is True + validate_date_match("-5/-30", ["-1/to/-20", "-30"]) def test_empty_allowed_values(self): - assert date_check(ds(-1), []) is True + validate_date_match(ds(-1), []) # --- ServerError cases --- def test_rules_not_a_list_raises_server_error(self): with pytest.raises(ServerError): - date_check(ds(-1), ">30d") + validate_date_match(ds(-1), ">30d") def test_mixed_rule_styles_raises_server_error(self): with pytest.raises(ServerError): - date_check(ds(-1), [">30d", "-1/to/-20"]) + validate_date_match(ds(-1), [">30d", "-1/to/-20"]) def test_invalid_token_in_rule_raises_server_error(self): with pytest.raises(ServerError): - date_check(ds(-1), ["notadate"]) + validate_date_match(ds(-1), ["notadate"]) def test_invalid_token_in_range_rule_raises_server_error(self): with pytest.raises(ServerError): - date_check(ds(-1), ["notadate/to/-20"]) + validate_date_match(ds(-1), ["notadate/to/-20"]) # --------------------------------------------------------------------------- @@ -247,37 +247,37 @@ def test_invalid_token_in_range_rule_raises_server_error(self): class TestDateCheckComparative: def test_single(self): - assert date_check(ds(-32), [">30d"]) is True + validate_date_match(ds(-32), [">30d"]) with pytest.raises(DateError): - date_check(ds(-5), [">30d"]) + validate_date_match(ds(-5), [">30d"]) def test_multiple_and_logic(self): # Both conditions must be satisfied - assert date_check(ds(-32), [">30d", "<40d"]) is True + validate_date_match(ds(-32), [">30d", "<40d"]) with pytest.raises(DateError): - date_check(ds(-32), [">30d", "<20d"]) + validate_date_match(ds(-32), [">30d", "<20d"]) def test_range(self): # All dates in range must satisfy rule - assert date_check(f"{ds(-60)}/to/{ds(-40)}", [">30d"]) is True + validate_date_match(f"{ds(-60)}/to/{ds(-40)}", [">30d"]) with pytest.raises(DateError): - date_check(f"{ds(-60)}/to/{ds(-25)}", [">30d"]) + validate_date_match(f"{ds(-60)}/to/{ds(-25)}", [">30d"]) def test_edges(self): # Edge cases: exactly 30 days ago should NOT satisfy ">30d" with pytest.raises(DateError): - date_check(ds(-29), [">30d"]) + validate_date_match(ds(-29), [">30d"]) # Exactly 31 days ago should satisfy ">30d" - assert date_check(ds(-30), [">30d"]) is True + validate_date_match(ds(-30), [">30d"]) def test_date_list(self): - assert date_check(f"{ds(-32)}/{ds(-40)}/{ds(-50)}", [">30d"]) is True + validate_date_match(f"{ds(-32)}/{ds(-40)}/{ds(-50)}", [">30d"]) with pytest.raises(DateError): - date_check(f"{ds(-32)}/{ds(-40)}/{ds(-5)}", [">30d"]) + validate_date_match(f"{ds(-32)}/{ds(-40)}/{ds(-5)}", [">30d"]) def test_invalid_date_in_input_raises_date_error(self): with pytest.raises(DateError): - date_check("notadate", [">30d"]) + validate_date_match("notadate", [">30d"]) # --------------------------------------------------------------------------- @@ -287,33 +287,33 @@ def test_invalid_date_in_input_raises_date_error(self): class TestDateCheckComparativeRule: def test_single_pass(self): - assert date_check_comparative_rule(ds(-32), ">30d") is True + validate_comparative_date_rule(ds(-32), ">30d") def test_single_fail(self): with pytest.raises(DateError): - date_check_comparative_rule(ds(-5), ">30d") + validate_comparative_date_rule(ds(-5), ">30d") def test_range_pass(self): - assert date_check_comparative_rule(f"{ds(-60)}/to/{ds(-40)}", ">30d") is True + validate_comparative_date_rule(f"{ds(-60)}/to/{ds(-40)}", ">30d") def test_range_fail(self): with pytest.raises(DateError): - date_check_comparative_rule(f"{ds(-60)}/to/{ds(-25)}", ">30d") + validate_comparative_date_rule(f"{ds(-60)}/to/{ds(-25)}", ">30d") def test_stepped_range_pass(self): - assert date_check_comparative_rule(f"{ds(-60)}/to/{ds(-40)}/by/7", ">30d") is True + validate_comparative_date_rule(f"{ds(-60)}/to/{ds(-40)}/by/7", ">30d") def test_stepped_range_fail(self): with pytest.raises(DateError): - date_check_comparative_rule(f"{ds(-60)}/to/{ds(-25)}/by/7", ">30d") + validate_comparative_date_rule(f"{ds(-60)}/to/{ds(-25)}/by/7", ">30d") def test_list_pass(self): - assert date_check_comparative_rule(f"{ds(-32)}/{ds(-40)}/{ds(-50)}", ">30d") is True + validate_comparative_date_rule(f"{ds(-32)}/{ds(-40)}/{ds(-50)}", ">30d") def test_list_fail(self): with pytest.raises(DateError): - date_check_comparative_rule(f"{ds(-32)}/{ds(-40)}/{ds(-5)}", ">30d") + validate_comparative_date_rule(f"{ds(-32)}/{ds(-40)}/{ds(-5)}", ">30d") def test_invalid_operator_raises_server_error(self): with pytest.raises(ServerError): - date_check_comparative_rule(ds(-32), "=30d") + validate_comparative_date_rule(ds(-32), "=30d") From 9e31583841f5bb77141e9efcd665d66a6f206df0 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 09:08:37 +0000 Subject: [PATCH 11/14] fix(date_check): ranges have 3 string parts Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- polytope_server/common/datasource/date_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index 5fd358b9..1fb9605d 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -148,7 +148,7 @@ def _is_comparative_rule(rule: str) -> bool: def _is_mars_range_rule(rule_str: str) -> bool: """Returns True if the Mars rule is a range (A/to/B).""" parts = rule_str.split("/") - return len(parts) >= 2 and parts[1].strip().lower() == "to" + return len(parts) >= 3 and parts[1].strip().lower() == "to" def parse_mars_date_token(token: str) -> datetime: From c1edd3ba832a7b6dd5ffb87bb2878f382e34f4ab Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 09:09:39 +0000 Subject: [PATCH 12/14] fix: return None Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- polytope_server/common/datasource/date_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index 1fb9605d..6cf3feda 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -38,7 +38,7 @@ def validate_date_match(date: str, rules: list[str]) -> None: raise ServerError("Allowed values must be a list") if not rules: - return True + return are_comparative_rules = set(_is_comparative_rule(rule) for rule in rules) if all(are_comparative_rules): From 923356d1fbb0958f08aa42e74fd50d84d431ec06 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 09:10:12 +0000 Subject: [PATCH 13/14] chore(date_check): input sequence Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- polytope_server/common/datasource/date_check.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index 6cf3feda..b14942be 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -2,6 +2,7 @@ from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta +from collections.abc import Sequence from ..exceptions import ServerError @@ -12,14 +13,14 @@ class DateError(Exception): pass -def validate_date_match(date: str, rules: list[str]) -> None: +def validate_date_match(date: str, rules: Sequence[str]) -> None: """ Check a Mars-format date string against a list of allowed date rules. :param date: Date to check. Accepts a single date, a slash-separated list, or a range in Mars date format (e.g. ``-1``, ``-1/-5/-10``, ``20250101/to/20250131``, ``20250101/to/20250131/by/7``). - :param rules: List of rules. All rules must be the same style: + :param rules: List or tuple of rules. All rules must be the same style: - Comparative (e.g. ``>30d``, ``<40d``, ``>1h``, ``<2m``): Each date must satisfy ALL rules (AND logic). @@ -30,11 +31,11 @@ def validate_date_match(date: str, rules: list[str]) -> None: rule (OR logic). :returns: ``None``. Returns normally when the date passes all checks. - :raises ServerError: If ``rules`` is not a list, or if comparative and - Mars-style rules are mixed. + :raises ServerError: If ``rules`` is not a list-like sequence, or if comparative + and Mars-style rules are mixed. :raises DateError: If the date does not satisfy the rules. """ - if not isinstance(rules, list): + if not isinstance(rules, Sequence) or isinstance(rules, (str, bytes)): raise ServerError("Allowed values must be a list") if not rules: From 6106fb198f539b5b893a859dbc5e299cdebad9e1 Mon Sep 17 00:00:00 2001 From: Peter Tsrunchev Date: Mon, 16 Mar 2026 09:28:17 +0000 Subject: [PATCH 14/14] isort --- polytope_server/common/datasource/date_check.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polytope_server/common/datasource/date_check.py b/polytope_server/common/datasource/date_check.py index b14942be..b0520aaa 100644 --- a/polytope_server/common/datasource/date_check.py +++ b/polytope_server/common/datasource/date_check.py @@ -1,8 +1,8 @@ import re +from collections.abc import Sequence from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta -from collections.abc import Sequence from ..exceptions import ServerError