# HG changeset patch # User Goffi # Date 1661944736 -7200 # Node ID 46930301f0c15c3f6bb54f18dc046408caaa950d # Parent 32087d7c25d40021e551799c47e9542ccd683d50 tools: renamed module `sat.tools.datetime` to `date.tools.xmpp_datetime` to avoid conflict with Python's standard lib diff -r 32087d7c25d4 -r 46930301f0c1 sat/plugins/plugin_xep_0082.py --- a/sat/plugins/plugin_xep_0082.py Wed Aug 31 13:11:26 2022 +0200 +++ b/sat/plugins/plugin_xep_0082.py Wed Aug 31 13:18:56 2022 +0200 @@ -23,7 +23,7 @@ from sat.core.constants import Const as C from sat.core.i18n import D_ from sat.core.sat_main import SAT -from sat.tools import datetime +from sat.tools import xmpp_datetime __all__ = [ # pylint: disable=unused-variable @@ -49,7 +49,7 @@ """ Implementation of the date and time profiles specified in XEP-0082 using Python's datetime module. The legacy format described in XEP-0082 section "4. Migration" is not - supported. Reexports of the functions in :mod:`sat.tools.datetime`. + supported. Reexports of the functions in :mod:`sat.tools.xmpp_datetime`. This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas actively, but offers API for other plugins to use. @@ -60,9 +60,9 @@ @param sat: The SAT instance. """ - format_date = staticmethod(datetime.format_date) - parse_date = staticmethod(datetime.parse_date) - format_datetime = staticmethod(datetime.format_datetime) - parse_datetime = staticmethod(datetime.parse_datetime) - format_time = staticmethod(datetime.format_time) - parse_time = staticmethod(datetime.parse_time) + format_date = staticmethod(xmpp_datetime.format_date) + parse_date = staticmethod(xmpp_datetime.parse_date) + format_datetime = staticmethod(xmpp_datetime.format_datetime) + parse_datetime = staticmethod(xmpp_datetime.parse_datetime) + format_time = staticmethod(xmpp_datetime.format_time) + parse_time = staticmethod(xmpp_datetime.parse_time) diff -r 32087d7c25d4 -r 46930301f0c1 sat/tools/datetime.py --- a/sat/tools/datetime.py Wed Aug 31 13:11:26 2022 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,187 +0,0 @@ -#!/usr/bin/env python3 - -# Libervia: XMPP Date and Time profiles as per XEP-0082 -# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# Type-check with `mypy --strict` -# Lint with `pylint` - -from sat.core import exceptions -from datetime import date, datetime, time, timezone -import re -from typing import Optional, Tuple - - -__all__ = [ # pylint: disable=unused-variable - "format_date", - "parse_date", - "format_datetime", - "parse_datetime", - "format_time", - "parse_time" -] - - -def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]: - """ - datetime's strptime only supports up to six digits of the fraction of a seconds, while - the XEP-0082 specification allows for any number of digits. This function parses and - removes the optional fraction of a second from the input string. - - @param value: The input string, containing a section of the format [.sss]. - @return: The input string with the fraction of a second removed, and the fraction of a - second parsed with microsecond resolution. Returns the unaltered input string and - ``None`` if no fraction of a second was found in the input string. - """ - - # The following regex matches the optional fraction of a seconds for manual - # processing. - match = re.search(r"\.(\d*)", value) - microsecond: Optional[int] = None - if match is not None: - # Remove the fraction of a second from the input string - value = value[:match.start()] + value[match.end():] - - # datetime supports microsecond resolution for the fraction of a second, thus - # limit/pad the parsed fraction of a second to six digits - microsecond = int(match.group(1)[:6].ljust(6, '0')) - - return value, microsecond - - -def format_date(value: Optional[date] = None) -> str: - """ - @param value: The date for format. Defaults to the current date in the UTC timezone. - @return: The date formatted according to the Date profile specified in XEP-0082. - - @warning: Formatting of the current date in the local timezone may leak geographical - information of the sender. Thus, it is advised to only format the current date in - UTC. - """ - # CCYY-MM-DD - - # The Date profile of XEP-0082 is equal to the ISO 8601 format. - return (datetime.now(timezone.utc).date() if value is None else value).isoformat() - - -def parse_date(value: str) -> date: - """ - @param value: A string containing date information formatted according to the Date - profile specified in XEP-0082. - @return: The date parsed from the input string. - @raise ValueError: if the input string is not correctly formatted. - """ - # CCYY-MM-DD - - # The Date profile of XEP-0082 is equal to the ISO 8601 format. - return date.fromisoformat(value) - - -def format_datetime( - value: Optional[datetime] = None, - include_microsecond: bool = False -) -> str: - """ - @param value: The datetime to format. Defaults to the current datetime. - must be an aware datetime object (timezone must be specified) - @param include_microsecond: Include the microsecond of the datetime in the output. - @return: The datetime formatted according to the DateTime profile specified in - XEP-0082. The datetime is always converted to UTC before formatting to avoid - leaking geographical information of the sender. - """ - # CCYY-MM-DDThh:mm:ss[.sss]TZD - - # We format the time in UTC, since the %z formatter of strftime doesn't include colons - # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a - # simple letter 'Z' as the time zone definition. - if value is not None: - if value.tzinfo is None: - raise exceptions.InternalError( - "an aware datetime object must be used, but a naive one has been provided" - ) - value = value.astimezone(timezone.utc) # pylint: disable=no-member - else: - value = datetime.now(timezone.utc) - - if include_microsecond: - return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") - - return value.strftime("%Y-%m-%dT%H:%M:%SZ") - - -def parse_datetime(value: str) -> datetime: - """ - @param value: A string containing datetime information formatted according to the - DateTime profile specified in XEP-0082. - @return: The datetime parsed from the input string. - @raise ValueError: if the input string is not correctly formatted. - """ - # CCYY-MM-DDThh:mm:ss[.sss]TZD - - value, microsecond = __parse_fraction_of_a_second(value) - - result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z") - - if microsecond is not None: - result = result.replace(microsecond=microsecond) - - return result - - -def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str: - """ - @param value: The time to format. Defaults to the current time in the UTC timezone. - @param include_microsecond: Include the microsecond of the time in the output. - @return: The time formatted according to the Time profile specified in XEP-0082. - - @warning: Since accurate timezone conversion requires the date to be known, this - function cannot convert input times to UTC before formatting. This means that - geographical information of the sender may be leaked if a time in local timezone - is formatted. Thus, when passing a time to format, it is advised to pass the time - in UTC if possible. - """ - # hh:mm:ss[.sss][TZD] - - if value is None: - # There is no time.now() method as one might expect, but the current time can be - # extracted from a datetime object including time zone information. - value = datetime.now(timezone.utc).timetz() - - # The format created by time.isoformat complies with the XEP-0082 Time profile. - return value.isoformat("auto" if include_microsecond else "seconds") - - -def parse_time(value: str) -> time: - """ - @param value: A string containing time information formatted according to the Time - profile specified in XEP-0082. - @return: The time parsed from the input string. - @raise ValueError: if the input string is not correctly formatted. - """ - # hh:mm:ss[.sss][TZD] - - value, microsecond = __parse_fraction_of_a_second(value) - - # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time - # profile, except that it doesn't handle the letter Z as time zone information for - # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which - # is another way to represent UTC. - result = time.fromisoformat(value.replace('Z', "+00:00")) - - if microsecond is not None: - result = result.replace(microsecond=microsecond) - - return result diff -r 32087d7c25d4 -r 46930301f0c1 sat/tools/utils.py --- a/sat/tools/utils.py Wed Aug 31 13:11:26 2022 +0200 +++ b/sat/tools/utils.py Wed Aug 31 13:18:56 2022 +0200 @@ -34,7 +34,7 @@ from twisted.internet import defer from sat.core.constants import Const as C from sat.core.log import getLogger -from sat.tools.datetime import format_date, format_datetime +from sat.tools import xmpp_datetime log = getLogger(__name__) @@ -157,7 +157,10 @@ datetime.timezone.utc ) - return format_datetime(dtime) if with_time else format_date(dtime.date()) + return ( + xmpp_datetime.format_datetime(dtime) if with_time + else xmpp_datetime.format_date(dtime.date()) + ) def generatePassword(vocabulary=None, size=20): diff -r 32087d7c25d4 -r 46930301f0c1 sat/tools/xmpp_datetime.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/tools/xmpp_datetime.py Wed Aug 31 13:18:56 2022 +0200 @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +# Libervia: XMPP Date and Time profiles as per XEP-0082 +# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +# Type-check with `mypy --strict` +# Lint with `pylint` + +from sat.core import exceptions +from datetime import date, datetime, time, timezone +import re +from typing import Optional, Tuple + + +__all__ = [ # pylint: disable=unused-variable + "format_date", + "parse_date", + "format_datetime", + "parse_datetime", + "format_time", + "parse_time" +] + + +def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]: + """ + datetime's strptime only supports up to six digits of the fraction of a seconds, while + the XEP-0082 specification allows for any number of digits. This function parses and + removes the optional fraction of a second from the input string. + + @param value: The input string, containing a section of the format [.sss]. + @return: The input string with the fraction of a second removed, and the fraction of a + second parsed with microsecond resolution. Returns the unaltered input string and + ``None`` if no fraction of a second was found in the input string. + """ + + # The following regex matches the optional fraction of a seconds for manual + # processing. + match = re.search(r"\.(\d*)", value) + microsecond: Optional[int] = None + if match is not None: + # Remove the fraction of a second from the input string + value = value[:match.start()] + value[match.end():] + + # datetime supports microsecond resolution for the fraction of a second, thus + # limit/pad the parsed fraction of a second to six digits + microsecond = int(match.group(1)[:6].ljust(6, '0')) + + return value, microsecond + + +def format_date(value: Optional[date] = None) -> str: + """ + @param value: The date for format. Defaults to the current date in the UTC timezone. + @return: The date formatted according to the Date profile specified in XEP-0082. + + @warning: Formatting of the current date in the local timezone may leak geographical + information of the sender. Thus, it is advised to only format the current date in + UTC. + """ + # CCYY-MM-DD + + # The Date profile of XEP-0082 is equal to the ISO 8601 format. + return (datetime.now(timezone.utc).date() if value is None else value).isoformat() + + +def parse_date(value: str) -> date: + """ + @param value: A string containing date information formatted according to the Date + profile specified in XEP-0082. + @return: The date parsed from the input string. + @raise ValueError: if the input string is not correctly formatted. + """ + # CCYY-MM-DD + + # The Date profile of XEP-0082 is equal to the ISO 8601 format. + return date.fromisoformat(value) + + +def format_datetime( + value: Optional[datetime] = None, + include_microsecond: bool = False +) -> str: + """ + @param value: The datetime to format. Defaults to the current datetime. + must be an aware datetime object (timezone must be specified) + @param include_microsecond: Include the microsecond of the datetime in the output. + @return: The datetime formatted according to the DateTime profile specified in + XEP-0082. The datetime is always converted to UTC before formatting to avoid + leaking geographical information of the sender. + """ + # CCYY-MM-DDThh:mm:ss[.sss]TZD + + # We format the time in UTC, since the %z formatter of strftime doesn't include colons + # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a + # simple letter 'Z' as the time zone definition. + if value is not None: + if value.tzinfo is None: + raise exceptions.InternalError( + "an aware datetime object must be used, but a naive one has been provided" + ) + value = value.astimezone(timezone.utc) # pylint: disable=no-member + else: + value = datetime.now(timezone.utc) + + if include_microsecond: + return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + return value.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_datetime(value: str) -> datetime: + """ + @param value: A string containing datetime information formatted according to the + DateTime profile specified in XEP-0082. + @return: The datetime parsed from the input string. + @raise ValueError: if the input string is not correctly formatted. + """ + # CCYY-MM-DDThh:mm:ss[.sss]TZD + + value, microsecond = __parse_fraction_of_a_second(value) + + result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z") + + if microsecond is not None: + result = result.replace(microsecond=microsecond) + + return result + + +def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str: + """ + @param value: The time to format. Defaults to the current time in the UTC timezone. + @param include_microsecond: Include the microsecond of the time in the output. + @return: The time formatted according to the Time profile specified in XEP-0082. + + @warning: Since accurate timezone conversion requires the date to be known, this + function cannot convert input times to UTC before formatting. This means that + geographical information of the sender may be leaked if a time in local timezone + is formatted. Thus, when passing a time to format, it is advised to pass the time + in UTC if possible. + """ + # hh:mm:ss[.sss][TZD] + + if value is None: + # There is no time.now() method as one might expect, but the current time can be + # extracted from a datetime object including time zone information. + value = datetime.now(timezone.utc).timetz() + + # The format created by time.isoformat complies with the XEP-0082 Time profile. + return value.isoformat("auto" if include_microsecond else "seconds") + + +def parse_time(value: str) -> time: + """ + @param value: A string containing time information formatted according to the Time + profile specified in XEP-0082. + @return: The time parsed from the input string. + @raise ValueError: if the input string is not correctly formatted. + """ + # hh:mm:ss[.sss][TZD] + + value, microsecond = __parse_fraction_of_a_second(value) + + # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time + # profile, except that it doesn't handle the letter Z as time zone information for + # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which + # is another way to represent UTC. + result = time.fromisoformat(value.replace('Z', "+00:00")) + + if microsecond is not None: + result = result.replace(microsecond=microsecond) + + return result