Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions dateparser/date.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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'.

Expand Down Expand Up @@ -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)

Expand Down
143 changes: 143 additions & 0 deletions tests/test_date_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading