Global Timezone support added (tz=) (#1398)

This commit is contained in:
Chris Caron
2025-08-23 19:02:26 -04:00
committed by GitHub
parent 52801b6369
commit dca2f7aad8
13 changed files with 496 additions and 9 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

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

View File

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

View File

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

View File

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

View File

@@ -1792,6 +1792,7 @@ def test_apprise_details_plugin_verification():
"schema",
"fullpath",
# NotifyBase parameters:
"tz",
"format",
"overflow",
"emojis",

View 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")

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

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, 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)

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
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."""

View File

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

View File

@@ -4,3 +4,4 @@
# occur in pyproject.toml. Contents of this file can be found
# in [project.optional-dependencies].windows
pywin32
tzdata