1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2025, Brandon Nielsen
4# All rights reserved.
5#
6# This software may be modified and distributed under the terms
7# of the BSD license. See the LICENSE file for details.
8
9from aniso8601 import compat
10from aniso8601.builders import TupleBuilder
11from aniso8601.builders.python import PythonTimeBuilder
12from aniso8601.date import parse_date
13from aniso8601.decimalfraction import normalize
14from aniso8601.exceptions import ISOFormatError
15from aniso8601.resolution import DurationResolution
16from aniso8601.time import parse_time
17
18
19def get_duration_resolution(isodurationstr):
20 # Valid string formats are:
21 #
22 # PnYnMnDTnHnMnS (or any reduced precision equivalent)
23 # PnW
24 # P<date>T<time>
25 isodurationtuple = parse_duration(isodurationstr, builder=TupleBuilder)
26
27 if isodurationtuple.TnS is not None:
28 return DurationResolution.Seconds
29
30 if isodurationtuple.TnM is not None:
31 return DurationResolution.Minutes
32
33 if isodurationtuple.TnH is not None:
34 return DurationResolution.Hours
35
36 if isodurationtuple.PnD is not None:
37 return DurationResolution.Days
38
39 if isodurationtuple.PnW is not None:
40 return DurationResolution.Weeks
41
42 if isodurationtuple.PnM is not None:
43 return DurationResolution.Months
44
45 return DurationResolution.Years
46
47
48def parse_duration(isodurationstr, builder=PythonTimeBuilder):
49 # Given a string representing an ISO 8601 duration, return a
50 # a duration built by the given builder. Valid formats are:
51 #
52 # PnYnMnDTnHnMnS (or any reduced precision equivalent)
53 # PnW
54 # P<date>T<time>
55
56 if compat.is_string(isodurationstr) is False:
57 raise ValueError("Duration must be string.")
58
59 if len(isodurationstr) == 0:
60 raise ISOFormatError(
61 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
62 )
63
64 if isodurationstr[0] != "P":
65 raise ISOFormatError("ISO 8601 duration must start with a P.")
66
67 # If Y, M, D, H, S, or W are in the string,
68 # assume it is a specified duration
69 if _has_any_component(isodurationstr, ["Y", "M", "D", "H", "S", "W"]) is True:
70 parseresult = _parse_duration_prescribed(isodurationstr)
71 return builder.build_duration(**parseresult)
72
73 if isodurationstr.find("T") != -1:
74 parseresult = _parse_duration_combined(isodurationstr)
75 return builder.build_duration(**parseresult)
76
77 raise ISOFormatError(
78 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
79 )
80
81
82def _parse_duration_prescribed(isodurationstr):
83 # durationstr can be of the form PnYnMnDTnHnMnS or PnW
84
85 # Make sure the end character is valid
86 # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed
87 if isodurationstr[-1] not in ["Y", "M", "D", "H", "S", "W"]:
88 raise ISOFormatError("ISO 8601 duration must end with a valid character.")
89
90 # Make sure only the lowest order element has decimal precision
91 durationstr = normalize(isodurationstr)
92
93 if durationstr.count(".") > 1:
94 raise ISOFormatError(
95 "ISO 8601 allows only lowest order element to have a decimal fraction."
96 )
97
98 seperatoridx = durationstr.find(".")
99
100 if seperatoridx != -1:
101 remaining = durationstr[seperatoridx + 1 : -1]
102
103 # There should only ever be 1 letter after a decimal if there is more
104 # then one, the string is invalid
105 if remaining.isdigit() is False:
106 raise ISOFormatError(
107 "ISO 8601 duration must end with a single valid character."
108 )
109
110 # Do not allow W in combination with other designators
111 # https://bitbucket.org/nielsenb/aniso8601/issues/2/week-designators-should-not-be-combinable
112 if (
113 durationstr.find("W") != -1
114 and _has_any_component(durationstr, ["Y", "M", "D", "H", "S"]) is True
115 ):
116 raise ISOFormatError(
117 "ISO 8601 week designators may not be combined "
118 "with other time designators."
119 )
120
121 # Parse the elements of the duration
122 if durationstr.find("T") == -1:
123 return _parse_duration_prescribed_notime(durationstr)
124
125 return _parse_duration_prescribed_time(durationstr)
126
127
128def _parse_duration_prescribed_notime(isodurationstr):
129 # durationstr can be of the form PnYnMnD or PnW
130
131 durationstr = normalize(isodurationstr)
132
133 yearstr = None
134 monthstr = None
135 daystr = None
136 weekstr = None
137
138 weekidx = durationstr.find("W")
139 yearidx = durationstr.find("Y")
140 monthidx = durationstr.find("M")
141 dayidx = durationstr.find("D")
142
143 if weekidx != -1:
144 weekstr = durationstr[1:-1]
145 elif yearidx != -1 and monthidx != -1 and dayidx != -1:
146 yearstr = durationstr[1:yearidx]
147 monthstr = durationstr[yearidx + 1 : monthidx]
148 daystr = durationstr[monthidx + 1 : -1]
149 elif yearidx != -1 and monthidx != -1:
150 yearstr = durationstr[1:yearidx]
151 monthstr = durationstr[yearidx + 1 : monthidx]
152 elif yearidx != -1 and dayidx != -1:
153 yearstr = durationstr[1:yearidx]
154 daystr = durationstr[yearidx + 1 : dayidx]
155 elif monthidx != -1 and dayidx != -1:
156 monthstr = durationstr[1:monthidx]
157 daystr = durationstr[monthidx + 1 : -1]
158 elif yearidx != -1:
159 yearstr = durationstr[1:-1]
160 elif monthidx != -1:
161 monthstr = durationstr[1:-1]
162 elif dayidx != -1:
163 daystr = durationstr[1:-1]
164 else:
165 raise ISOFormatError(
166 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
167 )
168
169 for componentstr in [yearstr, monthstr, daystr, weekstr]:
170 if componentstr is not None:
171 if "." in componentstr:
172 intstr = componentstr.split(".", 1)[0]
173
174 if intstr.isdigit() is False:
175 raise ISOFormatError(
176 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
177 )
178 else:
179 if componentstr.isdigit() is False:
180 raise ISOFormatError(
181 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
182 )
183
184 return {"PnY": yearstr, "PnM": monthstr, "PnW": weekstr, "PnD": daystr}
185
186
187def _parse_duration_prescribed_time(isodurationstr):
188 # durationstr can be of the form PnYnMnDTnHnMnS
189
190 timeidx = isodurationstr.find("T")
191
192 datestr = isodurationstr[:timeidx]
193 timestr = normalize(isodurationstr[timeidx + 1 :])
194
195 hourstr = None
196 minutestr = None
197 secondstr = None
198
199 houridx = timestr.find("H")
200 minuteidx = timestr.find("M")
201 secondidx = timestr.find("S")
202
203 if houridx != -1 and minuteidx != -1 and secondidx != -1:
204 hourstr = timestr[0:houridx]
205 minutestr = timestr[houridx + 1 : minuteidx]
206 secondstr = timestr[minuteidx + 1 : -1]
207 elif houridx != -1 and minuteidx != -1:
208 hourstr = timestr[0:houridx]
209 minutestr = timestr[houridx + 1 : minuteidx]
210 elif houridx != -1 and secondidx != -1:
211 hourstr = timestr[0:houridx]
212 secondstr = timestr[houridx + 1 : -1]
213 elif minuteidx != -1 and secondidx != -1:
214 minutestr = timestr[0:minuteidx]
215 secondstr = timestr[minuteidx + 1 : -1]
216 elif houridx != -1:
217 hourstr = timestr[0:-1]
218 elif minuteidx != -1:
219 minutestr = timestr[0:-1]
220 elif secondidx != -1:
221 secondstr = timestr[0:-1]
222 else:
223 raise ISOFormatError(
224 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
225 )
226
227 for componentstr in [hourstr, minutestr, secondstr]:
228 if componentstr is not None:
229 if "." in componentstr:
230 intstr = componentstr.split(".", 1)[0]
231
232 if intstr.isdigit() is False:
233 raise ISOFormatError(
234 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
235 )
236 else:
237 if componentstr.isdigit() is False:
238 raise ISOFormatError(
239 '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
240 )
241
242 # Parse any date components
243 durationdict = {"PnY": None, "PnM": None, "PnW": None, "PnD": None}
244
245 if len(datestr) > 1:
246 durationdict = _parse_duration_prescribed_notime(datestr)
247
248 durationdict.update({"TnH": hourstr, "TnM": minutestr, "TnS": secondstr})
249
250 return durationdict
251
252
253def _parse_duration_combined(durationstr):
254 # Period of the form P<date>T<time>
255
256 # Split the string in to its component parts
257 datepart, timepart = durationstr[1:].split("T", 1) # We skip the 'P'
258
259 datevalue = parse_date(datepart, builder=TupleBuilder)
260 timevalue = parse_time(timepart, builder=TupleBuilder)
261
262 return {
263 "PnY": datevalue.YYYY,
264 "PnM": datevalue.MM,
265 "PnD": datevalue.DD,
266 "TnH": timevalue.hh,
267 "TnM": timevalue.mm,
268 "TnS": timevalue.ss,
269 }
270
271
272def _has_any_component(durationstr, components):
273 # Given a duration string, and a list of components, returns True
274 # if any of the listed components are present, False otherwise.
275 #
276 # For instance:
277 # durationstr = 'P1Y'
278 # components = ['Y', 'M']
279 #
280 # returns True
281 #
282 # durationstr = 'P1Y'
283 # components = ['M', 'D']
284 #
285 # returns False
286
287 for component in components:
288 if durationstr.find(component) != -1:
289 return True
290
291 return False