comparison libervia/backend/tools/xmpp_datetime.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/tools/xmpp_datetime.py@cecf45416403
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # Libervia: XMPP Date and Time profiles as per XEP-0082
4 # Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from datetime import date, datetime, time, timezone
20 import re
21 from typing import Optional, Tuple
22
23 from libervia.backend.core import exceptions
24
25
26 __all__ = [ # pylint: disable=unused-variable
27 "format_date",
28 "parse_date",
29 "format_datetime",
30 "parse_datetime",
31 "format_time",
32 "parse_time"
33 ]
34
35
36 def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]:
37 """
38 datetime's strptime only supports up to six digits of the fraction of a seconds, while
39 the XEP-0082 specification allows for any number of digits. This function parses and
40 removes the optional fraction of a second from the input string.
41
42 @param value: The input string, containing a section of the format [.sss].
43 @return: The input string with the fraction of a second removed, and the fraction of a
44 second parsed with microsecond resolution. Returns the unaltered input string and
45 ``None`` if no fraction of a second was found in the input string.
46 """
47
48 # The following regex matches the optional fraction of a seconds for manual
49 # processing.
50 match = re.search(r"\.(\d*)", value)
51 microsecond: Optional[int] = None
52 if match is not None:
53 # Remove the fraction of a second from the input string
54 value = value[:match.start()] + value[match.end():]
55
56 # datetime supports microsecond resolution for the fraction of a second, thus
57 # limit/pad the parsed fraction of a second to six digits
58 microsecond = int(match.group(1)[:6].ljust(6, '0'))
59
60 return value, microsecond
61
62
63 def format_date(value: Optional[date] = None) -> str:
64 """
65 @param value: The date for format. Defaults to the current date in the UTC timezone.
66 @return: The date formatted according to the Date profile specified in XEP-0082.
67
68 @warning: Formatting of the current date in the local timezone may leak geographical
69 information of the sender. Thus, it is advised to only format the current date in
70 UTC.
71 """
72 # CCYY-MM-DD
73
74 # The Date profile of XEP-0082 is equal to the ISO 8601 format.
75 return (datetime.now(timezone.utc).date() if value is None else value).isoformat()
76
77
78 def parse_date(value: str) -> date:
79 """
80 @param value: A string containing date information formatted according to the Date
81 profile specified in XEP-0082.
82 @return: The date parsed from the input string.
83 @raise exceptions.ParsingError: if the input string is not correctly formatted.
84 """
85 # CCYY-MM-DD
86
87 # The Date profile of XEP-0082 is equal to the ISO 8601 format.
88 try:
89 return date.fromisoformat(value)
90 except ValueError as e:
91 raise exceptions.ParsingError() from e
92
93
94 def format_datetime(
95 value: Optional[datetime] = None,
96 include_microsecond: bool = False
97 ) -> str:
98 """
99 @param value: The datetime to format. Defaults to the current datetime.
100 must be an aware datetime object (timezone must be specified)
101 @param include_microsecond: Include the microsecond of the datetime in the output.
102 @return: The datetime formatted according to the DateTime profile specified in
103 XEP-0082. The datetime is always converted to UTC before formatting to avoid
104 leaking geographical information of the sender.
105 """
106 # CCYY-MM-DDThh:mm:ss[.sss]TZD
107
108 # We format the time in UTC, since the %z formatter of strftime doesn't include colons
109 # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a
110 # simple letter 'Z' as the time zone definition.
111 if value is not None:
112 if value.tzinfo is None:
113 raise exceptions.InternalError(
114 "an aware datetime object must be used, but a naive one has been provided"
115 )
116 value = value.astimezone(timezone.utc) # pylint: disable=no-member
117 else:
118 value = datetime.now(timezone.utc)
119
120 if include_microsecond:
121 return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
122
123 return value.strftime("%Y-%m-%dT%H:%M:%SZ")
124
125
126 def parse_datetime(value: str) -> datetime:
127 """
128 @param value: A string containing datetime information formatted according to the
129 DateTime profile specified in XEP-0082.
130 @return: The datetime parsed from the input string.
131 @raise exceptions.ParsingError: if the input string is not correctly formatted.
132 """
133 # CCYY-MM-DDThh:mm:ss[.sss]TZD
134
135 value, microsecond = __parse_fraction_of_a_second(value)
136
137 try:
138 result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z")
139 except ValueError as e:
140 raise exceptions.ParsingError() from e
141
142 if microsecond is not None:
143 result = result.replace(microsecond=microsecond)
144
145 return result
146
147
148 def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str:
149 """
150 @param value: The time to format. Defaults to the current time in the UTC timezone.
151 @param include_microsecond: Include the microsecond of the time in the output.
152 @return: The time formatted according to the Time profile specified in XEP-0082.
153
154 @warning: Since accurate timezone conversion requires the date to be known, this
155 function cannot convert input times to UTC before formatting. This means that
156 geographical information of the sender may be leaked if a time in local timezone
157 is formatted. Thus, when passing a time to format, it is advised to pass the time
158 in UTC if possible.
159 """
160 # hh:mm:ss[.sss][TZD]
161
162 if value is None:
163 # There is no time.now() method as one might expect, but the current time can be
164 # extracted from a datetime object including time zone information.
165 value = datetime.now(timezone.utc).timetz()
166
167 # The format created by time.isoformat complies with the XEP-0082 Time profile.
168 return value.isoformat("auto" if include_microsecond else "seconds")
169
170
171 def parse_time(value: str) -> time:
172 """
173 @param value: A string containing time information formatted according to the Time
174 profile specified in XEP-0082.
175 @return: The time parsed from the input string.
176 @raise exceptions.ParsingError: if the input string is not correctly formatted.
177 """
178 # hh:mm:ss[.sss][TZD]
179
180 value, microsecond = __parse_fraction_of_a_second(value)
181
182 # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time
183 # profile, except that it doesn't handle the letter Z as time zone information for
184 # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which
185 # is another way to represent UTC.
186 try:
187 result = time.fromisoformat(value.replace('Z', "+00:00"))
188 except ValueError as e:
189 raise exceptions.ParsingError() from e
190
191 if microsecond is not None:
192 result = result.replace(microsecond=microsecond)
193
194 return result