Skip to content
Open
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
25 changes: 14 additions & 11 deletions plann/timespec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import zoneinfo
from dataclasses import dataclass

import dateutil
import dateutil.parser
import dateparser

"""
Most important content:
Expand All @@ -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.
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]))
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dependencies = [
"PyYAML",
"sortedcontainers",
"tzlocal",
"python-dateutil",
"dateparser>=1.2",
"icalendar",
]

Expand Down
2 changes: 1 addition & 1 deletion tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions tests/test_timespec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading