aboutsummaryrefslogtreecommitdiffhomepage
path: root/libs/aniso8601/duration.py
blob: cdc0f8f7fe8c1d5659bb0d1bf7262061882e5c74 (plain)
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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# -*- coding: utf-8 -*-

# Copyright (c) 2021, Brandon Nielsen
# All rights reserved.
#
# This software may be modified and distributed under the terms
# of the BSD license.  See the LICENSE file for details.

from aniso8601 import compat
from aniso8601.builders import TupleBuilder
from aniso8601.builders.python import PythonTimeBuilder
from aniso8601.date import parse_date
from aniso8601.decimalfraction import normalize
from aniso8601.exceptions import ISOFormatError
from aniso8601.resolution import DurationResolution
from aniso8601.time import parse_time


def get_duration_resolution(isodurationstr):
    # Valid string formats are:
    #
    # PnYnMnDTnHnMnS (or any reduced precision equivalent)
    # PnW
    # P<date>T<time>
    isodurationtuple = parse_duration(isodurationstr, builder=TupleBuilder)

    if isodurationtuple.TnS is not None:
        return DurationResolution.Seconds

    if isodurationtuple.TnM is not None:
        return DurationResolution.Minutes

    if isodurationtuple.TnH is not None:
        return DurationResolution.Hours

    if isodurationtuple.PnD is not None:
        return DurationResolution.Days

    if isodurationtuple.PnW is not None:
        return DurationResolution.Weeks

    if isodurationtuple.PnM is not None:
        return DurationResolution.Months

    return DurationResolution.Years


def parse_duration(isodurationstr, builder=PythonTimeBuilder):
    # Given a string representing an ISO 8601 duration, return a
    # a duration built by the given builder. Valid formats are:
    #
    # PnYnMnDTnHnMnS (or any reduced precision equivalent)
    # PnW
    # P<date>T<time>

    if compat.is_string(isodurationstr) is False:
        raise ValueError("Duration must be string.")

    if len(isodurationstr) == 0:
        raise ISOFormatError(
            '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
        )

    if isodurationstr[0] != "P":
        raise ISOFormatError("ISO 8601 duration must start with a P.")

    # If Y, M, D, H, S, or W are in the string,
    # assume it is a specified duration
    if _has_any_component(isodurationstr, ["Y", "M", "D", "H", "S", "W"]) is True:
        parseresult = _parse_duration_prescribed(isodurationstr)
        return builder.build_duration(**parseresult)

    if isodurationstr.find("T") != -1:
        parseresult = _parse_duration_combined(isodurationstr)
        return builder.build_duration(**parseresult)

    raise ISOFormatError(
        '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
    )


def _parse_duration_prescribed(isodurationstr):
    # durationstr can be of the form PnYnMnDTnHnMnS or PnW

    # Make sure the end character is valid
    # https://bitbucket.org/nielsenb/aniso8601/issues/9/durations-with-trailing-garbage-are-parsed
    if isodurationstr[-1] not in ["Y", "M", "D", "H", "S", "W"]:
        raise ISOFormatError("ISO 8601 duration must end with a valid " "character.")

    # Make sure only the lowest order element has decimal precision
    durationstr = normalize(isodurationstr)

    if durationstr.count(".") > 1:
        raise ISOFormatError(
            "ISO 8601 allows only lowest order element to " "have a decimal fraction."
        )

    seperatoridx = durationstr.find(".")

    if seperatoridx != -1:
        remaining = durationstr[seperatoridx + 1 : -1]

        # There should only ever be 1 letter after a decimal if there is more
        # then one, the string is invalid
        if remaining.isdigit() is False:
            raise ISOFormatError(
                "ISO 8601 duration must end with " "a single valid character."
            )

    # Do not allow W in combination with other designators
    # https://bitbucket.org/nielsenb/aniso8601/issues/2/week-designators-should-not-be-combinable
    if (
        durationstr.find("W") != -1
        and _has_any_component(durationstr, ["Y", "M", "D", "H", "S"]) is True
    ):
        raise ISOFormatError(
            "ISO 8601 week designators may not be combined "
            "with other time designators."
        )

    # Parse the elements of the duration
    if durationstr.find("T") == -1:
        return _parse_duration_prescribed_notime(durationstr)

    return _parse_duration_prescribed_time(durationstr)


def _parse_duration_prescribed_notime(isodurationstr):
    # durationstr can be of the form PnYnMnD or PnW

    durationstr = normalize(isodurationstr)

    yearstr = None
    monthstr = None
    daystr = None
    weekstr = None

    weekidx = durationstr.find("W")
    yearidx = durationstr.find("Y")
    monthidx = durationstr.find("M")
    dayidx = durationstr.find("D")

    if weekidx != -1:
        weekstr = durationstr[1:-1]
    elif yearidx != -1 and monthidx != -1 and dayidx != -1:
        yearstr = durationstr[1:yearidx]
        monthstr = durationstr[yearidx + 1 : monthidx]
        daystr = durationstr[monthidx + 1 : -1]
    elif yearidx != -1 and monthidx != -1:
        yearstr = durationstr[1:yearidx]
        monthstr = durationstr[yearidx + 1 : monthidx]
    elif yearidx != -1 and dayidx != -1:
        yearstr = durationstr[1:yearidx]
        daystr = durationstr[yearidx + 1 : dayidx]
    elif monthidx != -1 and dayidx != -1:
        monthstr = durationstr[1:monthidx]
        daystr = durationstr[monthidx + 1 : -1]
    elif yearidx != -1:
        yearstr = durationstr[1:-1]
    elif monthidx != -1:
        monthstr = durationstr[1:-1]
    elif dayidx != -1:
        daystr = durationstr[1:-1]
    else:
        raise ISOFormatError(
            '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
        )

    for componentstr in [yearstr, monthstr, daystr, weekstr]:
        if componentstr is not None:
            if "." in componentstr:
                intstr, fractionalstr = componentstr.split(".", 1)

                if intstr.isdigit() is False:
                    raise ISOFormatError(
                        '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
                    )
            else:
                if componentstr.isdigit() is False:
                    raise ISOFormatError(
                        '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
                    )

    return {"PnY": yearstr, "PnM": monthstr, "PnW": weekstr, "PnD": daystr}


def _parse_duration_prescribed_time(isodurationstr):
    # durationstr can be of the form PnYnMnDTnHnMnS

    timeidx = isodurationstr.find("T")

    datestr = isodurationstr[:timeidx]
    timestr = normalize(isodurationstr[timeidx + 1 :])

    hourstr = None
    minutestr = None
    secondstr = None

    houridx = timestr.find("H")
    minuteidx = timestr.find("M")
    secondidx = timestr.find("S")

    if houridx != -1 and minuteidx != -1 and secondidx != -1:
        hourstr = timestr[0:houridx]
        minutestr = timestr[houridx + 1 : minuteidx]
        secondstr = timestr[minuteidx + 1 : -1]
    elif houridx != -1 and minuteidx != -1:
        hourstr = timestr[0:houridx]
        minutestr = timestr[houridx + 1 : minuteidx]
    elif houridx != -1 and secondidx != -1:
        hourstr = timestr[0:houridx]
        secondstr = timestr[houridx + 1 : -1]
    elif minuteidx != -1 and secondidx != -1:
        minutestr = timestr[0:minuteidx]
        secondstr = timestr[minuteidx + 1 : -1]
    elif houridx != -1:
        hourstr = timestr[0:-1]
    elif minuteidx != -1:
        minutestr = timestr[0:-1]
    elif secondidx != -1:
        secondstr = timestr[0:-1]
    else:
        raise ISOFormatError(
            '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
        )

    for componentstr in [hourstr, minutestr, secondstr]:
        if componentstr is not None:
            if "." in componentstr:
                intstr, fractionalstr = componentstr.split(".", 1)

                if intstr.isdigit() is False:
                    raise ISOFormatError(
                        '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
                    )
            else:
                if componentstr.isdigit() is False:
                    raise ISOFormatError(
                        '"{0}" is not a valid ISO 8601 duration.'.format(isodurationstr)
                    )

    # Parse any date components
    durationdict = {"PnY": None, "PnM": None, "PnW": None, "PnD": None}

    if len(datestr) > 1:
        durationdict = _parse_duration_prescribed_notime(datestr)

    durationdict.update({"TnH": hourstr, "TnM": minutestr, "TnS": secondstr})

    return durationdict


def _parse_duration_combined(durationstr):
    # Period of the form P<date>T<time>

    # Split the string in to its component parts
    datepart, timepart = durationstr[1:].split("T", 1)  # We skip the 'P'

    datevalue = parse_date(datepart, builder=TupleBuilder)
    timevalue = parse_time(timepart, builder=TupleBuilder)

    return {
        "PnY": datevalue.YYYY,
        "PnM": datevalue.MM,
        "PnD": datevalue.DD,
        "TnH": timevalue.hh,
        "TnM": timevalue.mm,
        "TnS": timevalue.ss,
    }


def _has_any_component(durationstr, components):
    # Given a duration string, and a list of components, returns True
    # if any of the listed components are present, False otherwise.
    #
    # For instance:
    # durationstr = 'P1Y'
    # components = ['Y', 'M']
    #
    # returns True
    #
    # durationstr = 'P1Y'
    # components = ['M', 'D']
    #
    # returns False

    for component in components:
        if durationstr.find(component) != -1:
            return True

    return False