From dca2f7aad827f5d86b9e9cdfd8dea869f98f2fae Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 23 Aug 2025 19:02:26 -0400 Subject: [PATCH] Global Timezone support added (tz=) (#1398) --- apprise/asset.py | 28 ++++++++ apprise/config/base.py | 22 +++++- apprise/plugins/base.py | 47 +++++++++++++ apprise/plugins/email/base.py | 16 +++-- apprise/utils/time.py | 79 +++++++++++++++++++++ pyproject.toml | 3 + tests/test_api.py | 1 + tests/test_apprise_asset.py | 64 +++++++++++++++++ tests/test_apprise_utils.py | 32 +++++++++ tests/test_config_base.py | 127 +++++++++++++++++++++++++++++++++- tests/test_plugin_email.py | 83 +++++++++++++++++++++- tests/test_plugin_msteams.py | 2 +- win-requirements.txt | 1 + 13 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 apprise/utils/time.py create mode 100644 tests/test_apprise_asset.py diff --git a/apprise/asset.py b/apprise/asset.py index d4fa2a90..50e565f9 100644 --- a/apprise/asset.py +++ b/apprise/asset.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, tzinfo from os.path import abspath, dirname, isfile, join import re from typing import Any, Optional, Union @@ -37,6 +38,7 @@ from .common import ( PersistentStoreMode, ) from .manager_plugins import NotificationManager +from .utils.time import zoneinfo # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() @@ -205,6 +207,14 @@ class AppriseAsset: # A unique identifer we can use to associate our calling source _uid = str(uuid4()) + # Default timezone to use (pass in timezone value) + # A list of timezones can be found here: + # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + # You can specify things such as 'America/Montreal' + # If no timezone is specified, then the one detected on the system + # is uzed + _tzinfo = None + def __init__( self, plugin_paths: Optional[list[str]] = None, @@ -212,6 +222,7 @@ class AppriseAsset: storage_mode: Optional[Union[str, PersistentStoreMode]] = None, storage_salt: Optional[Union[str, bytes]] = None, storage_idlen: Optional[int] = None, + timezone: Optional[Union[str, tzinfo]] = None, **kwargs: Any ) -> None: """Asset Initialization.""" @@ -259,6 +270,18 @@ class AppriseAsset: # Store value self.__storage_idlen = storage_idlen + if isinstance(timezone, tzinfo): + self._tzinfo = timezone + + elif timezone is not None: + self._tzinfo = zoneinfo(timezone) + if not self._tzinfo: + raise AttributeError( + "AppriseAsset timezone provided is invalid") from None + else: + # Default our timezone to what is detected on the system + self._tzinfo = datetime.now().astimezone().tzinfo + if storage_salt is not None: # Define the number of characters utilized from our namespace lengh @@ -484,3 +507,8 @@ class AppriseAsset: """Return the persistent storage id length.""" return self.__storage_idlen + + @property + def tzinfo(self) -> tzinfo: + """Return the timezone object""" + return self._tzinfo diff --git a/apprise/config/base.py b/apprise/config/base.py index 337eb3c7..3f1980df 100644 --- a/apprise/config/base.py +++ b/apprise/config/base.py @@ -39,6 +39,7 @@ from ..manager_plugins import NotificationManager from ..url import URLBase 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) @@ -890,10 +891,29 @@ class ConfigBase(URLBase): # global asset object # asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Prepare our default timezone + default_timezone = asset.tzinfo + + # Acquire our asset tokens tokens = result.get("asset", None) if tokens and isinstance(tokens, dict): - for k, v in tokens.items(): + raw_tz = tokens.get("timezone", tokens.get("tz")) + if isinstance(raw_tz, str): + default_timezone = zoneinfo(re.sub(r"[^\w/-]+", "", raw_tz)) + if not default_timezone: + ConfigBase.logger.warning( + 'Ignored invalid timezone "%s"', raw_tz) + default_timezone = asset.tzinfo + else: + asset._tzinfo = default_timezone + elif raw_tz is not None: + ConfigBase.logger.warning( + 'Ignored invalid timezone "%r"', raw_tz) + + # Iterate over remaining tokens + for k, v in tokens.items(): if k.startswith("_") or k.endswith("_"): # Entries are considered reserved if they start or end # with an underscore diff --git a/apprise/plugins/base.py b/apprise/plugins/base.py index 3135483f..2f3065e8 100644 --- a/apprise/plugins/base.py +++ b/apprise/plugins/base.py @@ -27,9 +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 ZoneInfo from ..apprise_attachment import AppriseAttachment from ..common import ( @@ -45,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): @@ -141,6 +144,10 @@ class NotifyBase(URLBase): # Persistent storage default settings persistent_storage = True + # Timezone Default; by setting it to None, the timezone detected + # on the server is used + timezone = None + # Default Notify Format notify_format = NotifyFormat.TEXT @@ -225,6 +232,15 @@ class NotifyBase(URLBase): # runtime. "_lookup_default": "persistent_storage", }, + "tz": { + "name": _("Timezone"), + "type": "string", + # Provide a default + "default": timezone, + # look up default using the following parent class value at + # runtime. + "_lookup_default": "timezone", + }, }, ) @@ -276,6 +292,12 @@ 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 @@ -325,6 +347,16 @@ class NotifyBase(URLBase): self.logger.warning(err) raise TypeError(err) from None + if "tz" in kwargs: + value = kwargs["tz"] + 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: @@ -844,6 +876,10 @@ class NotifyBase(URLBase): "overflow": self.overflow_mode.value, } + # Timezone Information (if ZoneInfo) + if self.__tzinfo and isinstance(self.__tzinfo, ZoneInfo): + params["tz"] = self.__tzinfo.key + # Persistent Storage Setting if self.persistent_storage != NotifyBase.persistent_storage: params["store"] = "yes" if self.persistent_storage else "no" @@ -909,6 +945,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"] @@ -955,3 +995,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 new file mode 100644 index 00000000..bb476311 --- /dev/null +++ b/apprise/utils/time.py @@ -0,0 +1,79 @@ +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +import contextlib +from datetime import timezone as _tz +from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from ..logger import logger + + +def zoneinfo(name: str) -> Optional[ZoneInfo]: + """ + More forgiving ZoneInfo instantiation + - Accepts lower/upper case + - Normalises common UTC variants + """ + if not isinstance(name, str): + return None + + raw = name.strip() + if not raw: + return None + + # Windows-safe: accept UTC family even without tzdata + if raw.lower() in { + "utc", "z", "gmt", "etc/utc", "etc/gmt", "gmt0", "utc0"}: + return _tz.utc + + # Try exact match first + try: + return ZoneInfo(name) + + except ZoneInfoNotFoundError: + pass + + # Try case-insensitive match across available keys + from zoneinfo import available_timezones + lowered = name.lower().strip() + for zone in available_timezones(): + full_zone = zone.lower() + if full_zone == lowered: + return ZoneInfo(zone) + + with contextlib.suppress(IndexError): + + # 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) + + logger.warning("Unknown timezone specified: %s", name) + return None diff --git a/pyproject.toml b/pyproject.toml index 975c60ca..78be0783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ dependencies = [ "PyYAML", # Root certificate authority bundle "certifi", + # Always ship IANA tzdb on Windows so ZoneInfo works + "tzdata; platform_system == 'Windows'", ] # Identifies all of the supported plugins @@ -220,6 +222,7 @@ all-plugins = [ ] windows = [ "pywin32", + "tzdata", ] [project.urls] 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 new file mode 100644 index 00000000..803f7716 --- /dev/null +++ b/tests/test_apprise_asset.py @@ -0,0 +1,64 @@ +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +# Disable logging for a cleaner testing output +from datetime import timezone, tzinfo +import logging +import sys +from zoneinfo import ZoneInfo + +import pytest + +from apprise.asset import AppriseAsset + +logging.disable(logging.CRITICAL) + +# Ensure we don't create .pyc files for these tests +sys.dont_write_bytecode = True + + +def test_timezone(): + "asset: timezone() testing" + asset = AppriseAsset(timezone="utc") + assert isinstance(asset.tzinfo, tzinfo) + + # Default (uses system value) + 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) + + with pytest.raises(AttributeError): + AppriseAsset(timezone="invalid") diff --git a/tests/test_apprise_utils.py b/tests/test_apprise_utils.py index f36b642e..6d5518e0 100644 --- a/tests/test_apprise_utils.py +++ b/tests/test_apprise_utils.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 @@ -3233,3 +3234,34 @@ def test_bytes_to_str(): # Support strings too assert utils.disk.bytes_to_str("0") == "0.00B" assert utils.disk.bytes_to_str("1024") == "1.00KB" + + +def test_time_zoneinfo(): + """utils: zoneinfo() testing""" + + # Some valid strings + assert isinstance(utils.time.zoneinfo("UTC"), tzinfo) + assert isinstance(utils.time.zoneinfo("z"), tzinfo) + assert isinstance(utils.time.zoneinfo("gmt"), tzinfo) + assert isinstance(utils.time.zoneinfo("utc"), tzinfo) + assert isinstance(utils.time.zoneinfo("Toronto"), tzinfo) + 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 + assert utils.time.zoneinfo(1) is None + assert utils.time.zoneinfo("") is None + assert utils.time.zoneinfo("invalid") is None diff --git a/tests/test_config_base.py b/tests/test_config_base.py index 5dea1327..f6420ce5 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 datetime, timezone as _tz, tzinfo from inspect import cleandoc # Disable logging for a cleaner testing output @@ -33,9 +34,10 @@ import logging import pytest import yaml -from apprise import Apprise, AppriseAsset, ConfigFormat +from apprise import Apprise, AppriseAsset, AppriseConfig, ConfigFormat from apprise.config import ConfigBase from apprise.plugins.email import NotifyEmail +from apprise.utils.time import zoneinfo logging.disable(logging.CRITICAL) @@ -1005,6 +1007,9 @@ asset: image_path_mask: tmp/path + # Timezone (supports tz keyword too) + tz: America/Montreal + # invalid entry theme: - @@ -1042,6 +1047,10 @@ urls: # Boolean types stay boolean assert asset.async_mode is False + # Our TimeZone + assert isinstance(asset.tzinfo, tzinfo) + 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 @@ -1067,6 +1076,9 @@ asset: app_desc: Apprise Test Notifications app_url: http://nuxref.com + # An invalid timezone + timezone: invalid + # Optionally define some global tags to associate with ALL of your # urls below. tag: admin, devops @@ -1580,3 +1592,116 @@ include: [file:///absolute/path/, relative/path, http://test.com] assert "file:///absolute/path/" in config assert "relative/path" in config assert "http://test.com" in config + + +def test_yaml_asset_timezone_and_asset_tokens(tmpdir): + """ + Covers: valid tz, reserved keys, invalid key, bool coercion, None->"", + invalid type for string, and %z formatting path used later by plugins. + """ + cfg = tmpdir.join("asset-tz.yml") + cfg.write( + """ +version: 1 +asset: + tz: " america/toronto " # case-insensitive + whitespace cleanup + _private: "ignored" # reserved (starts with _) + name_: "ignored" # reserved (ends with _) + not_a_field: "ignored" # invalid asset key + secure_logging: "yes" # string -> bool via parse_bool + app_id: null # None becomes empty string + app_desc: [ "list" ] # invalid type for string -> warning path +urls: + - json://localhost +""" + ) + + ac = AppriseConfig(paths=str(cfg)) + # Force a fresh parse and get the loaded plugin + servers = ac.servers() + assert len(servers) == 1 + + plugin = servers[0] + asset = plugin.asset + + # tz was accepted and normalised + # lower() is required since Mac and Window are not case sensitive and will + # See output as it was passed in and not corrected per IANA + assert getattr(asset.tzinfo, "key", None).lower() == "america/toronto" + # boolean coercion applied + assert asset.secure_logging is True + # None -> "" + assert asset.app_id == "" + + +def test_yaml_asset_timezone_invalid_and_precedence(tmpdir): + """ + If 'timezone' is present but invalid, it takes precedence over 'tz' + and MUST NOT set the asset to the 'tz' value. We assert that London + was not applied. We deliberately avoid asserting the exact fallback, + since environments may surface a system tz (datetime.timezone) that + lacks a `.key` attribute. + """ + cfg = tmpdir.join("asset-tz-invalid.yml") + cfg.write( + """ +version: 1 +asset: + timezone: null # invalid (will be seen as "None") + tz: Europe/London # would be valid, but 'timezone' wins +urls: + - json://localhost +""" + ) + + base_asset = AppriseAsset(timezone="UTC") + ac = AppriseConfig(paths=str(cfg)) + servers = ac.servers(asset=base_asset) + assert len(servers) == 1 + + tzinfo = servers[0].asset.tzinfo + + # The key assertion: 'tz' MUST NOT have been applied + assert getattr(tzinfo, "key", "").lower() != "europe/london" + + # Sanity check that something sensible is set + # Compare offsets at a fixed instant instead of object identity + dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc) + assert tzinfo.utcoffset(dt) is not None + + +@pytest.mark.parametrize("garbage_yaml", [ + "123", "3.1415", "true", "[UTC]", "{x: UTC}", +]) +def test_yaml_asset_tz_garbage_types_only(tmpdir, garbage_yaml): + """ + If only 'tz' is present and it is non-string, it is ignored. + We assert it didn't become a real IANA zone (e.g., Europe/London), + and that the tzinfo is usable. + """ + cfg = tmpdir.join("asset-tz-garbage-only.yml") + cfg.write( + f""" +version: 1 +asset: + tz: {garbage_yaml} # non-string -> warning path +urls: + - json://localhost +""" + ) + + base_asset = AppriseAsset(timezone="UTC") + ac = AppriseConfig(paths=str(cfg)) + servers = ac.servers(asset=base_asset) + assert len(servers) == 1 + + tzinfo = servers[0].asset.tzinfo + + # 1) Did not “accidentally” become a valid IANA from elsewhere. + assert getattr(tzinfo, "key", "").lower() != "europe/london" + + # 2) tzinfo is usable (offset resolves at a fixed instant). + dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc) + assert tzinfo.utcoffset(dt) is not None + # also stable tzname resolution + assert isinstance(tzinfo.tzname(dt), str) diff --git a/tests/test_plugin_email.py b/tests/test_plugin_email.py index c44d00cd..f1f9dab8 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,78 @@ 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 + # lower() is required since Mac and Window are not case sensitive and will + # See output as it was passed in and not corrected per IANA + assert obj.tzinfo.key.lower() == "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 diff --git a/win-requirements.txt b/win-requirements.txt index 3cd15009..7dc71239 100644 --- a/win-requirements.txt +++ b/win-requirements.txt @@ -4,3 +4,4 @@ # occur in pyproject.toml. Contents of this file can be found # in [project.optional-dependencies].windows pywin32 +tzdata