initial commit - work in progress - global timezone setting

pull/1398/head
Chris Caron 2025-08-17 20:06:24 -04:00
parent 52801b6369
commit 886fd253ee
7 changed files with 225 additions and 1 deletions

View File

@ -25,6 +25,7 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
from datetime import datetime, tzinfo
from os.path import abspath, dirname, isfile, join from os.path import abspath, dirname, isfile, join
import re import re
from typing import Any, Optional, Union from typing import Any, Optional, Union
@ -37,6 +38,7 @@ from .common import (
PersistentStoreMode, PersistentStoreMode,
) )
from .manager_plugins import NotificationManager from .manager_plugins import NotificationManager
from .utils.time import zoneinfo
# Grant access to our Notification Manager Singleton # Grant access to our Notification Manager Singleton
N_MGR = NotificationManager() N_MGR = NotificationManager()
@ -205,6 +207,14 @@ class AppriseAsset:
# A unique identifer we can use to associate our calling source # A unique identifer we can use to associate our calling source
_uid = str(uuid4()) _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__( def __init__(
self, self,
plugin_paths: Optional[list[str]] = None, plugin_paths: Optional[list[str]] = None,
@ -212,6 +222,7 @@ class AppriseAsset:
storage_mode: Optional[Union[str, PersistentStoreMode]] = None, storage_mode: Optional[Union[str, PersistentStoreMode]] = None,
storage_salt: Optional[Union[str, bytes]] = None, storage_salt: Optional[Union[str, bytes]] = None,
storage_idlen: Optional[int] = None, storage_idlen: Optional[int] = None,
timezone: Optional[Union[str, tzinfo]] = None,
**kwargs: Any **kwargs: Any
) -> None: ) -> None:
"""Asset Initialization.""" """Asset Initialization."""
@ -259,6 +270,18 @@ class AppriseAsset:
# Store value # Store value
self.__storage_idlen = storage_idlen 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: if storage_salt is not None:
# Define the number of characters utilized from our namespace lengh # Define the number of characters utilized from our namespace lengh
@ -484,3 +507,8 @@ class AppriseAsset:
"""Return the persistent storage id length.""" """Return the persistent storage id length."""
return self.__storage_idlen return self.__storage_idlen
@property
def tzinfo(self) -> tzinfo:
"""Return the timezone object"""
return self._tzinfo

View File

@ -37,6 +37,7 @@ from ..asset import AppriseAsset
from ..manager_config import ConfigurationManager from ..manager_config import ConfigurationManager
from ..manager_plugins import NotificationManager from ..manager_plugins import NotificationManager
from ..url import URLBase from ..url import URLBase
from ..utils.time import zoneinfo
from ..utils.cwe312 import cwe312_url from ..utils.cwe312 import cwe312_url
from ..utils.parse import GET_SCHEMA_RE, parse_bool, parse_list, parse_urls from ..utils.parse import GET_SCHEMA_RE, parse_bool, parse_list, parse_urls
@ -890,10 +891,30 @@ class ConfigBase(URLBase):
# global asset object # global asset object
# #
asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() 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) tokens = result.get("asset", None)
if tokens and isinstance(tokens, dict): 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("_"): if k.startswith("_") or k.endswith("_"):
# Entries are considered reserved if they start or end # Entries are considered reserved if they start or end
# with an underscore # with an underscore

View File

@ -30,6 +30,7 @@ from collections.abc import Generator
from functools import partial from functools import partial
import re import re
from typing import Any, ClassVar, Optional, TypedDict, Union from typing import Any, ClassVar, Optional, TypedDict, Union
from zoneinfo import available_timezones
from ..apprise_attachment import AppriseAttachment from ..apprise_attachment import AppriseAttachment
from ..common import ( from ..common import (
@ -141,6 +142,9 @@ class NotifyBase(URLBase):
# Persistent storage default settings # Persistent storage default settings
persistent_storage = True persistent_storage = True
# Timezone Default
timezone = 'UTC'
# Default Notify Format # Default Notify Format
notify_format = NotifyFormat.TEXT notify_format = NotifyFormat.TEXT
@ -225,6 +229,17 @@ class NotifyBase(URLBase):
# runtime. # runtime.
"_lookup_default": "persistent_storage", "_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",
},
}, },
) )

71
apprise/utils/time.py Normal file
View File

@ -0,0 +1,71 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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

View File

@ -0,0 +1,56 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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")

View File

@ -25,6 +25,7 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
from datetime import tzinfo
from inspect import cleandoc from inspect import cleandoc
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
@ -3233,3 +3234,23 @@ def test_bytes_to_str():
# Support strings too # Support strings too
assert utils.disk.bytes_to_str("0") == "0.00B" assert utils.disk.bytes_to_str("0") == "0.00B"
assert utils.disk.bytes_to_str("1024") == "1.00KB" 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

View File

@ -33,6 +33,8 @@ import logging
import pytest import pytest
import yaml import yaml
from datetime import tzinfo
from apprise.utils.time import zoneinfo
from apprise import Apprise, AppriseAsset, ConfigFormat from apprise import Apprise, AppriseAsset, ConfigFormat
from apprise.config import ConfigBase from apprise.config import ConfigBase
from apprise.plugins.email import NotifyEmail from apprise.plugins.email import NotifyEmail
@ -1005,6 +1007,9 @@ asset:
image_path_mask: tmp/path image_path_mask: tmp/path
# Timezone (supports tz keyword too)
tz: America/Montreal
# invalid entry # invalid entry
theme: theme:
- -
@ -1042,6 +1047,10 @@ urls:
# Boolean types stay boolean # Boolean types stay boolean
assert asset.async_mode is False 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 # the theme was not updated and remains the same as it was
assert asset.theme == AppriseAsset().theme assert asset.theme == AppriseAsset().theme
@ -1067,6 +1076,9 @@ asset:
app_desc: Apprise Test Notifications app_desc: Apprise Test Notifications
app_url: http://nuxref.com app_url: http://nuxref.com
# An invalid timezone
timezone: invalid
# Optionally define some global tags to associate with ALL of your # Optionally define some global tags to associate with ALL of your
# urls below. # urls below.
tag: admin, devops tag: admin, devops