diff --git a/plann/timespec.py b/plann/timespec.py index e4d257e..f991f53 100644 --- a/plann/timespec.py +++ b/plann/timespec.py @@ -3,8 +3,7 @@ import zoneinfo from dataclasses import dataclass -import dateutil -import dateutil.parser +import dateparser """ Most important content: @@ -13,7 +12,8 @@ * parse_timespec - parses a string (which may be a timestamp or an interval) into two datetimes * tz - a singleton containing the default timezones to be used -parse_dt supports things like "+2h" or "now", while parse_timespec doesn't. +parse_dt supports things like "+2h", "now", "yesterday", or "3 hours ago", +while parse_timespec doesn't. The naming of those two are a bit arbitrary and may be changed in a future version of the library. Old names will then continue working as legacy aliases. """ @@ -104,14 +104,17 @@ def _parse_dt(input, return_type=None): if return_type is datetime.datetime: return _ensure_ts(input) return input - ## dateutil.parser.parse does not recognize 'now' if input == 'now': ret = _now() - ## dateutil.parser.parse does not recognize '+2 hours', like date does. elif input.startswith('+'): ret = parse_add_dur(_now(), input[1:]) else: - ret = dateutil.parser.parse(input) + settings: dict = {"RETURN_AS_TIMEZONE_AWARE": True, "PREFER_DATES_FROM": "current_period"} + if tz.implicit_timezone: + settings["TIMEZONE"] = str(tz.implicit_timezone) + ret = dateparser.parse(input, settings=settings) + if ret is None: + raise ValueError(f"Could not parse datetime string: {input!r}") if return_type is datetime.datetime: return _ensure_ts(ret) elif return_type is datetime.date: @@ -209,12 +212,12 @@ def _parse_timespec(timespec): end = parse_add_dur(start, rx.group(2)) return (start, end) try: - ## parse("2015-05-05 2015-05-05") does not throw the ParserError - if timespec.count('-')>3: - raise dateutil.parser.ParserError("Seems to be two dates here") + ## dateparser may misparse strings containing multiple dates; detect early + if timespec.count('-') > 3: + raise ValueError("Seems to be two dates here") ret = parse_dt(timespec) - return (ret,None) - except dateutil.parser.ParserError: + return (ret, None) + except ValueError: split_by_space = timespec.split(' ') if len(split_by_space) == 2: return (parse_dt(split_by_space[0]), parse_dt(split_by_space[1])) diff --git a/pyproject.toml b/pyproject.toml index 78a86bd..ecdbd32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "PyYAML", "sortedcontainers", "tzlocal", - "python-dateutil", + "dateparser>=1.2", "icalendar", ] diff --git a/tests/test_functional.py b/tests/test_functional.py index 5d50226..4615081 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -203,7 +203,7 @@ def dag(obj, reltype, observed=None): assert len(ctx.obj['objs'])==0 ## Journal tests - journal1 = _add_journal(ctx, summary=['bought new keyboard'], set_dtstart='2012-12-20') + _add_journal(ctx, summary=['bought new keyboard'], set_dtstart='2012-12-20') _select(ctx, journal=True) assert len(ctx.obj['objs'])==1 _select(ctx, todo=True) diff --git a/tests/test_timespec.py b/tests/test_timespec.py index 0d3a2e0..2c3fda7 100644 --- a/tests/test_timespec.py +++ b/tests/test_timespec.py @@ -163,6 +163,36 @@ def testCalendarCliFormat(self): } self._testTimeSpec(expected) +class TestNaturalLanguage: + """Tests for natural language date parsing enabled by switching to dateparser.""" + + def test_yesterday(self): + tz.implicit_timezone = "Europe/Oslo" + result = parse_dt("yesterday") + expected_date = (datetime.now().astimezone() - timedelta(days=1)).date() + assert result.date() == expected_date + + def test_today(self): + tz.implicit_timezone = "Europe/Oslo" + result = parse_dt("today") + assert result.date() == datetime.now().astimezone().date() + + def test_relative_hours_ago(self): + tz.implicit_timezone = "Europe/Oslo" + result = parse_dt("3 hours ago") + expected = datetime.now().astimezone() - timedelta(hours=3) + assert abs((result - expected).total_seconds()) < 5 + + def test_day_name(self): + tz.implicit_timezone = "Europe/Oslo" + result = parse_dt("Monday") + assert result.weekday() == 0 # Monday + + def test_invalid_raises(self): + with pytest.raises(ValueError): + parse_dt("not a date at all !!!!") + + def test_ensure_ts(): now = datetime.now() utcnow = now.astimezone(utc)