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
# 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

View File

@ -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

View File

@ -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",
},
},
)

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
# 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

View File

@ -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