diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 86aa4d1..bcb3f7a 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -15,8 +15,9 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index c415531..88c3bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,3 @@ dmypy.json # Pyre type checker .pyre/ - -/.idea -.idea/ \ No newline at end of file diff --git a/README.md b/README.md index d8c2c9f..064bec4 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,27 @@ Below you can find some examples of how datetimeparser can be used. ```python from datetimeparser import parse -print(parse("next 3 years and 2 months")) -# 2025-04-06 11:43:28 +print(parse("next 3 years and 2 months").time) +# 2025-12-28 11:57:25 -print(parse("begin of advent of code 2022")) +print(parse("begin of advent of code 2022").time) # 2022-12-01 06:00:00 -print(parse("in 1 Year 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds")) -# 2023-05-01 17:59:52 +print(parse("in 1 Year 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds").time) +# 2024-01-22 17:04:26 -print(parse("10 days and 2 hours after 3 months before christmas 2020")) +print(parse("10 days and 2 hours after 3 months before christmas 2020").time) # 2020-10-05 02:00:00 + +print(parse("sunrise")) +# + +print(parse("sunrise", timezone="Asia/Dubai")) +# + +# https://www.timeanddate.com/sun/japan/tokyo states that the sunset today (2022-10-28) is at '16:50' in Tokyo +print(parse("sunset", coordinates=(139.839478, 35.652832))) # (Tokyo in Japan) +# ``` ## Installation @@ -115,6 +125,18 @@ We highly appreciate everyone who wants to help our project!
  • valentine day
  • +pi day +
      +
    • piday
    • +
    • pi-day
    • +
    +
    +tau day +
      +
    • tauday
    • +
    • tau-day
    • +
    +
    summer end
    • end of summer
    • diff --git a/datetimeparser/__init__.py b/datetimeparser/__init__.py index 0193731..98d31a0 100644 --- a/datetimeparser/__init__.py +++ b/datetimeparser/__init__.py @@ -1,7 +1,6 @@ from datetimeparser import parser from datetimeparser import evaluator -from datetimeparser import enums -from datetimeparser import baseclasses -from datetimeparser import parsermethods +from datetimeparser.utils import baseclasses, enums +from datetimeparser.parser import parsermethods from datetimeparser.datetimeparser import parse diff --git a/datetimeparser/datetimeparser.py b/datetimeparser/datetimeparser.py index d8e979b..d6e5df8 100644 --- a/datetimeparser/datetimeparser.py +++ b/datetimeparser/datetimeparser.py @@ -2,34 +2,42 @@ Main module which provides the parse function. """ -__all__ = ['parse', '__version__', '__author__'] -__version__ = "0.12.2" +__all__ = ['parse', 'Result', '__version__', '__author__'] +__version__ = "1.0.0" __author__ = "aridevelopment" -import datetime -from typing import Union +from typing import Optional, Tuple from datetimeparser.evaluator import Evaluator from datetimeparser.parser import Parser +from datetimeparser.utils.models import Result -def parse(datetime_string: str, timezone: str = "Europe/Berlin") -> Union[datetime.datetime, None]: +def parse( + datetime_string: str, + timezone: str = "Europe/Berlin", + coordinates: Optional[Tuple[float, float]] = None +) -> Optional[Result]: """ Parses a datetime string and returns a datetime object. If the datetime string cannot be parsed, None is returned. :param datetime_string: The datetime string to parse. :param timezone: The timezone to use. Should be a valid timezone for pytz.timezone(). Default: Europe/Berlin - :return: A datetime object or None + :param coordinates: A tuple containing longitude and latitude. If coordinates are given, the timezone will be calculated, + independently of the given timezone param. + NOTE: It can take some seconds until a result is returned + :return: A result object containing the returned time, the timezone and optional coordinates. + If the process fails, None will be returned """ parser_result = Parser(datetime_string).parse() if parser_result is None: return None - evaluator_result = Evaluator(parser_result, tz=timezone).evaluate() + evaluator_result, tz, coordinates = Evaluator(parser_result, tz=timezone, coordinates=coordinates).evaluate() if evaluator_result is None: return None - return evaluator_result + return Result(evaluator_result, tz, coordinates) diff --git a/datetimeparser/evaluator.py b/datetimeparser/evaluator.py deleted file mode 100644 index d6efd8e..0000000 --- a/datetimeparser/evaluator.py +++ /dev/null @@ -1,52 +0,0 @@ -from datetime import datetime -from pytz import timezone, UnknownTimeZoneError -from typing import Union - -from .baseclasses import AbsoluteDateTime, RelativeDateTime -from .enums import Method -from .evaluatormethods import EvaluatorMethods - - -class Evaluator: - def __init__(self, parsed_object, tz="Europe/Berlin"): - """ - :param parsed_object: the parsed object from parser - :param tz: the timezone for the datetime - """ - - try: - tiz = timezone(tz) - except UnknownTimeZoneError: - raise ValueError("Unknown timezone: {}".format(tz)) - - self.parsed_object_type = parsed_object[0] - self.parsed_object_content: Union[list, AbsoluteDateTime, RelativeDateTime] = parsed_object[1] - self.current_datetime: datetime = datetime.strptime(datetime.strftime(datetime.now(tz=tiz), "%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S") - self.offset = tiz.utcoffset(self.current_datetime) - - def evaluate(self) -> Union[datetime, None]: - ev_out = None - ev = EvaluatorMethods(self.parsed_object_content, self.current_datetime, self.offset) - - if self.parsed_object_type == Method.ABSOLUTE_DATE_FORMATS: - ev_out = ev.evaluate_absolute_date_formats() - - if self.parsed_object_type == Method.ABSOLUTE_PREPOSITIONS: - ev_out = ev.evaluate_absolute_prepositions() - - if self.parsed_object_type == Method.CONSTANTS: - ev_out = ev.evaluate_constants() - - if self.parsed_object_type == Method.RELATIVE_DATETIMES: - ev_out = ev.evaluate_relative_datetime() - - if self.parsed_object_type == Method.CONSTANTS_RELATIVE_EXTENSIONS: - ev_out = ev.evaluate_constant_relatives() - - if self.parsed_object_type == Method.DATETIME_DELTA_CONSTANTS: - ev_out = ev.evaluate_datetime_delta_constants() - - if ev_out: - return ev_out - else: - raise ValueError diff --git a/datetimeparser/evaluator/__init__.py b/datetimeparser/evaluator/__init__.py new file mode 100644 index 0000000..6eedafc --- /dev/null +++ b/datetimeparser/evaluator/__init__.py @@ -0,0 +1 @@ +from .evaluator import Evaluator diff --git a/datetimeparser/evaluator/evaluator.py b/datetimeparser/evaluator/evaluator.py new file mode 100644 index 0000000..829fb85 --- /dev/null +++ b/datetimeparser/evaluator/evaluator.py @@ -0,0 +1,60 @@ +from datetime import datetime +from typing import Optional, Tuple, Union +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from datetimeparser.utils.baseclasses import AbsoluteDateTime, RelativeDateTime +from datetimeparser.utils.enums import Method +from datetimeparser.evaluator.evaluatormethods import EvaluatorMethods +from datetimeparser.utils.exceptions import FailedEvaluation, InvalidValue +from datetimeparser.utils.geometry import TimeZoneManager + + +class Evaluator: + def __init__(self, parsed_object, tz="Europe/Berlin", coordinates: Optional[Tuple[float, float]] = None): + """ + :param parsed_object: the parsed object from parser + :param tz: the timezone for the datetime + :param coordinates: longitude and latitude for timezone calculation and for sunrise and sunset + """ + + if coordinates: + tz = TimeZoneManager().timezone_at(lng=coordinates[0], lat=coordinates[1]) + try: + tiz = ZoneInfo(tz) + except ZoneInfoNotFoundError: + raise InvalidValue(f"Unknown timezone: '{tz}'") + + self.parsed_object_type = parsed_object[0] + self.parsed_object_content: Union[list, AbsoluteDateTime, RelativeDateTime] = parsed_object[1] + self.current_datetime: datetime = datetime.strptime(datetime.strftime(datetime.now(tz=tiz), "%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S") + self.offset = tiz.utcoffset(self.current_datetime) + self.timezone: ZoneInfo = tiz + self.coordinates = coordinates + + def evaluate(self) -> Union[Tuple[datetime, str, Tuple[float, float]], None]: + ev_out: Optional[datetime] = None + coordinates: Optional[Tuple[float, float]] = None + ev = EvaluatorMethods(self.parsed_object_content, self.current_datetime, self.timezone.key, self.coordinates, self.offset) + + if self.parsed_object_type == Method.ABSOLUTE_DATE_FORMATS: + ev_out = ev.evaluate_absolute_date_formats() + + if self.parsed_object_type == Method.ABSOLUTE_PREPOSITIONS: + ev_out = ev.evaluate_absolute_prepositions() + + if self.parsed_object_type == Method.CONSTANTS: + ev_out, coordinates = ev.evaluate_constants() + + if self.parsed_object_type == Method.RELATIVE_DATETIMES: + ev_out = ev.evaluate_relative_datetime() + + if self.parsed_object_type == Method.CONSTANTS_RELATIVE_EXTENSIONS: + ev_out = ev.evaluate_constant_relatives() + + if self.parsed_object_type == Method.DATETIME_DELTA_CONSTANTS: + ev_out = ev.evaluate_datetime_delta_constants() + + if ev_out: + return ev_out, self.timezone.key, self.coordinates or coordinates + else: + raise FailedEvaluation(self.parsed_object_content) diff --git a/datetimeparser/evaluatormethods.py b/datetimeparser/evaluator/evaluatormethods.py similarity index 65% rename from datetimeparser/evaluatormethods.py rename to datetimeparser/evaluator/evaluatormethods.py index 151df37..15f1940 100644 --- a/datetimeparser/evaluatormethods.py +++ b/datetimeparser/evaluator/evaluatormethods.py @@ -1,6 +1,11 @@ -from .baseclasses import * -from .enums import * -from .evaluatorutils import EvaluatorUtils +from typing import Any, Optional, Tuple + +from datetimeparser.evaluator.evaluatorutils import EvaluatorUtils +from datetimeparser.utils.baseclasses import * +from datetimeparser.utils.enums import * +from datetimeparser.utils.exceptions import InvalidValue +from datetimeparser.utils.formulars import calc_sun_time +from datetimeparser.utils.geometry import TimeZoneManager class EvaluatorMethods(EvaluatorUtils): @@ -8,16 +13,22 @@ class EvaluatorMethods(EvaluatorUtils): Evaluates a datetime-object from a given list returned from the parser """ - def __init__(self, parsed, current_time: datetime, offset: timedelta = None): + def __init__( + self, parsed: Any, current_time: datetime, timezone: str, coordinates: Optional[Tuple[float, float]], offset: timedelta = None + ): """ :param parsed: object returned from the parser :param current_time: the current datetime + :param timezone: the given timezone + :param coordinates: coordinates from the timezone :param offset: the UTC-offset from the current timezone. Default: None """ self.parsed = parsed self.current_time = current_time self.offset = offset + self.coordinates = coordinates + self.timezone = timezone def evaluate_absolute_date_formats(self) -> datetime: ev_out = datetime( @@ -32,7 +43,7 @@ def evaluate_absolute_date_formats(self) -> datetime: return ev_out def evaluate_constant_relatives(self) -> datetime: - sanitized = self.sanitize_input(self.current_time, self.parsed) + sanitized, _ = self.sanitize_input(self.current_time, self.parsed) base: datetime = self.current_time ev_out = None @@ -45,7 +56,7 @@ def evaluate_constant_relatives(self) -> datetime: ev_out = datetime(base.year, base.month, base.day, hour, minute, sec) elif isinstance(sanitized[-1], RelativeDateTime): - base += self.prepare_relative_delta(sanitized[-1]) + base = self.add_relative_delta(base, sanitized[-1], self.current_time) if sanitized[-2] in WeekdayConstants.ALL: base = self.cut_time(base) @@ -89,14 +100,17 @@ def evaluate_constant_relatives(self) -> datetime: def evaluate_absolute_prepositions(self) -> datetime: base_year = self.current_time.year - sanitized = self.sanitize_input(self.current_time, self.parsed) - base = self.get_base(sanitized, base_year, self.current_time) + sanitized, given_year = self.sanitize_input(self.current_time, self.parsed) + if not given_year: + base = self.get_base(sanitized, base_year, self.current_time) + else: + base = self.get_base(sanitized, given_year, self.current_time, forced=True) rel_out = self.calc_relative_time(sanitized) - base += self.prepare_relative_delta(rel_out) + base = self.add_relative_delta(base, rel_out, self.current_time) - return self.remove_milli_seconds(base) + return base - def evaluate_constants(self) -> datetime: + def evaluate_constants(self) -> Tuple[datetime, Optional[Tuple[float, float]]]: dt: datetime = self.current_time object_type: Constant = self.parsed[0] @@ -109,8 +123,8 @@ def evaluate_constants(self) -> datetime: dt = object_type.time_value(object_year + 1) else: - if object_type.name == "infinity": - raise ValueError("'infinity' isn't a valid time") + if object_type.name == "infinity": # TODO: has to be improved for more invalid constants if needed + raise InvalidValue(object_type.name) elif object_type in WeekdayConstants.ALL: dt: datetime = datetime.strptime( @@ -118,9 +132,20 @@ def evaluate_constants(self) -> datetime: "%Y-%m-%d %H:%M:%S" ) + elif object_type.name == "sunset" or object_type.name == "sunrise": + ofs = self.offset.total_seconds() / 60 / 60 # -> to hours + # TODO: at the moment summer and winter time change the result for the offset around 1 hour + if not self.coordinates: + self.coordinates = TimeZoneManager().get_coordinates(self.timezone) + + dt = calc_sun_time( + self.current_time, + (self.coordinates[0], self.coordinates[1], ofs), + object_type.name == "sunrise" + ) + else: dt = object_type.time_value(self.current_time.year) - if isinstance(dt, tuple): dt = datetime( year=self.current_time.year, @@ -130,23 +155,31 @@ def evaluate_constants(self) -> datetime: minute=dt[1], second=dt[2] ) + return dt, self.coordinates - if self.current_time > dt and self.parsed[0] not in Constants.ALL_RELATIVE_CONSTANTS: + if self.current_time >= dt and self.parsed[0] not in ( + Constants.ALL_RELATIVE_CONSTANTS and WeekdayConstants.ALL and DatetimeDeltaConstants.CHANGING + ): dt = object_type.time_value(self.current_time.year + 1) + if self.current_time >= dt and self.parsed[0] in WeekdayConstants.ALL: + dt += relativedelta(days=7) + ev_out = datetime( dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second ) if object_type.offset: - ev_out += self.prepare_relative_delta(self.get_offset(object_type, self.offset)) + ev_out = self.add_relative_delta(ev_out, self.get_offset(object_type, self.offset), self.current_time) + if self.daylight_saving(self.timezone): + ev_out -= timedelta(hours=1) - return ev_out + return ev_out, self.coordinates def evaluate_relative_datetime(self) -> datetime: out: datetime = self.current_time - out += self.prepare_relative_delta(self.parsed) + out = self.add_relative_delta(out, self.parsed, self.current_time) ev_out = datetime( out.year, out.month, out.day, out.hour, out.minute, out.second ) diff --git a/datetimeparser/evaluatorutils.py b/datetimeparser/evaluator/evaluatorutils.py similarity index 57% rename from datetimeparser/evaluatorutils.py rename to datetimeparser/evaluator/evaluatorutils.py index 5326d0d..b658c46 100644 --- a/datetimeparser/evaluatorutils.py +++ b/datetimeparser/evaluator/evaluatorutils.py @@ -1,7 +1,9 @@ -from typing import Union +from typing import Any, Tuple, Union +from zoneinfo import ZoneInfo -from .baseclasses import * -from .enums import * +from datetimeparser.utils.baseclasses import * +from datetimeparser.utils.enums import * +from datetimeparser.utils.exceptions import InvalidValue class EvaluatorUtils: @@ -19,6 +21,19 @@ def get_week_of(dt: datetime) -> datetime: return dt + timedelta(days=(7 - dt.weekday())) + @staticmethod + def x_week_of_month(relative_dt: RelativeDateTime, idx: int, parsed: List[Union[Any]], year): + + parsed[idx + 1] = EvaluatorUtils.datetime_to_absolute_datetime(parsed[idx + 1].time_value(year)) + + relative_dt.days = EvaluatorUtils.get_week_of( + EvaluatorUtils.absolute_datetime_to_datetime(parsed[idx + 1]) + ).day - 1 + + relative_dt.weeks -= 1 + + return parsed + @staticmethod def absolute_datetime_to_datetime(absolute_datetime: AbsoluteDateTime) -> datetime: """ @@ -58,7 +73,9 @@ def datetime_to_absolute_datetime(dt: datetime) -> AbsoluteDateTime: return absdt @staticmethod - def sanitize_input(current_time: datetime, parsed_list: list) -> List[Union[RelativeDateTime, AbsoluteDateTime, int, Constant]]: + def sanitize_input( + current_time: datetime, parsed_list: list + ) -> Tuple[List[Union[RelativeDateTime, AbsoluteDateTime, int, Constant]], int]: """ Removes useless keywords :param parsed_list: The list that should be sanitized @@ -66,6 +83,7 @@ def sanitize_input(current_time: datetime, parsed_list: list) -> List[Union[Rela :return: a list without keywords """ + given_year = 0 for idx, element in enumerate(parsed_list): if isinstance(element, Constant) and element.name == "of": if isinstance(parsed_list[idx - 1], RelativeDateTime): @@ -80,17 +98,25 @@ def sanitize_input(current_time: datetime, parsed_list: list) -> List[Union[Rela if parsed_list[idx + 1] in MonthConstants.ALL: try: year = parsed_list.pop(idx + 2).year + given_year = year except IndexError: year = current_time.year - parsed_list[idx + 1] = EvaluatorUtils.datetime_to_absolute_datetime(parsed_list[idx + 1].time_value(year)) - relative_dt.days = EvaluatorUtils.get_week_of( - EvaluatorUtils.absolute_datetime_to_datetime(parsed_list[idx + 1]) - ).day - 1 + pars1, pars2 = parsed_list.copy(), parsed_list.copy() + ghost_parsed_list = EvaluatorUtils.x_week_of_month(relative_dt, idx, pars1, year) + test_out = EvaluatorUtils.add_relative_delta( + EvaluatorUtils.absolute_datetime_to_datetime(ghost_parsed_list[-1]), + ghost_parsed_list[0], + current_time + ) + if current_time > test_out and not given_year: + parsed_list = EvaluatorUtils.x_week_of_month(relative_dt, idx, pars2, year + 1) - relative_dt.weeks -= 1 + else: + if isinstance(parsed_list[idx + 1], AbsoluteDateTime): + given_year = parsed_list[idx + 1].year - return list(filter(lambda e: e not in Keywords.ALL and not isinstance(e, str), parsed_list)) + return list(filter(lambda e: e not in Keywords.ALL and not isinstance(e, str), parsed_list)), given_year @staticmethod def cut_time(time: datetime) -> datetime: @@ -102,14 +128,14 @@ def cut_time(time: datetime) -> datetime: return datetime(time.year, time.month, time.day, 0, 0, 0) - @staticmethod - def get_base(sanitized_input: list, year: int, current_time: datetime) -> datetime: + def get_base(self, sanitized_input: list, year: int, current_time: datetime, forced: bool = False) -> datetime: """ Takes the last elements from the list and tries to generate a basis for further processing from them The base consists of at least one constant, to which values are then assigned :param sanitized_input: The sanitized list :param year: The year for the Constant :param current_time: The current datetime + :param forced: If a given year should be used regardless of current time :return: datetime """ @@ -122,7 +148,37 @@ def get_base(sanitized_input: list, year: int, current_time: datetime) -> dateti dt: datetime = sanitized_input[-2].time_value(sanitized_input[-1].year) day: int = sanitized_input[-3] return datetime(dt.year, dt.month, day, dt.hour, dt.minute, dt.second) - return sanitized_input[-2].time_value(sanitized_input[-1].year) + + if sanitized_input[-2].time_value: + dt = sanitized_input[-2].time_value(sanitized_input[-1].year) + + if isinstance(sanitized_input[-3], Constant) and sanitized_input[-3].value: + dt += relativedelta(days=sanitized_input[-3].value - 1) + elif isinstance(sanitized_input[-3], Constant) and not sanitized_input[-3].value: + val = sanitized_input[-4].value + + if sanitized_input[-3].name == "days": + return datetime(dt.year, dt.month, val, dt.hour, dt.minute, dt.second) + if sanitized_input[-3].name == "weeks": + dt = self.get_week_of(dt) + return dt + relativedelta(weeks=val-1) + if sanitized_input[-3].name == "months": + return datetime(dt.year, val, dt.day, dt.hour, dt.minute, dt.second) + + days_dict = {x.name: x.time_value(dt) for x in WeekdayConstants.ALL} + if sanitized_input[-3].name in days_dict: + dt = datetime.strptime(days_dict.get(sanitized_input[-3].name), "%Y-%m-%d %H:%M:%S") + dt += relativedelta(weeks=val - 1) + + return dt + + elif sanitized_input[-3].value: + val: int = sanitized_input[-3].value + + if sanitized_input[-2].name == "days": + return datetime(sanitized_input[-1].year, 1, val, 0, 0, 0) + if sanitized_input[-2].name == "months": + return datetime(sanitized_input[-1].year, val, 1, 0, 0, 0) # If a year is given but no months/days, they will be set to '1' because datetime can't handle month/day-values with '0' if sanitized_input[-1].year != 0: @@ -146,14 +202,31 @@ def get_base(sanitized_input: list, year: int, current_time: datetime) -> dateti # If no AbsoluteDatetime is given, the default year will be used instead elif isinstance(sanitized_input[-1], Constant): + dt: datetime = sanitized_input[-1].time_value(year) if isinstance(sanitized_input[-2], int): - dt: datetime = sanitized_input[-1].time_value(year) day: int = sanitized_input[-2] + out = datetime(dt.year, dt.month, day, dt.hour, dt.minute, dt.second) - return datetime(dt.year, dt.month, day, dt.hour, dt.minute, dt.second) + if out > current_time or forced: + return out + out += relativedelta(years=1) + return out + else: + if len(sanitized_input) == 3: + val: int = sanitized_input[-3].value + + if sanitized_input[-2].name == "days": + return datetime(dt.year, dt.month, dt.day + val, dt.hour, dt.minute, dt.second) + if sanitized_input[-2].name == "weeks": + dt = self.get_week_of(dt) + return dt + relativedelta(weeks=val) + if sanitized_input[-2].name == "months": + return datetime(dt.year, dt.month + val, dt.day, dt.hour, dt.minute, dt.second) + if not isinstance(sanitized_input[-2], RelativeDateTime): + return datetime(dt.year, dt.month, sanitized_input[-2].value, dt.hour, dt.minute, dt.second) # Checks if an event already happened this year (f.e. eastern). If so, the next year will be used - if sanitized_input[-1].time_value(year) > current_time: + if sanitized_input[-1].time_value(year) > current_time or forced: return sanitized_input[-1].time_value(year) else: return sanitized_input[-1].time_value(year + 1) @@ -181,10 +254,12 @@ def calc_relative_time(sanitized_list: list) -> RelativeDateTime: return ev_out @staticmethod - def prepare_relative_delta(rel_time: RelativeDateTime) -> relativedelta: + def add_relative_delta(base_time: datetime, rel_time: RelativeDateTime, current_time: datetime) -> datetime: """ Prepares a RelativeDateTime-object for adding to a datetime + :param base_time: DateTime-object the time should be added too :param rel_time: RelativeDateTime-object + :param current_time: current datetime :return: relativedelta """ @@ -198,17 +273,14 @@ def prepare_relative_delta(rel_time: RelativeDateTime) -> relativedelta: seconds=rel_time.seconds ) - return rel + try: + if base_time > current_time > base_time + rel: + rel.years += 1 + out = base_time + rel + except ValueError as e: + raise InvalidValue(e.args[0]) - @staticmethod - def remove_milli_seconds(dt: datetime) -> datetime: - """ - Cuts milliseconds of - :param dt: The time with milliseconds at the end - :return: datetime - """ - - return datetime.strptime(dt.strftime("%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S") + return out @staticmethod def get_offset(con: Constant, offset) -> RelativeDateTime: @@ -227,3 +299,8 @@ def get_offset(con: Constant, offset) -> RelativeDateTime: off += con.offset return RelativeDateTime(hours=off + offset.seconds / 3600 + offset.days * 24) + + @staticmethod + def daylight_saving(tz: str): + """checks if a timezone currently saves daylight (winter-/summer-time)""" + return bool(datetime.now(ZoneInfo(tz)).dst()) diff --git a/datetimeparser/formulars.py b/datetimeparser/formulars.py deleted file mode 100644 index 3547977..0000000 --- a/datetimeparser/formulars.py +++ /dev/null @@ -1,36 +0,0 @@ -from datetime import datetime, timedelta - - -def eastern_calc(year_time: int) -> datetime: - a = year_time % 19 - k = year_time // 100 - m = 15 + (3 * k + 3) // 4 - (8 * k + 13) // 25 - d = (19 * a + m) % 30 - s = 2 - (3 * k + 3) // 4 - r = d // 29 + (d // 28 - d // 29) * (a // 11) - og = 21 + d + r - sz = 7 - (year_time + year_time // 4 + s) % 7 - oe = 7 - (og - sz) % 7 - os = og + oe - - if os > 32: - return datetime(year=year_time, month=4, day=(os - 31)) - else: - return datetime(year=year_time, month=3, day=os) - - -def thanksgiving_calc(year_time: int) -> datetime: - year_out = datetime(year=year_time, month=11, day=29) - date_out = datetime(year=year_time, month=11, day=3) - return year_out - timedelta(days=(date_out.weekday() + 2)) - - -def days_feb(year_time: int) -> int: - if int(year_time) % 400 == 0 or int(year_time) % 4 == 0 and not int(year_time) % 100 == 0: - return 29 - else: - return 28 - - -def year_start(year_time: int) -> datetime: - return datetime(year=year_time, month=1, day=1) diff --git a/datetimeparser/parser/__init__.py b/datetimeparser/parser/__init__.py new file mode 100644 index 0000000..2a3855a --- /dev/null +++ b/datetimeparser/parser/__init__.py @@ -0,0 +1 @@ +from .parser import Parser diff --git a/datetimeparser/parser.py b/datetimeparser/parser/parser.py similarity index 89% rename from datetimeparser/parser.py rename to datetimeparser/parser/parser.py index a04edb7..59453d2 100644 --- a/datetimeparser/parser.py +++ b/datetimeparser/parser/parser.py @@ -1,12 +1,12 @@ import string as string_utils -from .parsermethods import ( +from datetimeparser.parser.parsermethods import ( AbsoluteDateFormatsParser, - RelativeDatetimesParser, - ConstantsParser, + AbsolutePrepositionParser, ConstantRelativeExtensionsParser, + ConstantsParser, DatetimeDeltaConstantsParser, - AbsolutePrepositionParser + RelativeDatetimesParser ) diff --git a/datetimeparser/parsermethods.py b/datetimeparser/parser/parsermethods.py similarity index 88% rename from datetimeparser/parsermethods.py rename to datetimeparser/parser/parsermethods.py index d2ef220..5bc79c5 100644 --- a/datetimeparser/parsermethods.py +++ b/datetimeparser/parser/parsermethods.py @@ -1,8 +1,8 @@ import re from typing import Optional, Tuple, Union -from .enums import * -from .baseclasses import * +from datetimeparser.utils.enums import * +from datetimeparser.utils.baseclasses import * def parse_int(text: str) -> bool: @@ -113,15 +113,15 @@ def parse(self, string: str) -> Union[None, Tuple[MethodEnum, RelativeDateTime]] # Checks if the string starts with a preposition # And then cuts it off because we need the preposition to differentiate future and past events for preposition in self.PREPOSITIONS: - if string.startswith(preposition): - string = string[len(preposition):] + if string.split()[0] == preposition: + string = " ".join(string.split()[1:]) break else: preposition = "" new_data = [] - for argument in string.split(): + for idx, argument in enumerate(string.split()): not_possible = True argument = argument.lower().strip(",") @@ -130,7 +130,13 @@ def parse(self, string: str) -> Union[None, Tuple[MethodEnum, RelativeDateTime]] continue # an `a` always represents a 1 (e.g. a day = 1 day) - if argument.lower() == "a": + # But there is one special case: `quarter an hour`. This means 0.25 hours or 15 minutes + if argument.lower() in ("a", "an"): + if new_data and new_data[-1] == DatetimeConstants.QUARTERS: + if idx + 1 < len(string.split()) and string.split()[idx + 1] in DatetimeConstants.HOURS.get_all(): + # We break because there can't be anything after "quarter an hour" + break + new_data.append(1 if preposition != "last" else -1) not_possible = False @@ -219,7 +225,7 @@ class ConstantsParser: FUTURE_PREPOSITIONS = ("next", "this") # Order is important because "at" and "the" are both in "at the" - CUTOFF_KEYWORDS = ("at the", "in the", "at", "the") + CUTOFF_KEYWORDS = ("at the", "in the", "at", "the", "after") def _find_constant(self, argument: str) -> Optional[Constant]: """ @@ -244,12 +250,12 @@ def parse(self, string: str) -> Optional[Tuple[MethodEnum, Tuple]]: # noqa: C90 :param string: The string to parse :return: A tuple containing the method and the data or None """ - string = string.lower() + string = " ".join(string.lower().split()) # Cut off 'at', 'at the' and 'the' for cutoff_word in self.CUTOFF_KEYWORDS: - if string.startswith(cutoff_word): - string = string[len(cutoff_word):] + if string.split()[:len(cutoff_word.split())] == cutoff_word.split(): + string = " ".join(string.split()[len(cutoff_word.split()):]) # Strip whitespace string = " ".join(string.split()) @@ -257,8 +263,8 @@ def parse(self, string: str) -> Optional[Tuple[MethodEnum, Tuple]]: # noqa: C90 # Cut off the preposition if the strings starts with one and save the preposition # To differentiate future and past for preposition in self.PREPOSITIONS: - if string.startswith(preposition): - string = string[len(preposition):] + if string.split()[0] == preposition: + string = " ".join(string.split()[1:]) break else: preposition = None @@ -325,7 +331,7 @@ def _find_number(string: str) -> Optional[int]: :return: The number if found, None otherwise """ - if string == "a": + if string in ("a", "an"): return 1 if parse_int(string): @@ -372,10 +378,15 @@ def _find_constant(cls, string: str, rest_arguments: List[str]) -> Optional[Tupl return None else: # Only the argument directly after the keyword can be a year + # Except for cases like tomorrow 12 o'clock if rest_arguments[0].isdecimal(): year = int(rest_arguments[0]) result = cls._find_constant(string, []) + # Check the exception via a DatetimeDeltaConstantsParser and set year to None. + if DatetimeDeltaConstantsParser().parse(" ".join(rest_arguments)) is not None: + year = None + if result is not None: return result[0], year else: @@ -399,8 +410,8 @@ def _get_preposition(cls, string: str) -> Optional[Tuple[str, str]]: # Cut off the preposition if the strings starts with one and save the preposition # To differentiate future and past for preposition in cls.PREPOSITIONS: - if string.startswith(preposition): - string = string[len(preposition):] + if string.split()[0] == preposition: + string = " ".join(string.split()[1:]) break else: preposition = "at" @@ -418,9 +429,8 @@ def _get_preposition_keyword(cls, string: str) -> Optional[Tuple[str, str, Union """ # Cut off 'at', 'at the' and 'the' for cutoff_word in cls.CUTOFF_KEYWORDS: - if string.startswith(cutoff_word): - string = string[len(cutoff_word):] - string = string.strip() + if string.split()[:len(cutoff_word.split())] == cutoff_word.split(): + string = " ".join(string.split()[len(cutoff_word.split()):]) preposition, string = cls._get_preposition(string) @@ -438,6 +448,8 @@ def _get_preposition_keyword(cls, string: str) -> Optional[Tuple[str, str, Union result = cls._find_constant(tryable_keyword, arguments[i:]) if result is not None: + # Year might also be a clock time (such as (tomorrow) 12 (o' clock)) + # If that's the case, year will be None keyword, year = result break @@ -505,6 +517,7 @@ def parse(self, string: str) -> Optional[Tuple[Method, Tuple]]: if result is None: return None + # Identify the first parts of the string and cutoff keyword + year if a year exists (there might also be no year) first_preposition, tryable_keyword, first_keyword, first_year, string = result string = string[len(tryable_keyword) + len(str(first_year if first_year is not None else "")):] string = " ".join(string.split()) @@ -591,7 +604,7 @@ def parse(self, string: str) -> Optional[Tuple[MethodEnum, AbsoluteDateTime]]: continue # If the time does not match a clocktime format, does not contain a colon and is a number - # e.g. "3(pm|am)", return that time respecting the after_midday flag + # e.g. "3(pm|am)" or "3 o'clock", return that time respecting the after_midday flag if not parsed_time and time.count(":") == 0 and parse_int(time): if after_midday is not None: parsed_time = AbsoluteDateTime(hour=(12 if after_midday else 0) + int(time)) @@ -615,7 +628,7 @@ def parse(self, string: str) -> Optional[Tuple[MethodEnum, AbsoluteDateTime]]: # If there's no more content left # Return the parsed time - if not data: + if not data or (len(data) == 1 and data[0].lower() == 'o\'clock'): return Method.DATETIME_DELTA_CONSTANTS, parsed_time else: # Otherwise search for constants like @@ -663,6 +676,7 @@ class AbsolutePrepositionParser: ) RELATIVE_DATETIME_CONSTANTS = (*NumberCountConstants.ALL, *NumberConstants.ALL, *DatetimeConstants.ALL) + ABSOLUTE_RELATIVE_DATETIME_CONSTANTS = (*WeekdayConstants.ALL, *MonthConstants.ALL) RELATIVE_TIME_CONSTANTS = DatetimeConstants.ALL RELATIVE_DATA_SKIPPABLE_WORDS = ( @@ -688,7 +702,7 @@ def _split_data(self, string: str) -> Optional[Tuple[dict, dict, dict]]: """ # If none of the known prepositions are in the string, return None - if not any(absolute_preposition.name in string for absolute_preposition in self.ABSOLUTE_PREPOSITION_TOKENS): + if not any(absolute_preposition.name in string.split() for absolute_preposition in self.ABSOLUTE_PREPOSITION_TOKENS): return None word = None @@ -706,7 +720,7 @@ def _split_data(self, string: str) -> Optional[Tuple[dict, dict, dict]]: relative = string[:char_count - len(word) - 2] absolute = string[char_count:] - # There may be more prepositions in the absolute part so we try it again via recursion + # There may be more prepositions in the absolute part, so we try it again via recursion recursion_result = self._split_data(absolute) if recursion_result is not None: @@ -734,7 +748,7 @@ def _parse_relative_statement(self, relative_statement: str, preposition: str) - continue # 'a' means the same as '1' (e.g. 'a day' => '1 day') - if argument == "a": + if argument in ("a", "an"): returned_data.append(1) continue @@ -742,32 +756,48 @@ def _parse_relative_statement(self, relative_statement: str, preposition: str) - returned_data.append(int(argument)) continue + found_keyword = False + # '1st', '1.', 'first', ... # 'one', 'two', 'three', ... # 'seconds', 'minutes', 'hours', ... - for keyword in self.RELATIVE_DATETIME_CONSTANTS if preposition != "past" else (*self.RELATIVE_DATETIME_CONSTANTS, self.RELATIVE_TIME_CONSTANTS): - for alias in keyword.get_all(): - if alias == argument: - if keyword in DatetimeConstants.ALL: - if not returned_data or not isinstance(returned_data[-1], int): - # For cases like 'day and month (before christmas)' - returned_data.append(1) + for keyword in self.RELATIVE_DATETIME_CONSTANTS: + if argument in keyword.get_all(): + if keyword in DatetimeConstants.ALL: + if not returned_data or (not isinstance(returned_data[-1], int) and not returned_data[-1] in NumberCountConstants.ALL): + # For cases like 'day and month (before christmas)' + returned_data.append(1) + returned_data.append(keyword) + elif keyword in NumberCountConstants.ALL: + if not returned_data: + # The keyword comes before the actual datetime constant 'second (monday of August)' returned_data.append(keyword) - else: - returned_data.append(keyword.value) + else: + returned_data.append(keyword.value) - break - else: - continue + found_keyword = True + break - break - else: + # 'monday', 'tuesday', 'wednesday', ... + # 'january', 'february', 'march', ... + # GitHub issue #198 + for keyword in self.ABSOLUTE_RELATIVE_DATETIME_CONSTANTS: + if argument in keyword.get_all(): + returned_data.append(keyword) + + found_keyword = True + break + + if not found_keyword: return None return returned_data - def _concatenate_relative_data(self, relative_data_tokens: List[Union[int, Constant]], preposition: str) -> List[Union[int, Constant, RelativeDateTime]]: + def _concatenate_relative_data( + self, relative_data_tokens: List[Union[int, Constant]], + preposition: str + ) -> List[Union[int, Constant, RelativeDateTime]]: """ Concatenates [1, RelativeDate(DAY), 2, RelativeDate(MONTH)] into [RelativeDate(days=1, months=2)] respecting the preposition (future and past) @@ -809,6 +839,9 @@ def _concatenate_relative_data(self, relative_data_tokens: List[Union[int, Const else: # Otherwise, we cannot concatenate both parts of the data, so we just append the current one returned_data.append(current_data) + elif value in NumberCountConstants.ALL and unit in (*DatetimeConstants.ALL, *WeekdayConstants.ALL, *MonthConstants.ALL): + returned_data.append(value) + returned_data.append(unit) return returned_data @@ -825,11 +858,13 @@ def _parse_absolute_statement(self, data: Union[str, Tuple]) -> Optional: if isinstance(data, str): # Try constants (e.g. "(three days after) christmas") constants_parser = ConstantsParser() - constants_parser.CONSTANT_KEYWORDS = (*Constants.ALL, *DatetimeDeltaConstants.ALL, *MonthConstants.ALL, *WeekdayConstants.ALL) + constants_parser.CONSTANT_KEYWORDS = ( + *Constants.ALL, *DatetimeDeltaConstants.ALL, *DatetimeConstants.ALL, *MonthConstants.ALL, *WeekdayConstants.ALL + ) constants_parser.PREPOSITIONS = ("last", "next", "this", "previous") constants_parser.PAST_PREPOSITIONS = ("last", "previous") constants_parser.FUTURE_PREPOSITIONS = ("next", "this") - constants_parser.CUTOFF_KEYWORDS = ("at the", "the", "at") + constants_parser.CUTOFF_KEYWORDS = ("at the", "the", "at", "an", "a") result = constants_parser.parse(data) diff --git a/datetimeparser/utils/__init__.py b/datetimeparser/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/datetimeparser/baseclasses.py b/datetimeparser/utils/baseclasses.py similarity index 97% rename from datetimeparser/baseclasses.py rename to datetimeparser/utils/baseclasses.py index 70d8b9d..feaa9b4 100644 --- a/datetimeparser/baseclasses.py +++ b/datetimeparser/utils/baseclasses.py @@ -3,7 +3,7 @@ from typing import Callable, List, TYPE_CHECKING if TYPE_CHECKING: - from datetimeparser.enums import ConstantOption # noqa: I2041 + from datetimeparser.utils.enums import ConstantOption # noqa: I2041 class Printable: diff --git a/datetimeparser/enums.py b/datetimeparser/utils/enums.py similarity index 98% rename from datetimeparser/enums.py rename to datetimeparser/utils/enums.py index b2f4e0a..721f54f 100644 --- a/datetimeparser/enums.py +++ b/datetimeparser/utils/enums.py @@ -3,8 +3,8 @@ from dateutil.relativedelta import relativedelta -from .baseclasses import Constant, MethodEnum -from .formulars import days_feb, eastern_calc, thanksgiving_calc, year_start +from datetimeparser.utils.baseclasses import Constant, MethodEnum +from datetimeparser.utils.formulars import days_feb, eastern_calc, thanksgiving_calc, year_start class ConstantOption(Enum): @@ -119,7 +119,7 @@ class DatetimeDeltaConstants: DAYLIGHT_CHANGE = Constant('daylight change', ['daylight saving', 'daylight saving time'], value=0, options=[ConstantOption.YEAR_VARIABLE, ConstantOption.DATE_VARIABLE], time_value=lambda _: (6, 0, 0)) - SUNRISE = Constant('sunrise', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (7, 0, 0)) + SUNRISE = Constant('sunrise', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: None) MORNING = Constant('morning', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (6, 0, 0)) BREAKFAST = Constant('breakfast', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (8, 0, 0)) @@ -132,12 +132,15 @@ class DatetimeDeltaConstants: time_value=lambda _: (19, 0, 0)) DAWN = Constant('dawn', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (6, 0, 0)) DUSK = Constant('dusk', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (20, 0, 0)) - SUNSET = Constant('sunset', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (18, 30, 0)) + SUNSET = Constant('sunset', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: None) ALL = [ MORNING, AFTERNOON, EVENING, NIGHT, MORNING_NIGHT, DAYLIGHT_CHANGE, MIDNIGHT, MIDDAY, DAWN, DUSK, SUNRISE, SUNSET, LUNCH, DINNER, BREAKFAST ] + CHANGING = [ + SUNRISE, SUNSET + ] class NumberConstants: diff --git a/datetimeparser/utils/exceptions.py b/datetimeparser/utils/exceptions.py new file mode 100644 index 0000000..a569238 --- /dev/null +++ b/datetimeparser/utils/exceptions.py @@ -0,0 +1,26 @@ +from typing import Union + + +class EvaluatorException(Exception): + """ + Base class for Evaluator Exceptions + """ + def __init__(self, error_type: str, errors: str): + self.errors = errors + self.error_type = error_type + super().__init__(error_type) + + def __str__(self): + return f"{self.error_type}: {self.errors}" + + +class FailedEvaluation(EvaluatorException): + def __init__(self, tried: Union[str, list, None] = None): + + super().__init__(type(self).__name__, f"Cannot evaluate {tried} into datetime") + + +class InvalidValue(EvaluatorException): + def __init__(self, value: str): + + super().__init__(type(self).__name__, f"{value}") diff --git a/datetimeparser/utils/formulars.py b/datetimeparser/utils/formulars.py new file mode 100644 index 0000000..c131d65 --- /dev/null +++ b/datetimeparser/utils/formulars.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta +from typing import Tuple +import math + + +def day_of_year(dt: datetime) -> int: + n1 = math.floor(275 * dt.month / 9) + n2 = math.floor((dt.month + 9) / 12) + n3 = (1 + math.floor((dt.year - 4 * math.floor(dt.year / 4) + 2) / 3)) + + return n1 - (n2 * n3) + dt.day - 30 + + +def eastern_calc(year_time: int) -> datetime: + a = year_time % 19 + k = year_time // 100 + m = 15 + (3 * k + 3) // 4 - (8 * k + 13) // 25 + d = (19 * a + m) % 30 + s = 2 - (3 * k + 3) // 4 + r = d // 29 + (d // 28 - d // 29) * (a // 11) + og = 21 + d + r + sz = 7 - (year_time + year_time // 4 + s) % 7 + oe = 7 - (og - sz) % 7 + os = og + oe + + if os > 32: + return datetime(year=year_time, month=4, day=(os - 31)) + else: + return datetime(year=year_time, month=3, day=os) + + +def thanksgiving_calc(year_time: int) -> datetime: + year_out = datetime(year=year_time, month=11, day=29) + date_out = datetime(year=year_time, month=11, day=3) + return year_out - timedelta(days=(date_out.weekday() + 2)) + + +def days_feb(year_time: int) -> int: + if int(year_time) % 400 == 0 or int(year_time) % 4 == 0 and not int(year_time) % 100 == 0: + return 29 + else: + return 28 + + +def year_start(year_time: int) -> datetime: + return datetime(year=year_time, month=1, day=1) + + +def calc_sun_time(dt: datetime, timezone: Tuple[float, float, float], sunrise: bool = True) -> datetime: + """ + Calculates the time for sunrise and sunset based on coordinates and a date + :param dt: The date for calculating the sunset + :param timezone: A tuple with longitude and latitude and timezone offset + :param sunrise: If True the sunrise will be calculated if False the sunset + :returns: The time for the sunrise/sunset + """ + + to_rad: float = math.pi / 180 + day: int = day_of_year(dt) + longitude_to_hour = timezone[0] / 15 + b = timezone[1] * to_rad + h = -50 * to_rad / 60 + + time_equation = -0.171 * math.sin(0.0337 * day + 0.465) - 0.1299 * math.sin(0.01787 * day - 0.168) + declination = 0.4095 * math.sin(0.016906 * (day - 80.086)) + + time_difference = 12 * math.acos((math.sin(h) - math.sin(b) * math. sin(declination)) / (math.cos(b) * math.cos(declination))) / math.pi + + if sunrise: # woz -> True time at location + woz = 12 - time_difference + else: + woz = 12 + time_difference + + time: float = (woz - time_equation) - longitude_to_hour + timezone[2] + + hour: int = int(time) + minutes_left: float = time - int(time) + minutes_with_seconds = minutes_left * 60 + minute: int = int(minutes_with_seconds) + second: int = int((minutes_with_seconds - minute) * 60) + + out: datetime = datetime(year=dt.year, month=dt.month, day=dt.day, hour=hour, minute=minute, second=second) + + return out diff --git a/datetimeparser/utils/geometry.py b/datetimeparser/utils/geometry.py new file mode 100644 index 0000000..5cef2d8 --- /dev/null +++ b/datetimeparser/utils/geometry.py @@ -0,0 +1,23 @@ +from timezonefinder import TimezoneFinder +from typing import Tuple + + +class TimeZoneManager(TimezoneFinder): + + def __init__(self): + super(TimeZoneManager, self).__init__(in_memory=True) + + def get_coordinates(self, timezone: str) -> Tuple[float, float]: + coords = self.get_geometry(tz_name=timezone, coords_as_pairs=True) + + while not isinstance(coords[0], Tuple): + coords = coords[len(coords) // 2] + + coords: Tuple[float, float] = coords[len(coords) // 2] + + # timezone = self.timezone_at(lng=coords[0] + 1, lat=coords[1]) + # TODO: needs to be improved, at the moment it's just a small fix, not tested if it works with all timezones + # TODO: add testcases for ALL timezones if possible to check if the "+1" fix is working + # at the moment it returns "Europe/Belgium" if the timezone "Europe/Berlin" is used -> the "+1" on longitude fixes that + + return coords[0] + 1, coords[1] diff --git a/datetimeparser/utils/models.py b/datetimeparser/utils/models.py new file mode 100644 index 0000000..4fc49f7 --- /dev/null +++ b/datetimeparser/utils/models.py @@ -0,0 +1,31 @@ +from datetime import datetime +from typing import Tuple + + +class Result: + """ + The returned Result by the parse function, containing the output information + + - Attributes: + - time (datetime): The parsed time + - timezone (str): The used timezone + - coordinates (Optional[tuple[float, float]]): Coordinates used for parsing + + """ + time: datetime + timezone: str + coordinates: Tuple[float, float] + + def __init__(self, time: datetime, timezone: str, coordinates: Tuple[float, float] = None): + self.time = time + self.timezone = timezone + self.coordinates = coordinates + + def __repr__(self): + out: str = "'None'" + if self.coordinates: + out: str = f"[longitude='{self.coordinates[0]}', latitude='{self.coordinates[1]}]'" + return f"" + + def __str__(self): + return self.__repr__() diff --git a/editreadme.py b/editreadme.py index 4d5ee42..6f32ede 100644 --- a/editreadme.py +++ b/editreadme.py @@ -1,4 +1,4 @@ -from datetimeparser.enums import ( +from datetimeparser.utils.enums import ( Constants, MonthConstants, WeekdayConstants, diff --git a/requirements.txt b/requirements.txt index 0b65956..4f39626 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ python-dateutil -pytz -typing \ No newline at end of file +timezonefinder +typing +tzdata diff --git a/setup.cfg b/setup.cfg index 224a779..3f0b1ee 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,5 @@ [metadata] -description-file = README.md \ No newline at end of file +description-file = README.md + +[options] +packages = find: diff --git a/setup.py b/setup.py index 032c53d..fa3bd79 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,22 @@ +import re from distutils.core import setup from pathlib import Path -from datetimeparser.datetimeparser import __version__ - # for the readme this_directory = Path(__file__).parent +__version__ = re.search( + r"^__version__\s*=\s*[\'\"]([^\'\"]*)[\'\"]", + this_directory.joinpath("datetimeparser/datetimeparser.py").read_text(encoding="utf-8"), + re.MULTILINE +).group(1) long_description = (this_directory / "README.md").read_text(encoding="utf-8") setup( name='python-datetimeparser', long_description_content_type="text/markdown", long_description=long_description, - packages=['datetimeparser'], - version=".".join(__version__.split(".")[:2]) + "rc1." + __version__.split(".")[2], # version number: https://peps.python.org/pep-0440/ + version=__version__, license='MIT', description='A parser library built for parsing the english language into datetime objects.', author='Ari24', @@ -24,7 +27,8 @@ install_requires=[ 'python-dateutil', 'pytz', - 'typing' + 'typing', + 'timezonefinder' ], classifiers=[ 'Development Status :: 4 - Beta', # Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package diff --git a/tests/runtests.py b/tests/runtests.py index 3a799ca..5e478e4 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -45,10 +45,11 @@ def get_testcase_results(testcase: str, expected_value: datetime.datetime = None if parser_result is None: return StatusType.PARSER_RETURNS_NONE, None - evaluator = Evaluator(parser_result, tz="Europe/Berlin") + evaluator = Evaluator(parser_result, tz="Europe/Berlin", coordinates=None) + # Berlin (13.41053, 52.52437), Dubai (55.2962, 25.2684) try: - evaluator_result = evaluator.evaluate() + evaluator_result, _, _ = evaluator.evaluate() except BaseException as error: if expected_value == ThrowException: return StatusType.SUCCESS, "Evaluator threw exception but it was expected" @@ -116,7 +117,7 @@ def evaluate_testcases(testcase_results: dict, disable_color=False, disable_inde elif StatusType.NO_VALIDATION < status < StatusType.WRONG_RESULT: if status in (StatusType.PARSER_RETURNS_NONE, StatusType.EVALUATOR_RETURNS_NONE): message = f"{Colors.ANSI_CYAN}{'Parser' if status == StatusType.PARSER_RETURNS_NONE else 'Evaluator'} {Colors.ANSI_BOLD_WHITE}returned {Colors.ANSI_LIGHT_RED}None" - elif status in (StatusType.PARSER_EXCEPTION, StatusType.EVALUATOR_RETURNS_NONE): + elif status in (StatusType.PARSER_EXCEPTION, StatusType.EVALUATOR_EXCEPTION): message = f"{Colors.ANSI_CYAN}{'Parser' if status == StatusType.PARSER_EXCEPTION else 'Evaluator'} {Colors.ANSI_BOLD_WHITE}raised an {Colors.ANSI_LIGHT_RED}exception: {Colors.ANSI_WHITE}{info}" else: continue @@ -132,7 +133,7 @@ def evaluate_testcases(testcase_results: dict, disable_color=False, disable_inde elif StatusType.NO_VALIDATION < status < StatusType.WRONG_RESULT: if status in (StatusType.PARSER_RETURNS_NONE, StatusType.EVALUATOR_RETURNS_NONE): message = f"{'Parser' if status == StatusType.PARSER_RETURNS_NONE else 'Evaluator'} returned None" - elif status in (StatusType.PARSER_EXCEPTION, StatusType.EVALUATOR_RETURNS_NONE): + elif status in (StatusType.PARSER_EXCEPTION, StatusType.EVALUATOR_EXCEPTION): message = f"{'Parser' if status == StatusType.PARSER_EXCEPTION else 'Evaluator'} raised an exception: {info}" else: continue @@ -149,8 +150,8 @@ def evaluate_testcases(testcase_results: dict, disable_color=False, disable_inde print(f"{Colors.ANSI_YELLOW}No validation tests: {Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.NO_VALIDATION]}/{len_testcases}") print(f"{Colors.ANSI_RED}Wrong result tests: {Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.WRONG_RESULT]}/{len_testcases}") print() - print(f"{Colors.ANSI_RED}Parser returned None: {Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.PARSER_EXCEPTION]}/{len_testcases}") - print(f"{Colors.ANSI_LIGHT_RED}{Colors.ANSI_UNDERLINE}Parser exceptions: {Colors.ANSI_RESET}{Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.PARSER_RETURNS_NONE]}/{len_testcases}") + print(f"{Colors.ANSI_RED}Parser returned None: {Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.PARSER_RETURNS_NONE]}/{len_testcases}") + print(f"{Colors.ANSI_LIGHT_RED}{Colors.ANSI_UNDERLINE}Parser exceptions: {Colors.ANSI_RESET}{Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.PARSER_EXCEPTION]}/{len_testcases}") print(f"{Colors.ANSI_RED}Evaluator returned None: {Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.EVALUATOR_RETURNS_NONE]}/{len_testcases}") print(f"{Colors.ANSI_LIGHT_RED}{Colors.ANSI_UNDERLINE}Evaluator exceptions: {Colors.ANSI_RESET}{Colors.ANSI_BOLD_WHITE}{overall_results[StatusType.EVALUATOR_EXCEPTION]}/{len_testcases}") else: diff --git a/tests/testcases.py b/tests/testcases.py index 0443e8c..6f150ae 100644 --- a/tests/testcases.py +++ b/tests/testcases.py @@ -1,6 +1,6 @@ from datetime import datetime from dateutil.relativedelta import relativedelta -from pytz import timezone +from zoneinfo import ZoneInfo class ThrowException: @@ -20,7 +20,7 @@ def __repr__(self): class Expected: - TODAY = datetime.strptime(datetime.now(tz=timezone("Europe/Berlin")).strftime("%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S") + TODAY = datetime.strptime(datetime.now(tz=ZoneInfo("Europe/Berlin")).strftime("%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S") def __new__( cls, now: bool = False, delta: relativedelta = None, time_sensitive: bool = False, @@ -64,6 +64,10 @@ def __new__( return excepted_time +def is_leap_year(year: int) -> bool: + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) + + testcases = { # Absolute datetime formats "absolute_datetime_formats": { @@ -79,8 +83,8 @@ def __new__( # Absolute prepositions "absolute_prepositions": { "second day after christmas": Expected(time_sensitive=True, month=12, day=27), - "3rd week of august": Expected(time_sensitive=True, month=8, day=22), - "4. week of august": Expected(time_sensitive=True, month=8, day=29), + "3rd week of august": None, # Removed, because 3rd week of August is different for each year + "4. week of august": None, # Same reasoning as above "1st of august": Expected(time_sensitive=True, month=8, day=1), "fifth month of 2021": Expected(year=2021, month=5, day=1), "three days after the fifth of august 2018": Expected(year=2018, month=8, day=8), @@ -102,6 +106,8 @@ def __new__( # GitHub issue #176 "10 days after pi-day": Expected(time_sensitive=True, month=3, day=14, delta=relativedelta(days=10)), "10 days before tau day": Expected(time_sensitive=True, month=6, day=28, delta=relativedelta(days=-10)), + # GitHub issue #198 + "second monday of august 2023": Expected(year=2023, month=8, day=14), }, # Relative Datetimes "relative_datetimes": { @@ -122,6 +128,8 @@ def __new__( "next three months": Expected(now=True, delta=relativedelta(months=3)), "today": Expected(), "now": Expected(now=True), + # GitHub issue #45 + "after lunchtime": None }, # Constants "constants": { @@ -139,7 +147,7 @@ def __new__( "eastern 2010": Expected(year=2010, month=4, day=4), "halloween 2030": Expected(year=2030, month=10, day=31), "next april fools day": Expected(time_sensitive=True, month=4, day=1), - "thanksgiving": Expected(time_sensitive=True, month=11, day=24), + "thanksgiving": Expected(time_sensitive=True, month=11, day=23), "next st patricks day": Expected(time_sensitive=True, month=3, day=17), "valentine day 2010": Expected(year=2010, month=2, day=14), "summer": Expected(time_sensitive=True, month=6, day=1), @@ -147,7 +155,8 @@ def __new__( "next spring": Expected(time_sensitive=True, month=3, day=1), "begin of fall 2010": Expected(year=2010, month=9, day=1), "summer end": Expected(time_sensitive=True, month=8, day=31, hour=23, minute=59, second=59), - "end of winter": Expected(time_sensitive=True, month=2, day=28, hour=23, minute=59, second=59), + f"end of winter {datetime.today().year}": Expected(year=datetime.today().year, month=2, day=28 + is_leap_year(datetime.today().year), + hour=23, minute=59, second=59), "end of the spring": Expected(time_sensitive=True, month=5, day=31, hour=23, minute=59, second=59), "end of autumn 2020": Expected(year=2020, month=11, day=30, hour=23, minute=59, second=59), "begin of advent of code 2022": Expected(year=2022, month=12, day=1, hour=6), @@ -156,6 +165,9 @@ def __new__( # GitHub issue #176 "piday": Expected(time_sensitive=True, month=3, day=14), "tauday": Expected(time_sensitive=True, month=6, day=28), + # GitHub issue #45 + "in the morning": None, + "in the evening": None, }, # Constant Relative Extensions "constants_relative_expressions": {