From 886fd253ee8507dff37322c90674cd09338ccc4e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 17 Aug 2025 20:06:24 -0400 Subject: [PATCH] initial commit - work in progress - global timezone setting --- apprise/asset.py | 28 +++++++++++++++ apprise/config/base.py | 23 +++++++++++- apprise/plugins/base.py | 15 ++++++++ apprise/utils/time.py | 71 +++++++++++++++++++++++++++++++++++++ tests/test_apprise_asset.py | 56 +++++++++++++++++++++++++++++ tests/test_apprise_utils.py | 21 +++++++++++ tests/test_config_base.py | 12 +++++++ 7 files changed, 225 insertions(+), 1 deletion(-) 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..bedaeaee 100644 --- a/apprise/config/base.py +++ b/apprise/config/base.py @@ -37,6 +37,7 @@ 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 @@ -890,10 +891,30 @@ 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(): + # Prepare our default timezone (if specified) + timezone = str(tokens.get('timezone', tokens.get('tz', ''))) + if timezone: + default_timezone = zoneinfo(re.sub(r'[^\w/-]+', '', timezone)) + if not default_timezone: + ConfigBase.logger.warning( + 'Ignored invalid timezone "%s"', timezone) + # Restore our timezone back to what was found in the + # asset object + default_timezone = asset.tzinfo + else: + # Set our newly specified timezone + setattr(asset, '_tzinfo', default_timezone) + + # 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..95ac68ab 100644 --- a/apprise/plugins/base.py +++ b/apprise/plugins/base.py @@ -30,6 +30,7 @@ from collections.abc import Generator from functools import partial import re from typing import Any, ClassVar, Optional, TypedDict, Union +from zoneinfo import available_timezones from ..apprise_attachment import AppriseAttachment from ..common import ( @@ -141,6 +142,9 @@ class NotifyBase(URLBase): # Persistent storage default settings persistent_storage = True + # Timezone Default + timezone = 'UTC' + # Default Notify Format notify_format = NotifyFormat.TEXT @@ -225,6 +229,17 @@ class NotifyBase(URLBase): # runtime. "_lookup_default": "persistent_storage", }, + "tz": { + "name": _("Timezone"), + "type": "choice:string", + # Supported timezones + "values": available_timezones(), + # Provide a default + "default": timezone, + # look up default using the following parent class value at + # runtime. + "_lookup_default": "timezone", + }, }, ) diff --git a/apprise/utils/time.py b/apprise/utils/time.py new file mode 100644 index 00000000..e60aae8a --- /dev/null +++ b/apprise/utils/time.py @@ -0,0 +1,71 @@ +# 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 zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from ..logger import logger + + +def zoneinfo(name: str) -> ZoneInfo: + """ + More forgiving ZoneInfo instantiation + - Accepts lower/upper case + - Normalises common UTC variants + """ + if not isinstance(name, str): + return None + + # Normalise common UTC spellings + if name.lower() in {"utc", "z", "gmt"}: + name = "UTC" + + # Try exact match first + try: + return ZoneInfo(name) + + except ValueError: + # Just all around bad data; no need to proceed + return None + + 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): + location = full_zone.split("/")[1:][0] + if location and location == lowered: + return ZoneInfo(zone) + + logger.warning("Unknown timezone specified: %s", name) + return None diff --git a/tests/test_apprise_asset.py b/tests/test_apprise_asset.py new file mode 100644 index 00000000..5ac76560 --- /dev/null +++ b/tests/test_apprise_asset.py @@ -0,0 +1,56 @@ +# 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 tzinfo +import logging +import sys + +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) + + 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..b0cf9ecc 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,23 @@ 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_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) + + # 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..f6c9db51 100644 --- a/tests/test_config_base.py +++ b/tests/test_config_base.py @@ -33,6 +33,8 @@ 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 @@ -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