timezone completion

pull/1398/head
Chris Caron 2025-08-23 11:19:36 -04:00
parent 886fd253ee
commit 96ee18298b
10 changed files with 172 additions and 24 deletions

View File

@ -37,9 +37,9 @@ 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
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)
@ -900,9 +900,9 @@ class ConfigBase(URLBase):
if tokens and isinstance(tokens, dict):
# Prepare our default timezone (if specified)
timezone = str(tokens.get('timezone', tokens.get('tz', '')))
timezone = str(tokens.get("timezone", tokens.get("tz", "")))
if timezone:
default_timezone = zoneinfo(re.sub(r'[^\w/-]+', '', timezone))
default_timezone = zoneinfo(re.sub(r"[^\w/-]+", "", timezone))
if not default_timezone:
ConfigBase.logger.warning(
'Ignored invalid timezone "%s"', timezone)
@ -911,7 +911,7 @@ class ConfigBase(URLBase):
default_timezone = asset.tzinfo
else:
# Set our newly specified timezone
setattr(asset, '_tzinfo', default_timezone)
asset._tzinfo = default_timezone
# Iterate over remaining tokens
for k, v in tokens.items():

View File

@ -27,10 +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 available_timezones
from zoneinfo import ZoneInfo
from ..apprise_attachment import AppriseAttachment
from ..common import (
@ -46,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):
@ -142,8 +144,9 @@ class NotifyBase(URLBase):
# Persistent storage default settings
persistent_storage = True
# Timezone Default
timezone = 'UTC'
# Timezone Default; by setting it to None, the timezone detected
# on the server is used
timezone = None
# Default Notify Format
notify_format = NotifyFormat.TEXT
@ -231,9 +234,7 @@ class NotifyBase(URLBase):
},
"tz": {
"name": _("Timezone"),
"type": "choice:string",
# Supported timezones
"values": available_timezones(),
"type": "string",
# Provide a default
"default": timezone,
# look up default using the following parent class value at
@ -291,6 +292,13 @@ 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
@ -340,6 +348,20 @@ class NotifyBase(URLBase):
self.logger.warning(err)
raise TypeError(err) from None
if "tz" in kwargs:
value = kwargs["tz"]
if isinstance(value, ZoneInfo):
self.__tzinfo = kwargs["tz"]
else:
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:
@ -859,6 +881,10 @@ class NotifyBase(URLBase):
"overflow": self.overflow_mode.value,
}
# Timezone Information
if self.__tzinfo:
params["tz"] = self.__tzinfo.key
# Persistent Storage Setting
if self.persistent_storage != NotifyBase.persistent_storage:
params["store"] = "yes" if self.persistent_storage else "no"
@ -924,6 +950,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"]
@ -970,3 +1000,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)

View File

@ -25,12 +25,13 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import contextlib
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from ..logger import logger
def zoneinfo(name: str) -> ZoneInfo:
def zoneinfo(name: str) -> Optional[ZoneInfo]:
"""
More forgiving ZoneInfo instantiation
- Accepts lower/upper case
@ -63,7 +64,12 @@ def zoneinfo(name: str) -> ZoneInfo:
return ZoneInfo(zone)
with contextlib.suppress(IndexError):
location = full_zone.split("/")[1:][0]
# 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)

View File

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

View File

@ -26,9 +26,10 @@
# POSSIBILITY OF SUCH DAMAGE.
# Disable logging for a cleaner testing output
from datetime import tzinfo
from datetime import timezone, tzinfo
import logging
import sys
from zoneinfo import ZoneInfo
import pytest
@ -49,6 +50,13 @@ def test_timezone():
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)

View File

@ -3236,7 +3236,7 @@ def test_bytes_to_str():
assert utils.disk.bytes_to_str("1024") == "1.00KB"
def test_zoneinfo():
def test_time_zoneinfo():
"""utils: zoneinfo() testing"""
# Some valid strings
@ -3248,6 +3248,17 @@ def test_zoneinfo():
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

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
@ -33,11 +34,10 @@ 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
from apprise.utils.time import zoneinfo
logging.disable(logging.CRITICAL)
@ -1049,7 +1049,7 @@ urls:
# Our TimeZone
assert isinstance(asset.tzinfo, tzinfo)
assert asset.tzinfo.key == zoneinfo('America/Montreal').key
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

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,76 @@ 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
assert obj.tzinfo.key == "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