1"""This module provides an ISO 8601:2004 duration parser.
2
3It also provides a wrapper to strftime. This wrapper makes it easier to
4format timedelta or Duration instances as ISO conforming strings.
5"""
6
7import re
8from datetime import date, time, timedelta
9from decimal import Decimal
10from typing import Union, Optional
11
12from isodate.duration import Duration
13from isodate.isodatetime import parse_datetime
14from isodate.isoerror import ISO8601Error
15from isodate.isostrf import D_DEFAULT, strftime
16
17ISO8601_PERIOD_REGEX = re.compile(
18 r"^(?P<sign>[+-])?"
19 r"P(?!\b)"
20 r"(?P<years>[0-9]+([,.][0-9]+)?Y)?"
21 r"(?P<months>[0-9]+([,.][0-9]+)?M)?"
22 r"(?P<weeks>[0-9]+([,.][0-9]+)?W)?"
23 r"(?P<days>[0-9]+([,.][0-9]+)?D)?"
24 r"((?P<separator>T)(?P<hours>[0-9]+([,.][0-9]+)?H)?"
25 r"(?P<minutes>[0-9]+([,.][0-9]+)?M)?"
26 r"(?P<seconds>[0-9]+([,.][0-9]+)?S)?)?$"
27)
28# regular expression to parse ISO duration strings.
29
30
31def parse_duration(
32 datestring: str, as_timedelta_if_possible: bool = True
33) -> Union[timedelta, Duration]:
34 """Parses an ISO 8601 durations into datetime.timedelta or Duration objects.
35
36 If the ISO date string does not contain years or months, a timedelta
37 instance is returned, else a Duration instance is returned.
38
39 The following duration formats are supported:
40 -PnnW duration in weeks
41 -PnnYnnMnnDTnnHnnMnnS complete duration specification
42 -PYYYYMMDDThhmmss basic alternative complete date format
43 -PYYYY-MM-DDThh:mm:ss extended alternative complete date format
44 -PYYYYDDDThhmmss basic alternative ordinal date format
45 -PYYYY-DDDThh:mm:ss extended alternative ordinal date format
46
47 The '-' is optional.
48
49 Limitations: ISO standard defines some restrictions about where to use
50 fractional numbers and which component and format combinations are
51 allowed. This parser implementation ignores all those restrictions and
52 returns something when it is able to find all necessary components.
53 In detail:
54 it does not check, whether only the last component has fractions.
55 it allows weeks specified with all other combinations
56
57 The alternative format does not support durations with years, months or
58 days set to 0.
59 """
60 ret: Optional[Union[timedelta, Duration]] = None
61 if not isinstance(datestring, str):
62 raise TypeError("Expecting a string %r" % datestring)
63 match = ISO8601_PERIOD_REGEX.match(datestring)
64 if not match:
65 # try alternative format:
66 if datestring.startswith("P"):
67 durdt = parse_datetime(datestring[1:])
68 if as_timedelta_if_possible and durdt.year == 0 and durdt.month == 0:
69 # FIXME: currently not possible in alternative format
70 # create timedelta
71 ret = timedelta(
72 days=durdt.day,
73 seconds=durdt.second,
74 microseconds=durdt.microsecond,
75 minutes=durdt.minute,
76 hours=durdt.hour,
77 )
78 else:
79 # create Duration
80 ret = Duration(
81 days=durdt.day,
82 seconds=durdt.second,
83 microseconds=durdt.microsecond,
84 minutes=durdt.minute,
85 hours=durdt.hour,
86 months=durdt.month,
87 years=durdt.year,
88 )
89 return ret
90 raise ISO8601Error("Unable to parse duration string %r" % datestring)
91 groups = match.groupdict()
92 for key, val in groups.items():
93 if key not in ("separator", "sign"):
94 if val is None:
95 groups[key] = "0n"
96 # print groups[key]
97 if key in ("years", "months"):
98 groups[key] = Decimal(groups[key][:-1].replace(",", "."))
99 else:
100 # these values are passed into a timedelta object,
101 # which works with floats.
102 groups[key] = float(groups[key][:-1].replace(",", "."))
103 if as_timedelta_if_possible and groups["years"] == 0 and groups["months"] == 0:
104 ret = timedelta(
105 # values have been converted to float or Decimal
106 days=groups["days"], # type: ignore [arg-type]
107 hours=groups["hours"], # type: ignore [arg-type]
108 minutes=groups["minutes"], # type: ignore [arg-type]
109 seconds=groups["seconds"], # type: ignore [arg-type]
110 weeks=groups["weeks"], # type: ignore [arg-type]
111 )
112 if groups["sign"] == "-":
113 ret = timedelta(0) - ret
114 else:
115 ret = Duration(
116 # values have been converted to float or Decimal
117 years=groups["years"], # type: ignore [arg-type]
118 months=groups["months"], # type: ignore [arg-type]
119 days=groups["days"], # type: ignore [arg-type]
120 hours=groups["hours"], # type: ignore [arg-type]
121 minutes=groups["minutes"], # type: ignore [arg-type]
122 seconds=groups["seconds"], # type: ignore [arg-type]
123 weeks=groups["weeks"], # type: ignore [arg-type]
124 )
125 if groups["sign"] == "-":
126 ret = Duration(0) - ret
127 return ret
128
129
130def duration_isoformat(
131 tduration: Union[timedelta, Duration, time, date], format: str = D_DEFAULT
132) -> str:
133 """Format duration strings.
134
135 This method is just a wrapper around isodate.isostrf.strftime and uses
136 P%P (D_DEFAULT) as default format.
137 """
138 # TODO: implement better decision for negative Durations.
139 # should be done in Duration class in consistent way with timedelta.
140 if (
141 isinstance(tduration, Duration)
142 and (tduration.years < 0 or tduration.months < 0 or tduration.tdelta < timedelta(0))
143 ) or (isinstance(tduration, timedelta) and (tduration < timedelta(0))):
144 ret = "-"
145 else:
146 ret = ""
147 ret += strftime(tduration, format)
148 return ret