diff --git a/dateparser/date.py b/dateparser/date.py index cef23338b..74806fea1 100644 --- a/dateparser/date.py +++ b/dateparser/date.py @@ -1,6 +1,6 @@ import collections from collections.abc import Set -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import regex as re from dateutil.relativedelta import relativedelta @@ -16,6 +16,8 @@ from dateparser.utils import ( _get_missing_parts, apply_timezone_from_settings, + get_next_leap_year, + get_previous_leap_year, get_timezone_from_tz_string, set_correct_day_from_settings, set_correct_month_from_settings, @@ -175,6 +177,37 @@ def get_date_from_timestamp(date_string, settings, negative=False): return date_obj +def _apply_century_preference(date_obj, now, prefer_from): + """Shift *date_obj* by ±100 years to satisfy PREFER_DATES_FROM. + + *now* is normalised to naive so a tz-aware RELATIVE_BASE does not raise + TypeError. When the shifted year falls on Feb 29 in a non-leap year, the + nearest valid leap year in the preferred direction is used — matching the + behaviour of the NLP path in ``parser.py``. + + Returns the adjusted datetime, or the original when no shift is needed. + """ + now_naive = now.replace(tzinfo=None) if now.tzinfo is not None else now + + if now_naive < date_obj and prefer_from == "past": + target_year = date_obj.year - 100 + elif now_naive >= date_obj and prefer_from == "future": + target_year = date_obj.year + 100 + else: + return date_obj + + try: + return date_obj.replace(year=target_year) + except ValueError: + # Feb 29 shifted into a non-leap year — find the nearest valid one + target_year = ( + get_next_leap_year(target_year) + if prefer_from == "future" + else get_previous_leap_year(target_year) + ) + return date_obj.replace(year=target_year) + + def parse_with_formats(date_string, date_formats, settings): """Parse with formats and return a dictionary with 'period' and 'obj_date'. @@ -204,9 +237,15 @@ def parse_with_formats(date_string, date_formats, settings): period = "month" date_obj = set_correct_day_from_settings(date_obj, settings) + now = settings.RELATIVE_BASE or datetime.now(tz=timezone.utc).replace( + tzinfo=None + ) if not ("%y" in date_format or "%Y" in date_format): - today = datetime.today() - date_obj = date_obj.replace(year=today.year) + date_obj = date_obj.replace(year=now.year) + elif "%y" in date_format and "%Y" not in date_format: + date_obj = _apply_century_preference( + date_obj, now, settings.PREFER_DATES_FROM + ) date_obj = apply_timezone_from_settings(date_obj, settings) diff --git a/tests/test_date_parser.py b/tests/test_date_parser.py index 2233ec8d1..4bbf36c55 100644 --- a/tests/test_date_parser.py +++ b/tests/test_date_parser.py @@ -1701,6 +1701,149 @@ def test_word_numbers_advanced(self, date_string, expected_delta, description): f"{description}: Expected {expected}, got {result}", ) + @parameterized.expand( + [ + # Test PREFER_DATES_FROM='past' with 2-digit year format (Issue #445) + param( + date_string="1/15/64", + date_format="%m/%d/%y", + prefer_from="past", + expected_year=1964, + description="PREFER_DATES_FROM='past' with 2-digit year", + ), + # Test PREFER_DATES_FROM='future' with 2-digit year format + param( + date_string="1/15/64", + date_format="%m/%d/%y", + prefer_from="future", + expected_year=2064, + description="PREFER_DATES_FROM='future' with 2-digit year", + ), + # Test with past date and prefer future + param( + date_string="1/15/24", + date_format="%m/%d/%y", + prefer_from="future", + expected_year=2124, + description="Past date (24) with PREFER_DATES_FROM='future'", + ), + # Test with future date and prefer past + param( + date_string="1/15/35", + date_format="%m/%d/%y", + prefer_from="past", + expected_year=1935, + description="Future date (35) with PREFER_DATES_FROM='past'", + ), + # Test 4-digit year format (should not apply PREFER_DATES_FROM adjustment) + param( + date_string="1/15/2050", + date_format="%m/%d/%Y", + prefer_from="past", + expected_year=2050, + description="4-digit year format ignores PREFER_DATES_FROM", + ), + ] + ) + def test_prefer_dates_from_with_date_formats( + self, + date_string, + date_format, + prefer_from, + expected_year, + description, + ): + """Test that PREFER_DATES_FROM setting works with date_formats parameter (Issue #445).""" + result = parse( + date_string, + date_formats=[date_format], + settings={ + "PREFER_DATES_FROM": prefer_from, + "RELATIVE_BASE": datetime(2026, 1, 1), + }, + ) + + self.assertIsNotNone(result, f"Failed to parse: {description}") + self.assertEqual( + expected_year, + result.year, + f"{description}: Expected year {expected_year}, got {result.year}", + ) + + def test_prefer_dates_from_with_date_formats_and_relative_base(self): + """Test that PREFER_DATES_FROM respects RELATIVE_BASE with date_formats.""" + # Use a relative base in the past (e.g., 1980-01-01) + # When parsing '1/15/64' with RELATIVE_BASE='1980-01-01' and PREFER_DATES_FROM='past', + # it should be interpreted as 1964 (past relative to 1980) + relative_base = datetime(1980, 1, 1) + result = parse( + "1/15/64", + date_formats=["%m/%d/%y"], + settings={ + "PREFER_DATES_FROM": "past", + "RELATIVE_BASE": relative_base, + }, + ) + + self.assertIsNotNone(result) + self.assertEqual( + 1964, + result.year, + f"RELATIVE_BASE not respected: Expected 1964, got {result.year}", + ) + + # Now test with a relative base in the future + # When parsing '1/15/64' with RELATIVE_BASE='2020-01-01' and PREFER_DATES_FROM='future', + # it should be interpreted as 2064 (future relative to 2020) + relative_base = datetime(2020, 1, 1) + result = parse( + "1/15/64", + date_formats=["%m/%d/%y"], + settings={ + "PREFER_DATES_FROM": "future", + "RELATIVE_BASE": relative_base, + }, + ) + + self.assertIsNotNone(result) + self.assertEqual( + 2064, + result.year, + f"RELATIVE_BASE not respected: Expected 2064, got {result.year}", + ) + + def test_prefer_dates_from_with_date_formats_tz_aware_relative_base(self): + """Test that a tz-aware RELATIVE_BASE does not crash (Bug #1 fix).""" + from datetime import timezone as tz + + result = parse( + "1/15/64", + date_formats=["%m/%d/%y"], + settings={ + "PREFER_DATES_FROM": "past", + "RELATIVE_BASE": datetime(1980, 1, 1, tzinfo=tz.utc), + }, + ) + self.assertIsNotNone(result) + self.assertEqual(1964, result.year) + + def test_prefer_dates_from_with_date_formats_feb29_non_leap(self): + """Test that Feb 29 shifted to a non-leap year finds the next valid leap year (Bug #2 fix).""" + # 2/29/00 parses as 2000-02-29; shifting +100 → 2100 which is not a leap year. + # _apply_century_preference finds the next valid leap year: 2104. + result = parse( + "2/29/00", + date_formats=["%m/%d/%y"], + settings={ + "PREFER_DATES_FROM": "future", + "RELATIVE_BASE": datetime(2026, 1, 1), + }, + ) + self.assertIsNotNone(result) + self.assertEqual(2104, result.year) + self.assertEqual(2, result.month) + self.assertEqual(29, result.day) + if __name__ == "__main__": unittest.main()