From 96ee18298b7dddd46e5de53470b5154a37de3e1e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 23 Aug 2025 11:19:36 -0400 Subject: [PATCH] timezone completion --- apprise/config/base.py | 8 ++-- apprise/plugins/base.py | 49 ++++++++++++++++++--- apprise/plugins/email/base.py | 16 ++++--- apprise/utils/time.py | 10 ++++- tests/test_api.py | 1 + tests/test_apprise_asset.py | 10 ++++- tests/test_apprise_utils.py | 13 +++++- tests/test_config_base.py | 6 +-- tests/test_plugin_email.py | 81 ++++++++++++++++++++++++++++++++++- tests/test_plugin_msteams.py | 2 +- 10 files changed, 172 insertions(+), 24 deletions(-) diff --git a/apprise/config/base.py b/apprise/config/base.py index bedaeaee..59698de5 100644 --- a/apprise/config/base.py +++ b/apprise/config/base.py @@ -37,9 +37,9 @@ from ..asset import AppriseAsset from ..manager_config import ConfigurationManager from ..manager_plugins import NotificationManager from ..url import URLBase -from ..utils.time import zoneinfo from ..utils.cwe312 import cwe312_url from ..utils.parse import GET_SCHEMA_RE, parse_bool, parse_list, parse_urls +from ..utils.time import zoneinfo # Test whether token is valid or not VALID_TOKEN = re.compile(r"(?P[a-z0-9][a-z0-9_]+)", re.I) @@ -900,9 +900,9 @@ class ConfigBase(URLBase): if tokens and isinstance(tokens, dict): # Prepare our default timezone (if specified) - timezone = str(tokens.get('timezone', tokens.get('tz', ''))) + timezone = str(tokens.get("timezone", tokens.get("tz", ""))) if timezone: - default_timezone = zoneinfo(re.sub(r'[^\w/-]+', '', timezone)) + default_timezone = zoneinfo(re.sub(r"[^\w/-]+", "", timezone)) if not default_timezone: ConfigBase.logger.warning( 'Ignored invalid timezone "%s"', timezone) @@ -911,7 +911,7 @@ class ConfigBase(URLBase): default_timezone = asset.tzinfo else: # Set our newly specified timezone - setattr(asset, '_tzinfo', default_timezone) + asset._tzinfo = default_timezone # Iterate over remaining tokens for k, v in tokens.items(): diff --git a/apprise/plugins/base.py b/apprise/plugins/base.py index 95ac68ab..bb0455b5 100644 --- a/apprise/plugins/base.py +++ b/apprise/plugins/base.py @@ -27,10 +27,11 @@ import asyncio from collections.abc import Generator +from datetime import tzinfo from functools import partial import re from typing import Any, ClassVar, Optional, TypedDict, Union -from zoneinfo import available_timezones +from zoneinfo import ZoneInfo from ..apprise_attachment import AppriseAttachment from ..common import ( @@ -46,6 +47,7 @@ from ..locale import Translatable, gettext_lazy as _ from ..persistent_store import PersistentStore from ..url import URLBase from ..utils.parse import parse_bool +from ..utils.time import zoneinfo class RequirementsSpec(TypedDict, total=False): @@ -142,8 +144,9 @@ class NotifyBase(URLBase): # Persistent storage default settings persistent_storage = True - # Timezone Default - timezone = 'UTC' + # Timezone Default; by setting it to None, the timezone detected + # on the server is used + timezone = None # Default Notify Format notify_format = NotifyFormat.TEXT @@ -231,9 +234,7 @@ class NotifyBase(URLBase): }, "tz": { "name": _("Timezone"), - "type": "choice:string", - # Supported timezones - "values": available_timezones(), + "type": "string", # Provide a default "default": timezone, # look up default using the following parent class value at @@ -291,6 +292,13 @@ class NotifyBase(URLBase): # restrictions and that of body_maxlen overflow_amalgamate_title = False + # Identifies the timezone to use; if this is not over-ridden, then the + # timezone defined in the AppriseAsset() object is used instead. The + # Below is expected to be in a ZoneInfo type already. You can have this + # automatically initialized by specifying ?tz= on the Apprise URLs + __tzinfo = None + + def __init__(self, **kwargs): """Initialize some general configuration that will keep things consistent when working with the notifiers that will inherit this @@ -340,6 +348,20 @@ class NotifyBase(URLBase): self.logger.warning(err) raise TypeError(err) from None + if "tz" in kwargs: + value = kwargs["tz"] + if isinstance(value, ZoneInfo): + self.__tzinfo = kwargs["tz"] + + else: + self.__tzinfo = zoneinfo(value) + if not self.__tzinfo: + err = ( + f"An invalid notification timezone ({value}) was " + "specified.") + self.logger.warning(err) + raise TypeError(err) from None + if "overflow" in kwargs: value = kwargs["overflow"] try: @@ -859,6 +881,10 @@ class NotifyBase(URLBase): "overflow": self.overflow_mode.value, } + # Timezone Information + if self.__tzinfo: + params["tz"] = self.__tzinfo.key + # Persistent Storage Setting if self.persistent_storage != NotifyBase.persistent_storage: params["store"] = "yes" if self.persistent_storage else "no" @@ -924,6 +950,10 @@ class NotifyBase(URLBase): results["emojis"] = parse_bool(results["qsd"].get("emojis")) # Store our persistent storage boolean + # Allow overriding the default timezone + if "tz" in results["qsd"]: + results["tz"] = results["qsd"].get("tz", "") + if "store" in results["qsd"]: results["store"] = results["qsd"]["store"] @@ -970,3 +1000,10 @@ class NotifyBase(URLBase): ) return self.__store + + @property + def tzinfo(self) -> tzinfo: + """Returns our tzinfo file associated with this plugin if set + otherwise the default timezone is returned. + """ + return self.__tzinfo if self.__tzinfo else self.asset.tzinfo diff --git a/apprise/plugins/email/base.py b/apprise/plugins/email/base.py index 187f9b72..5e4250e6 100644 --- a/apprise/plugins/email/base.py +++ b/apprise/plugins/email/base.py @@ -25,13 +25,13 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from datetime import datetime, timezone +from datetime import datetime from email.header import Header from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from email.utils import formataddr, make_msgid +from email.utils import format_datetime, formataddr, make_msgid import re import smtplib from typing import Optional @@ -598,6 +598,7 @@ class NotifyEmail(NotifyBase): headers=headers, names=self.names, pgp=self.pgp if self.use_pgp else None, + tzinfo=self.tzinfo, ): try: socket.sendmail( @@ -946,6 +947,9 @@ class NotifyEmail(NotifyBase): # Pretty Good Privacy Support; Pass in an # ApprisePGPController if you wish to use it pgp=None, + # Define our timezone; if one isn't provided, then we use + # the system time instead + tzinfo=None, ): """ Generator for emails @@ -1005,6 +1009,10 @@ class NotifyEmail(NotifyBase): # Generate a host identifier (used for Message-ID Creation) smtp_host = from_addr[1].split("@")[1] + if not tzinfo: + # use server time + tzinfo = datetime.now().astimezone().tzinfo + logger.debug(f"SMTP Host: {smtp_host}") # Create a copy of the targets list @@ -1162,9 +1170,7 @@ class NotifyEmail(NotifyBase): base["From"] = formataddr(from_addr, charset="utf-8") base["To"] = formataddr((to_name, to_addr), charset="utf-8") base["Message-ID"] = make_msgid(domain=smtp_host) - base["Date"] = datetime.now(timezone.utc).strftime( - "%a, %d %b %Y %H:%M:%S +0000" - ) + base["Date"] = format_datetime(datetime.now(tz=tzinfo)) if cc: base["Cc"] = ",".join(_cc) diff --git a/apprise/utils/time.py b/apprise/utils/time.py index e60aae8a..5d150c73 100644 --- a/apprise/utils/time.py +++ b/apprise/utils/time.py @@ -25,12 +25,13 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib +from typing import Optional from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from ..logger import logger -def zoneinfo(name: str) -> ZoneInfo: +def zoneinfo(name: str) -> Optional[ZoneInfo]: """ More forgiving ZoneInfo instantiation - Accepts lower/upper case @@ -63,7 +64,12 @@ def zoneinfo(name: str) -> ZoneInfo: return ZoneInfo(zone) with contextlib.suppress(IndexError): - location = full_zone.split("/")[1:][0] + + # Break our zones and enforce limit + zones = full_zone.split("/")[1:3] + + # Possible we'll throw an index error here and that's okay + location = zones[-1] if len(zones) == 1 else "/".join(zones) if location and location == lowered: return ZoneInfo(zone) diff --git a/tests/test_api.py b/tests/test_api.py index a39e176f..c98cfe0c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1792,6 +1792,7 @@ def test_apprise_details_plugin_verification(): "schema", "fullpath", # NotifyBase parameters: + "tz", "format", "overflow", "emojis", diff --git a/tests/test_apprise_asset.py b/tests/test_apprise_asset.py index 5ac76560..803f7716 100644 --- a/tests/test_apprise_asset.py +++ b/tests/test_apprise_asset.py @@ -26,9 +26,10 @@ # POSSIBILITY OF SUCH DAMAGE. # Disable logging for a cleaner testing output -from datetime import tzinfo +from datetime import timezone, tzinfo import logging import sys +from zoneinfo import ZoneInfo import pytest @@ -49,6 +50,13 @@ def test_timezone(): asset = AppriseAsset(timezone=None) assert isinstance(asset.tzinfo, tzinfo) + # Timezone can also already be a tzinfo object + asset = AppriseAsset(timezone=timezone.utc) + assert isinstance(asset.tzinfo, tzinfo) + asset = AppriseAsset(timezone=ZoneInfo("America/Toronto")) + assert isinstance(asset.tzinfo, tzinfo) + + with pytest.raises(AttributeError): AppriseAsset(timezone=object) diff --git a/tests/test_apprise_utils.py b/tests/test_apprise_utils.py index b0cf9ecc..6d5518e0 100644 --- a/tests/test_apprise_utils.py +++ b/tests/test_apprise_utils.py @@ -3236,7 +3236,7 @@ def test_bytes_to_str(): assert utils.disk.bytes_to_str("1024") == "1.00KB" -def test_zoneinfo(): +def test_time_zoneinfo(): """utils: zoneinfo() testing""" # Some valid strings @@ -3248,6 +3248,17 @@ def test_zoneinfo(): assert isinstance(utils.time.zoneinfo("America/Toronto"), tzinfo) assert isinstance(utils.time.zoneinfo("america/toronto"), tzinfo) + # Edge Case with time also supported: + tz = utils.time.zoneinfo("America/Argentina/Cordoba") + isinstance(tz, tzinfo) + assert isinstance(utils.time.zoneinfo("Argentina/Cordoba"), tzinfo) + assert utils.time.zoneinfo("Argentina/Cordoba").key == tz.key + assert isinstance(utils.time.zoneinfo("Cordoba"), tzinfo) + assert utils.time.zoneinfo("Cordoba").key == "America/Cordoba" + + # Too ambiguous + assert utils.time.zoneinfo("Argentina") is None + # bad data assert utils.time.zoneinfo(object) is None assert utils.time.zoneinfo(None) is None diff --git a/tests/test_config_base.py b/tests/test_config_base.py index f6c9db51..dc29f838 100644 --- a/tests/test_config_base.py +++ b/tests/test_config_base.py @@ -25,6 +25,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from datetime import tzinfo from inspect import cleandoc # Disable logging for a cleaner testing output @@ -33,11 +34,10 @@ import logging import pytest import yaml -from datetime import tzinfo -from apprise.utils.time import zoneinfo from apprise import Apprise, AppriseAsset, ConfigFormat from apprise.config import ConfigBase from apprise.plugins.email import NotifyEmail +from apprise.utils.time import zoneinfo logging.disable(logging.CRITICAL) @@ -1049,7 +1049,7 @@ urls: # Our TimeZone assert isinstance(asset.tzinfo, tzinfo) - assert asset.tzinfo.key == zoneinfo('America/Montreal').key + assert asset.tzinfo.key == zoneinfo("America/Montreal").key # the theme was not updated and remains the same as it was assert asset.theme == AppriseAsset().theme diff --git a/tests/test_plugin_email.py b/tests/test_plugin_email.py index c44d00cd..018f0b1d 100644 --- a/tests/test_plugin_email.py +++ b/tests/test_plugin_email.py @@ -25,6 +25,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from datetime import datetime from email.header import decode_header from inspect import cleandoc import logging @@ -92,6 +93,14 @@ TEST_URLS = ( "instance": TypeError, }, ), + ( + # invalid Timezone + "mailto://user:pass@fastmail.com?tz=invalid", + { + # An error is thrown for this + "instance": TypeError, + }, + ), # Pre-Configured Email Services ( "mailto://user:pass@gmail.com", @@ -130,7 +139,7 @@ TEST_URLS = ( }, ), ( - "mailto://user:pass@fastmail.com", + "mailto://user:pass@fastmail.com?tz=UTC", { "instance": email.NotifyEmail, }, @@ -831,6 +840,76 @@ def test_plugin_email_smtplib_send_multiple_recipients(mock_smtplib): assert re.match(r".*cc=baz%40example.org.*", obj.url()) is not None +@mock.patch("smtplib.SMTP") +def test_plugin_email_timezone(mock_smtp): + """NotifyEmail() Timezone Handling""" + + response = mock.Mock() + mock_smtp.return_value = response + + # Loads America/Toronto + results = email.NotifyEmail.parse_url( + "mailtos://user:pass123@hotmail.com:123" + "?tz=Toronto" + ) + assert isinstance(results, dict) + # timezone is detected + assert "tz" in results + + # Instantiate the object + obj = email.NotifyEmail(**results) + assert isinstance(obj, email.NotifyEmail) + assert obj.tzinfo.key == "America/Toronto" + # Verify our URL has defined our timezone + # %2F = escaped '/' + assert "tz=America%2FToronto" in obj.url() + + # No Timezone setup/default + results = email.NotifyEmail.parse_url( + "mailtos://user:pass123@hotmail.com:1235" + ) + assert "tz" not in results + + # Instantiate the object + obj = email.NotifyEmail(**results) + assert isinstance(obj, email.NotifyEmail) + # Defaults to our system + assert obj.tzinfo == datetime.now().astimezone().tzinfo + assert "tz=" not in obj.url() + + # Now we'll work with an Asset to identify how it can hold + # our default global variable (initialization proves case + # insensitive initialization is supported) + asset = AppriseAsset(timezone="aMErica/vanCOUver") + + # Instatiate our object once again using the same variable set + # as above + obj = email.NotifyEmail(**results, asset=asset) + # Defaults to our system + assert obj.tzinfo.key == "America/Vancouver" + assert "tz=" not in obj.url() + + # Having ourselves a default variable also does not prevent + # anyone from defining their own over-ride is still supported: + + # Loads America/Montreal + results = email.NotifyEmail.parse_url( + "mailtos://user:pass123@hotmail.com:321" + "?tz=Montreal" + ) + assert isinstance(results, dict) + # timezone is detected + assert "tz" in results + + # Instantiate the object + obj = email.NotifyEmail(**results) + assert isinstance(obj, email.NotifyEmail) + assert obj.tzinfo.key == "America/Montreal" + # Verify our URL has defined our timezone + # %2F = escaped '/' + assert "tz=America%2FMontreal" in obj.url() + + @mock.patch("smtplib.SMTP") def test_plugin_email_smtplib_internationalization(mock_smtp): """NotifyEmail() Internationalization Handling.""" diff --git a/tests/test_plugin_msteams.py b/tests/test_plugin_msteams.py index bd571c90..f28dcea5 100644 --- a/tests/test_plugin_msteams.py +++ b/tests/test_plugin_msteams.py @@ -217,7 +217,7 @@ apprise_url_tests = ( }, ), ( - "msteams://{}@{}/{}/{}?tz".format(UUID4, UUID4, "z" * 32, UUID4), + "msteams://{}@{}/{}/{}?ta".format(UUID4, UUID4, "z" * 32, UUID4), { "instance": NotifyMSTeams, # Throws a series of i/o exceptions with this flag