Mercurial > libervia-backend
comparison sat/tools/xmpp_datetime.py @ 3879:46930301f0c1
tools: renamed module `sat.tools.datetime` to `date.tools.xmpp_datetime` to avoid conflict with Python's standard lib
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 31 Aug 2022 13:18:56 +0200 |
parents | sat/tools/datetime.py@32087d7c25d4 |
children | 8289ac1b34f4 |
comparison
equal
deleted
inserted
replaced
3878:32087d7c25d4 | 3879:46930301f0c1 |
---|---|
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 # Type-check with `mypy --strict` | |
20 # Lint with `pylint` | |
21 | |
22 from sat.core import exceptions | |
23 from datetime import date, datetime, time, timezone | |
24 import re | |
25 from typing import Optional, Tuple | |
26 | |
27 | |
28 __all__ = [ # pylint: disable=unused-variable | |
29 "format_date", | |
30 "parse_date", | |
31 "format_datetime", | |
32 "parse_datetime", | |
33 "format_time", | |
34 "parse_time" | |
35 ] | |
36 | |
37 | |
38 def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]: | |
39 """ | |
40 datetime's strptime only supports up to six digits of the fraction of a seconds, while | |
41 the XEP-0082 specification allows for any number of digits. This function parses and | |
42 removes the optional fraction of a second from the input string. | |
43 | |
44 @param value: The input string, containing a section of the format [.sss]. | |
45 @return: The input string with the fraction of a second removed, and the fraction of a | |
46 second parsed with microsecond resolution. Returns the unaltered input string and | |
47 ``None`` if no fraction of a second was found in the input string. | |
48 """ | |
49 | |
50 # The following regex matches the optional fraction of a seconds for manual | |
51 # processing. | |
52 match = re.search(r"\.(\d*)", value) | |
53 microsecond: Optional[int] = None | |
54 if match is not None: | |
55 # Remove the fraction of a second from the input string | |
56 value = value[:match.start()] + value[match.end():] | |
57 | |
58 # datetime supports microsecond resolution for the fraction of a second, thus | |
59 # limit/pad the parsed fraction of a second to six digits | |
60 microsecond = int(match.group(1)[:6].ljust(6, '0')) | |
61 | |
62 return value, microsecond | |
63 | |
64 | |
65 def format_date(value: Optional[date] = None) -> str: | |
66 """ | |
67 @param value: The date for format. Defaults to the current date in the UTC timezone. | |
68 @return: The date formatted according to the Date profile specified in XEP-0082. | |
69 | |
70 @warning: Formatting of the current date in the local timezone may leak geographical | |
71 information of the sender. Thus, it is advised to only format the current date in | |
72 UTC. | |
73 """ | |
74 # CCYY-MM-DD | |
75 | |
76 # The Date profile of XEP-0082 is equal to the ISO 8601 format. | |
77 return (datetime.now(timezone.utc).date() if value is None else value).isoformat() | |
78 | |
79 | |
80 def parse_date(value: str) -> date: | |
81 """ | |
82 @param value: A string containing date information formatted according to the Date | |
83 profile specified in XEP-0082. | |
84 @return: The date parsed from the input string. | |
85 @raise ValueError: if the input string is not correctly formatted. | |
86 """ | |
87 # CCYY-MM-DD | |
88 | |
89 # The Date profile of XEP-0082 is equal to the ISO 8601 format. | |
90 return date.fromisoformat(value) | |
91 | |
92 | |
93 def format_datetime( | |
94 value: Optional[datetime] = None, | |
95 include_microsecond: bool = False | |
96 ) -> str: | |
97 """ | |
98 @param value: The datetime to format. Defaults to the current datetime. | |
99 must be an aware datetime object (timezone must be specified) | |
100 @param include_microsecond: Include the microsecond of the datetime in the output. | |
101 @return: The datetime formatted according to the DateTime profile specified in | |
102 XEP-0082. The datetime is always converted to UTC before formatting to avoid | |
103 leaking geographical information of the sender. | |
104 """ | |
105 # CCYY-MM-DDThh:mm:ss[.sss]TZD | |
106 | |
107 # We format the time in UTC, since the %z formatter of strftime doesn't include colons | |
108 # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a | |
109 # simple letter 'Z' as the time zone definition. | |
110 if value is not None: | |
111 if value.tzinfo is None: | |
112 raise exceptions.InternalError( | |
113 "an aware datetime object must be used, but a naive one has been provided" | |
114 ) | |
115 value = value.astimezone(timezone.utc) # pylint: disable=no-member | |
116 else: | |
117 value = datetime.now(timezone.utc) | |
118 | |
119 if include_microsecond: | |
120 return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") | |
121 | |
122 return value.strftime("%Y-%m-%dT%H:%M:%SZ") | |
123 | |
124 | |
125 def parse_datetime(value: str) -> datetime: | |
126 """ | |
127 @param value: A string containing datetime information formatted according to the | |
128 DateTime profile specified in XEP-0082. | |
129 @return: The datetime parsed from the input string. | |
130 @raise ValueError: if the input string is not correctly formatted. | |
131 """ | |
132 # CCYY-MM-DDThh:mm:ss[.sss]TZD | |
133 | |
134 value, microsecond = __parse_fraction_of_a_second(value) | |
135 | |
136 result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z") | |
137 | |
138 if microsecond is not None: | |
139 result = result.replace(microsecond=microsecond) | |
140 | |
141 return result | |
142 | |
143 | |
144 def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str: | |
145 """ | |
146 @param value: The time to format. Defaults to the current time in the UTC timezone. | |
147 @param include_microsecond: Include the microsecond of the time in the output. | |
148 @return: The time formatted according to the Time profile specified in XEP-0082. | |
149 | |
150 @warning: Since accurate timezone conversion requires the date to be known, this | |
151 function cannot convert input times to UTC before formatting. This means that | |
152 geographical information of the sender may be leaked if a time in local timezone | |
153 is formatted. Thus, when passing a time to format, it is advised to pass the time | |
154 in UTC if possible. | |
155 """ | |
156 # hh:mm:ss[.sss][TZD] | |
157 | |
158 if value is None: | |
159 # There is no time.now() method as one might expect, but the current time can be | |
160 # extracted from a datetime object including time zone information. | |
161 value = datetime.now(timezone.utc).timetz() | |
162 | |
163 # The format created by time.isoformat complies with the XEP-0082 Time profile. | |
164 return value.isoformat("auto" if include_microsecond else "seconds") | |
165 | |
166 | |
167 def parse_time(value: str) -> time: | |
168 """ | |
169 @param value: A string containing time information formatted according to the Time | |
170 profile specified in XEP-0082. | |
171 @return: The time parsed from the input string. | |
172 @raise ValueError: if the input string is not correctly formatted. | |
173 """ | |
174 # hh:mm:ss[.sss][TZD] | |
175 | |
176 value, microsecond = __parse_fraction_of_a_second(value) | |
177 | |
178 # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time | |
179 # profile, except that it doesn't handle the letter Z as time zone information for | |
180 # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which | |
181 # is another way to represent UTC. | |
182 result = time.fromisoformat(value.replace('Z', "+00:00")) | |
183 | |
184 if microsecond is not None: | |
185 result = result.replace(microsecond=microsecond) | |
186 | |
187 return result |