import contextlib
from datetime import datetime, time, timezone, tzinfo
from typing import TYPE_CHECKING, Any
import regex as re
from dateutil.relativedelta import relativedelta
from dateparser.utils import (
_get_localzone,
apply_timezone,
localize_timezone,
set_correct_day_from_settings,
set_correct_month_from_settings,
strip_braces,
)
from .parser import time_parser
from .timezone_parser import pop_tz_offset_from_string
if TYPE_CHECKING:
from .conf import Settings
from .date import DateData
_UNITS = r"century|decade|fortnight|year|month|week|day|hour|minute|second"
# A number that continues a date or a time, e.g. the year in "03/06/2020 year",
# is not a quantity, and a string with one is not a relative date.
PATTERN = re.compile(
rf"(?<![\d/.:])([+-]?\s*\d++[.,]?\d*+)\s*({_UNITS})\b", re.I | re.S | re.U
)
_GLUED_QUANTITY = re.compile(rf"\d\s*(?:{_UNITS})\b", re.I)
# "the 1st of last month" translates to " 1 1 month ago".
_DAY_OF_MONTH = re.compile(r"^\s*(\d{1,2})\s+(?=(?:in\s+)?\d+\s+month\b)")
[docs]
class FreshnessDateDataParser:
"""Parses date string like "1 year, 2 months ago" and "3 hours, 50 minutes ago"."""
def _are_all_words_units(self, date_string: str) -> bool:
skip = [_UNITS, r"ago|in|\d+", r":|[ap]m"]
if _GLUED_QUANTITY.search(PATTERN.sub("", date_string)):
return False
date_string = re.sub(r"\s+", " ", date_string.strip())
words = [x for x in re.split(r"\W", date_string) if x]
words = [x for x in words if not re.match("|".join(skip), x)]
return not words
def _parse_time(self, date_string: str, settings: "Settings") -> time | None:
"""Attempts to parse time part of date strings like '1 day ago, 2 PM'"""
date_string = PATTERN.sub("", date_string)
date_string = re.sub(r"\b(?:ago|in)\b", "", date_string)
with contextlib.suppress(Exception):
return time_parser(date_string)
return None
[docs]
def get_local_tz(self) -> tzinfo:
return _get_localzone()
[docs]
def parse(
self, date_string: str, settings: "Settings"
) -> tuple[datetime | None, str | None]:
date, period, _ = self._parse_with_parts(date_string, settings)
return date, period
def _parse_with_parts( # noqa: PLR0912, PLR0915
self, date_string: str, settings: "Settings"
) -> tuple[datetime | None, str | None, tuple[str, ...]]:
date_string = strip_braces(date_string)
date_string, ptz = pop_tz_offset_from_string(date_string, settings=settings)
day = None
if match := _DAY_OF_MONTH.match(date_string):
day = int(match[1])
date_string = date_string[match.end() :]
_time = self._parse_time(date_string, settings)
_settings_tz = settings.TIMEZONE.lower()
def apply_time(dateobj: datetime, timeobj: time | None) -> datetime:
if not isinstance(timeobj, time):
return dateobj
return dateobj.replace(
hour=timeobj.hour,
minute=timeobj.minute,
second=timeobj.second,
microsecond=timeobj.microsecond,
)
if settings.RELATIVE_BASE:
now = settings.RELATIVE_BASE
if "local" not in _settings_tz:
now = localize_timezone(now, settings.TIMEZONE)
if ptz:
now = now.astimezone(ptz) if now.tzinfo else ptz.localize(now)
if not now.tzinfo:
now = now.replace(tzinfo=self.get_local_tz())
elif ptz:
localized_now = datetime.now(ptz)
if "local" in _settings_tz:
now = localized_now
else:
now = apply_timezone(localized_now, settings.TIMEZONE)
elif "local" not in _settings_tz:
utc_dt = datetime.now(tz=timezone.utc)
now = apply_timezone(utc_dt, settings.TIMEZONE)
else:
now = datetime.now(self.get_local_tz())
date, period, parts = self._parse_date(
date_string,
now,
settings.PREFER_DATES_FROM,
settings.RETURN_TIME_AS_PERIOD,
)
if date and day is not None:
if period != "month":
return None, None, ()
try:
date = date.replace(day=day)
except ValueError:
return None, None, ()
period = "day"
parts += ("day",)
if date:
if period == "year":
date = set_correct_month_from_settings(date, settings, date.month)
if period in ("year", "month"):
date = set_correct_day_from_settings(date, settings, date.day)
old_date = date
date = apply_time(date, _time)
if settings.RETURN_TIME_AS_PERIOD and old_date != date:
period = "time"
if isinstance(_time, time) and "time" not in parts:
parts += ("time",)
if settings.TO_TIMEZONE:
date = apply_timezone(date, settings.TO_TIMEZONE)
if not settings.RETURN_AS_TIMEZONE_AWARE or (
settings.RETURN_AS_TIMEZONE_AWARE
and "default" == settings.RETURN_AS_TIMEZONE_AWARE
and not ptz
):
date = date.replace(tzinfo=None)
return date, period, parts
def _parse_date( # noqa: PLR0912
self,
date_string: str,
now: datetime,
prefer_dates_from: str,
return_time_as_period: bool,
) -> tuple[datetime, str, tuple[str, ...]] | tuple[None, None, tuple[()]]:
if not self._are_all_words_units(date_string):
return None, None, ()
result = self.get_kwargs(date_string)
if isinstance(result, tuple):
kwargs, explicit_signs = result
else:
kwargs = result
explicit_signs = {}
if not kwargs:
return None, None, ()
period = "day"
if return_time_as_period and any(
unit in kwargs for unit in ("hours", "minutes", "seconds")
):
period = "time"
elif "days" not in kwargs:
for k in ["weeks", "fortnights", "months", "years", "decades", "centuries"]:
if k in kwargs:
period = {
"fortnights": "week",
"decades": "year",
"centuries": "year",
}.get(k, k[:-1])
break
going_forward = re.search(r"\bin\b", date_string) or (
re.search(r"\bfuture\b", prefer_dates_from)
and not re.search(r"\bago\b", date_string)
)
adjusted_kwargs: dict[str, Any] = {}
for key, value in kwargs.items():
if explicit_signs.get(key, False) or going_forward:
adjusted_kwargs[key] = value
else:
adjusted_kwargs[key] = -value
# Fold ``decades`` into ``years`` after each component's sign has been
# resolved so that an unsigned component combined with an explicitly
# signed one still follows the default ago/future context (fixes
# #1304).
for unit, factor, base in (
("centuries", 100, "years"),
("decades", 10, "years"),
("fortnights", 2, "weeks"),
):
if unit in adjusted_kwargs:
adjusted_kwargs[base] = adjusted_kwargs.get(
base, 0
) + factor * adjusted_kwargs.pop(unit)
td = relativedelta(**adjusted_kwargs)
date = now + td
# The smallest unit in the string determines which parts of the
# resulting date are meaningful.
parts: tuple[str, ...]
if kwargs.keys() & {"seconds", "minutes", "hours"}:
parts = ("year", "month", "day", "time")
elif kwargs.keys() & {"days", "weeks", "fortnights"}:
parts = ("year", "month", "day")
elif "months" in kwargs:
parts = ("year", "month")
else:
parts = ("year",)
return date, period, parts
[docs]
def get_kwargs(
self, date_string: str
) -> tuple[dict[str, float], dict[str, bool]] | dict[str, float]:
m = PATTERN.findall(date_string)
if not m:
return {}
kwargs: dict[str, float] = {}
explicit_signs: dict[str, bool] = {}
for num, unit in m:
has_explicit_sign = num.startswith(("+", "-"))
key = "centuries" if unit.lower() == "century" else unit.lower() + "s"
explicit_signs[key] = has_explicit_sign
kwargs[key] = float(num.replace(",", ".").replace(" ", ""))
return kwargs, explicit_signs
[docs]
def get_date_data(self, date_string: str, settings: "Settings") -> "DateData":
from dateparser.date import DateData # noqa: PLC0415
date, period, parts = self._parse_with_parts(date_string, settings)
return DateData(date_obj=date, period=period, parts=parts)
freshness_date_parser = FreshnessDateDataParser()