mirror of
https://github.com/caronc/apprise.git
synced 2025-12-15 10:04:06 +08:00
Global Timezone support added (tz=) (#1398)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<token>[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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
79
apprise/utils/time.py
Normal file
79
apprise/utils/time.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# 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 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
|
||||
@@ -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]
|
||||
|
||||
@@ -1792,6 +1792,7 @@ def test_apprise_details_plugin_verification():
|
||||
"schema",
|
||||
"fullpath",
|
||||
# NotifyBase parameters:
|
||||
"tz",
|
||||
"format",
|
||||
"overflow",
|
||||
"emojis",
|
||||
|
||||
64
tests/test_apprise_asset.py
Normal file
64
tests/test_apprise_asset.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
# occur in pyproject.toml. Contents of this file can be found
|
||||
# in [project.optional-dependencies].windows
|
||||
pywin32
|
||||
tzdata
|
||||
|
||||
Reference in New Issue
Block a user