-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathinterval.py
183 lines (156 loc) · 6.93 KB
/
interval.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# Pre 3.10 requires Union for multiple types, e.g. Union[int, None] instead of int | None
from typing import Optional, Union
from undate import Undate
from undate.date import ONE_DAY, ONE_YEAR, Timedelta
from undate.converters.base import BaseDateConverter
class UndateInterval:
"""A date range between two uncertain dates.
:param earliest: Earliest undate
:type earliest: `undate.Undate`
:param latest: Latest undate
:type latest: `undate.Undate`
:param label: A string to label a specific undate interval, similar to labels of `undate.Undate`.
:type label: `str`
"""
# date range between two undates
earliest: Union[Undate, None]
latest: Union[Undate, None]
label: Union[str, None]
# TODO: think about adding an optional precision / length /size field
# using DatePrecision for intervals of any standard duration (decade, century)
def __init__(
self,
earliest: Optional[Undate] = None,
latest: Optional[Undate] = None,
label: Optional[str] = None,
):
# takes two undate objects; allows conversion from supported types
if earliest:
try:
earliest = Undate.to_undate(earliest)
except TypeError as err:
raise ValueError(
f"earliest date {earliest} cannot be converted to Undate"
) from err
if latest:
try:
latest = Undate.to_undate(latest)
except TypeError as err:
raise ValueError(
f"latest date {latest} cannot be converted to Undate"
) from err
# check that the interval is valid
if latest and earliest and latest <= earliest:
raise ValueError(f"invalid interval {earliest}-{latest}")
self.earliest = earliest
self.latest = latest
self.label = label
def __str__(self) -> str:
# using EDTF syntax for open ranges
return "%s/%s" % (self.earliest or "..", self.latest or "")
def format(self, format) -> str:
"""format this undate interval as a string using the specified format;
for now, only supports named converters"""
converter_cls = BaseDateConverter.available_converters().get(format, None)
if converter_cls:
return converter_cls().to_string(self)
raise ValueError(f"Unsupported format '{format}'")
def __repr__(self) -> str:
if self.label:
return "<UndateInterval '%s' (%s)>" % (self.label, self)
return "<UndateInterval %s>" % self
def __eq__(self, other) -> bool:
# currently doesn't support comparison with any other types
if not isinstance(other, UndateInterval):
return NotImplemented
# consider interval equal if both dates are equal
return self.earliest == other.earliest and self.latest == other.latest
def duration(self) -> Timedelta:
"""Calculate the duration between two undates.
Note that durations are inclusive (i.e., a closed interval), and
include both the earliest and latest date rather than the difference
between them.
:returns: A duration
:rtype: Timedelta
"""
# what is the duration of this date range?
# if range is open-ended, can't calculate
if self.earliest is None or self.latest is None:
return NotImplemented
# if both years are known, subtract end of range from beginning of start
if self.latest.known_year and self.earliest.known_year:
return self.latest.latest - self.earliest.earliest + ONE_DAY
# if neither year is known...
elif not self.latest.known_year and not self.earliest.known_year:
# under what circumstances can we assume that if both years
# are unknown the dates are in the same year or sequential?
duration = self.latest.earliest - self.earliest.earliest
# if we get a negative, we've wrapped from end of one year
# to the beginning of the next;
# recalculate assuming second date is in the subsequent year
if duration.days < 0:
end = self.latest.earliest + ONE_YEAR
duration = end - self.earliest.earliest
# add the additional day *after* checking for a negative
# or after recalculating with adjusted year
duration += ONE_DAY
return duration
else:
# is there any meaningful way to calculate duration
# if one year is known and the other is not?
raise NotImplementedError
def __contains__(self, other: object) -> bool:
"""Determine if another interval or date falls within this
interval."""
# support comparison with another interval
if isinstance(other, UndateInterval):
# if two intervals are strictly equal, don't consider
# either one as containing the other
if self == other:
return False
# otherwise compare based on earliest/latest bounds
other_earliest = other.earliest
other_latest = other.latest
else:
# otherwise, try to convert to an Undate
try:
other = Undate.to_undate(other)
other_latest = other_earliest = other
except TypeError:
# if conversion fails, then we don't support comparison
raise
# if either bound of the current interval is None,
# then it is an open interval and we don't need to check the other value.
# if the other value is set, then check that it falls within the
# bounds of this interval
return (
self.earliest is None
or other_earliest is not None
and other_earliest >= self.earliest
) and (
self.latest is None
or other_latest is not None
and other_latest <= self.latest
)
def intersection(self, other: "UndateInterval") -> Optional["UndateInterval"]:
"""Determine the intersection or overlap between two :class:`UndateInterval`
objects and return a new interval. Returns None if there is no overlap.
"""
try:
# when both values are defined, return the inner bounds;
# if not, return whichever is not None, or None
earliest = (
max(self.earliest, other.earliest)
if self.earliest and other.earliest
else self.earliest or other.earliest
)
latest = (
min(self.latest, other.latest)
if self.latest and other.latest
else self.latest or other.latest
)
# if this results in an invalid interval, initialization
# will throw an exception
return UndateInterval(earliest, latest)
except ValueError:
return None