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