Mercurial > libervia-backend
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 |