Skip to content

Commit 9275d07

Browse files
committed
Support ISO time intervals
Previously, the only way an interval could be specified was through an ambiguous date, which has limited flexibility. Conveniently, the aniso8601 library provides a function to parse the various ISO interval formats¹. Note that this supports time information where all other date format support and logic is based on date without time information. This is fine because it is trivial to ignore the time information and have everything work as expected. It seems far-fetched for now, but maybe someday we will end up using time information, since date is inherently ambiguous without at least a time zone. ¹ <https://en.wikipedia.org/wiki/ISO_8601#Time_intervals>
1 parent 033c73b commit 9275d07

File tree

3 files changed

+70
-0
lines changed

3 files changed

+70
-0
lines changed

Diff for: augur/dates/__init__.py

+20
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import aniso8601
12
import argparse
23
import datetime
34
from textwrap import dedent
@@ -121,6 +122,25 @@ def get_numerical_date_from_value(value, fmt=None, min_max_year=None):
121122
except InvalidDate as error:
122123
raise AugurError(str(error)) from error
123124
return [treetime.utils.numeric_date(d) for d in ambig_date]
125+
if '/' in value:
126+
# ISO interval
127+
try:
128+
start, end = aniso8601.parse_interval(value)
129+
except ValueError as e:
130+
raise AugurError(f"Invalid date {value!r}: {e}") from e
131+
132+
# ignore time parts - we don't use this info, and dropping it allows for comparisons
133+
if isinstance(start, datetime.datetime):
134+
start = start.date()
135+
if isinstance(end, datetime.datetime):
136+
end = start.date()
137+
138+
# sort because aniso8601.parse_interval can return dates out of order
139+
# https://bitbucket.org/nielsenb/aniso8601/src/a4f767de4429b246356b7a3bc98b24c68ce49477/aniso8601/interval.py#lines-236:240
140+
if start > end:
141+
start, end = end, start
142+
143+
return list(map(treetime.utils.numeric_date, (start, end)))
124144
try:
125145
return treetime.utils.numeric_date(datetime.datetime.strptime(value, fmt))
126146
except:

Diff for: setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
package_data = {'augur': ['data/*']},
5252
python_requires = '>={}'.format('.'.join(str(n) for n in py_min_version)),
5353
install_requires = [
54+
"aniso8601 >=10.0.0, ==10.*",
5455
"bcbio-gff >=0.7.1, ==0.7.*",
5556
# TODO: Remove biopython >= 1.80 pin if it is added to bcbio-gff: https://github.com/chapmanb/bcbb/issues/142
5657
"biopython >=1.80, ==1.*",

Diff for: tests/dates/test_dates.py

+49
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,55 @@ def test_get_numerical_date_from_value_current_day_limit(self):
6060
== pytest.approx(2000.138, abs=1e-3)
6161
)
6262

63+
def test_get_numerical_date_from_value_interval(self):
64+
# Valid ISO dates form an interval.
65+
assert dates.get_numerical_date_from_value("2019-01-02/2019-03-04") == [
66+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=1, day=2)), abs=1e-3),
67+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=3, day=4)), abs=1e-3),
68+
]
69+
70+
# Time parts are valid but ignored.
71+
assert dates.get_numerical_date_from_value("2019-01-02T13:00:00Z/2019-03-04") == [
72+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=1, day=2)), abs=1e-3),
73+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=3, day=4)), abs=1e-3),
74+
]
75+
76+
# Shorthands are valid.
77+
assert dates.get_numerical_date_from_value("2019-01-02/03-04") == [
78+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=1, day=2)), abs=1e-3),
79+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=3, day=4)), abs=1e-3),
80+
]
81+
82+
# Reduced precision dates are not supported.
83+
with pytest.raises(AugurError):
84+
dates.get_numerical_date_from_value("2019-01/2019-03")
85+
86+
# This reduced precision date works.
87+
assert dates.get_numerical_date_from_value("2019-01/2019-03-04") == [
88+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=1, day=1)), abs=1e-3),
89+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=3, day=4)), abs=1e-3),
90+
]
91+
92+
# This one does not.
93+
with pytest.raises(AugurError):
94+
dates.get_numerical_date_from_value("2019-01-02/2019-03")
95+
96+
# Start and duration is valid.
97+
assert dates.get_numerical_date_from_value("2019-01-02/P1M") == [
98+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=1, day=2)), abs=1e-3),
99+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=2, day=1)), abs=1e-3),
100+
]
101+
102+
# Duration and end is valid.
103+
assert dates.get_numerical_date_from_value("P1M/2019-03-04") == [
104+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=2, day=2)), abs=1e-3),
105+
pytest.approx(dates.numeric_date(datetime.date(year=2019, month=3, day=4)), abs=1e-3),
106+
]
107+
108+
# Numerical dates are not valid.
109+
with pytest.raises(AugurError):
110+
dates.get_numerical_date_from_value("2019.0/2019-06-01")
111+
63112
def test_is_date_ambiguous(self):
64113
"""is_date_ambiguous should return true for ambiguous dates and false for valid dates."""
65114
# Test complete date strings with ambiguous values.

0 commit comments

Comments
 (0)