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