mirror of
https://github.com/caronc/apprise.git
synced 2025-01-23 14:28:49 +01:00
Turn large ntfy messages into a attachments (#1136)
This commit is contained in:
parent
e289279896
commit
de3acafef9
@ -142,13 +142,8 @@ class AppriseAttachment:
|
|||||||
# prepare default asset
|
# prepare default asset
|
||||||
asset = self.asset
|
asset = self.asset
|
||||||
|
|
||||||
if isinstance(attachments, AttachBase):
|
if isinstance(attachments, (AttachBase, str)):
|
||||||
# Go ahead and just add our attachments into our list
|
# store our instance
|
||||||
self.attachments.append(attachments)
|
|
||||||
return True
|
|
||||||
|
|
||||||
elif isinstance(attachments, str):
|
|
||||||
# Save our path
|
|
||||||
attachments = (attachments, )
|
attachments = (attachments, )
|
||||||
|
|
||||||
elif not isinstance(attachments, (tuple, set, list)):
|
elif not isinstance(attachments, (tuple, set, list)):
|
||||||
|
@ -148,6 +148,9 @@ class AttachBase(URLBase):
|
|||||||
# Absolute path to attachment
|
# Absolute path to attachment
|
||||||
self.download_path = None
|
self.download_path = None
|
||||||
|
|
||||||
|
# Track open file pointers
|
||||||
|
self.__pointers = set()
|
||||||
|
|
||||||
# Set our cache flag; it can be True, False, None, or a (positive)
|
# Set our cache flag; it can be True, False, None, or a (positive)
|
||||||
# integer... nothing else
|
# integer... nothing else
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
@ -226,15 +229,14 @@ class AttachBase(URLBase):
|
|||||||
Content is cached once determied to prevent overhead of future
|
Content is cached once determied to prevent overhead of future
|
||||||
calls.
|
calls.
|
||||||
"""
|
"""
|
||||||
|
if not self.exists():
|
||||||
|
# we could not obtain our attachment
|
||||||
|
return None
|
||||||
|
|
||||||
if self._mimetype:
|
if self._mimetype:
|
||||||
# return our pre-calculated cached content
|
# return our pre-calculated cached content
|
||||||
return self._mimetype
|
return self._mimetype
|
||||||
|
|
||||||
if not self.exists():
|
|
||||||
# we could not obtain our attachment
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not self.detected_mimetype:
|
if not self.detected_mimetype:
|
||||||
# guess_type() returns: (type, encoding) and sets type to None
|
# guess_type() returns: (type, encoding) and sets type to None
|
||||||
# if it can't otherwise determine it.
|
# if it can't otherwise determine it.
|
||||||
@ -258,6 +260,9 @@ class AttachBase(URLBase):
|
|||||||
Simply returns true if the object has downloaded and stored the
|
Simply returns true if the object has downloaded and stored the
|
||||||
attachment AND the attachment has not expired.
|
attachment AND the attachment has not expired.
|
||||||
"""
|
"""
|
||||||
|
if self.location == ContentLocation.INACCESSIBLE:
|
||||||
|
# our content is inaccessible
|
||||||
|
return False
|
||||||
|
|
||||||
cache = self.template_args['cache']['default'] \
|
cache = self.template_args['cache']['default'] \
|
||||||
if self.cache is None else self.cache
|
if self.cache is None else self.cache
|
||||||
@ -295,6 +300,11 @@ class AttachBase(URLBase):
|
|||||||
- download_path: Must contain a absolute path to content
|
- download_path: Must contain a absolute path to content
|
||||||
- detected_mimetype: Should identify mimetype of content
|
- detected_mimetype: Should identify mimetype of content
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Remove all open pointers
|
||||||
|
while self.__pointers:
|
||||||
|
self.__pointers.pop().close()
|
||||||
|
|
||||||
self.detected_name = None
|
self.detected_name = None
|
||||||
self.download_path = None
|
self.download_path = None
|
||||||
self.detected_mimetype = None
|
self.detected_mimetype = None
|
||||||
@ -314,6 +324,26 @@ class AttachBase(URLBase):
|
|||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"download() is implimented by the child class.")
|
"download() is implimented by the child class.")
|
||||||
|
|
||||||
|
def open(self, mode='rb'):
|
||||||
|
"""
|
||||||
|
return our file pointer and track it (we'll auto close later
|
||||||
|
"""
|
||||||
|
pointer = open(self.path, mode=mode)
|
||||||
|
self.__pointers.add(pointer)
|
||||||
|
return pointer
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""
|
||||||
|
support with keyword
|
||||||
|
"""
|
||||||
|
return self.open()
|
||||||
|
|
||||||
|
def __exit__(self, value_type, value, traceback):
|
||||||
|
"""
|
||||||
|
stub to do nothing; but support exit of with statement gracefully
|
||||||
|
"""
|
||||||
|
return
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True):
|
def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True):
|
||||||
"""Parses the URL and returns it broken apart into a dictionary.
|
"""Parses the URL and returns it broken apart into a dictionary.
|
||||||
@ -376,3 +406,9 @@ class AttachBase(URLBase):
|
|||||||
True is returned if our content was downloaded correctly.
|
True is returned if our content was downloaded correctly.
|
||||||
"""
|
"""
|
||||||
return True if self.path else False
|
return True if self.path else False
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""
|
||||||
|
Perform any house cleaning
|
||||||
|
"""
|
||||||
|
self.invalidate()
|
||||||
|
@ -78,7 +78,8 @@ class AttachFile(AttachBase):
|
|||||||
|
|
||||||
return 'file://{path}{params}'.format(
|
return 'file://{path}{params}'.format(
|
||||||
path=self.quote(self.dirty_path),
|
path=self.quote(self.dirty_path),
|
||||||
params='?{}'.format(self.urlencode(params)) if params else '',
|
params='?{}'.format(self.urlencode(params, safe='/'))
|
||||||
|
if params else '',
|
||||||
)
|
)
|
||||||
|
|
||||||
def download(self, **kwargs):
|
def download(self, **kwargs):
|
||||||
|
@ -352,7 +352,7 @@ class AttachHTTP(AttachBase):
|
|||||||
port='' if self.port is None or self.port == default_port
|
port='' if self.port is None or self.port == default_port
|
||||||
else ':{}'.format(self.port),
|
else ':{}'.format(self.port),
|
||||||
fullpath=self.quote(self.fullpath, safe='/'),
|
fullpath=self.quote(self.fullpath, safe='/'),
|
||||||
params=self.urlencode(params),
|
params=self.urlencode(params, safe='/'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
212
apprise/attachment/memory.py
Normal file
212
apprise/attachment/memory.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# BSD 2-Clause License
|
||||||
|
#
|
||||||
|
# Apprise - Push Notification Library.
|
||||||
|
# Copyright (c) 2024, 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 re
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
from .base import AttachBase
|
||||||
|
from ..common import ContentLocation
|
||||||
|
from ..locale import gettext_lazy as _
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class AttachMemory(AttachBase):
|
||||||
|
"""
|
||||||
|
A wrapper for Memory based attachment sources
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The default descriptive name associated with the service
|
||||||
|
service_name = _('Memory')
|
||||||
|
|
||||||
|
# The default protocol
|
||||||
|
protocol = 'memory'
|
||||||
|
|
||||||
|
# Content is local to the same location as the apprise instance
|
||||||
|
# being called (server-side)
|
||||||
|
location = ContentLocation.LOCAL
|
||||||
|
|
||||||
|
def __init__(self, content=None, name=None, mimetype=None,
|
||||||
|
encoding='utf-8', **kwargs):
|
||||||
|
"""
|
||||||
|
Initialize Memory Based Attachment Object
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Create our BytesIO object
|
||||||
|
self._data = io.BytesIO()
|
||||||
|
|
||||||
|
if content is None:
|
||||||
|
# Empty; do nothing
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif isinstance(content, str):
|
||||||
|
content = content.encode(encoding)
|
||||||
|
if mimetype is None:
|
||||||
|
mimetype = 'text/plain'
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
# Generate a unique filename
|
||||||
|
name = str(uuid.uuid4()) + '.txt'
|
||||||
|
|
||||||
|
elif not isinstance(content, bytes):
|
||||||
|
raise TypeError(
|
||||||
|
'Provided content for memory attachment is invalid')
|
||||||
|
|
||||||
|
# Store our content
|
||||||
|
if content:
|
||||||
|
self._data.write(content)
|
||||||
|
|
||||||
|
if mimetype is None:
|
||||||
|
# Default mimetype
|
||||||
|
mimetype = 'application/octet-stream'
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
# Generate a unique filename
|
||||||
|
name = str(uuid.uuid4()) + '.dat'
|
||||||
|
|
||||||
|
# Initialize our base object
|
||||||
|
super().__init__(name=name, mimetype=mimetype, **kwargs)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def url(self, privacy=False, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Returns the URL built dynamically based on specified arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Define any URL parameters
|
||||||
|
params = {
|
||||||
|
'mime': self._mimetype,
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'memory://{name}?{params}'.format(
|
||||||
|
name=self.quote(self._name),
|
||||||
|
params=self.urlencode(params, safe='/')
|
||||||
|
)
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
return our memory object
|
||||||
|
"""
|
||||||
|
# Return our object
|
||||||
|
self._data.seek(0, 0)
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""
|
||||||
|
support with clause
|
||||||
|
"""
|
||||||
|
# Return our object
|
||||||
|
self._data.seek(0, 0)
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
def download(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Handle memory download() call
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.location == ContentLocation.INACCESSIBLE:
|
||||||
|
# our content is inaccessible
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.max_file_size > 0 and len(self) > self.max_file_size:
|
||||||
|
# The content to attach is to large
|
||||||
|
self.logger.error(
|
||||||
|
'Content exceeds allowable maximum memory size '
|
||||||
|
'({}KB): {}'.format(
|
||||||
|
int(self.max_file_size / 1024), self.url(privacy=True)))
|
||||||
|
|
||||||
|
# Return False (signifying a failure)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def invalidate(self):
|
||||||
|
"""
|
||||||
|
Removes data
|
||||||
|
"""
|
||||||
|
self._data.truncate(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
def exists(self):
|
||||||
|
"""
|
||||||
|
over-ride exists() call
|
||||||
|
"""
|
||||||
|
size = len(self)
|
||||||
|
return True if self.location != ContentLocation.INACCESSIBLE \
|
||||||
|
and size > 0 and (
|
||||||
|
self.max_file_size <= 0 or
|
||||||
|
(self.max_file_size > 0 and size <= self.max_file_size)) \
|
||||||
|
else False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_url(url):
|
||||||
|
"""
|
||||||
|
Parses the URL so that we can handle all different file paths
|
||||||
|
and return it as our path object
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = AttachBase.parse_url(url, verify_host=False)
|
||||||
|
if not results:
|
||||||
|
# We're done early; it's not a good URL
|
||||||
|
return results
|
||||||
|
|
||||||
|
if 'name' not in results:
|
||||||
|
# Allow fall-back to be from URL
|
||||||
|
match = re.match(r'memory://(?P<path>[^?]+)(\?.*)?', url, re.I)
|
||||||
|
if match:
|
||||||
|
# Store our filename only (ignore any defined paths)
|
||||||
|
results['name'] = \
|
||||||
|
os.path.basename(AttachMemory.unquote(match.group('path')))
|
||||||
|
return results
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
"""
|
||||||
|
return the filename
|
||||||
|
"""
|
||||||
|
if not self.exists():
|
||||||
|
# we could not obtain our path
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
"""
|
||||||
|
Returns the size of he memory attachment
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self._data.getbuffer().nbytes
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
"""
|
||||||
|
Allows the Apprise object to be wrapped in an based 'if statement'.
|
||||||
|
True is returned if our content was downloaded correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.exists()
|
@ -53,6 +53,7 @@ from ..utils import is_ipaddr
|
|||||||
from ..utils import validate_regex
|
from ..utils import validate_regex
|
||||||
from ..url import PrivacyMode
|
from ..url import PrivacyMode
|
||||||
from ..attachment.base import AttachBase
|
from ..attachment.base import AttachBase
|
||||||
|
from ..attachment.memory import AttachMemory
|
||||||
|
|
||||||
|
|
||||||
class NtfyMode:
|
class NtfyMode:
|
||||||
@ -172,6 +173,20 @@ class NotifyNtfy(NotifyBase):
|
|||||||
# Support attachments
|
# Support attachments
|
||||||
attachment_support = True
|
attachment_support = True
|
||||||
|
|
||||||
|
# Maximum title length
|
||||||
|
title_maxlen = 200
|
||||||
|
|
||||||
|
# Maximum body length
|
||||||
|
body_maxlen = 7800
|
||||||
|
|
||||||
|
# Message size calculates title and body together
|
||||||
|
overflow_amalgamate_title = True
|
||||||
|
|
||||||
|
# Defines the number of bytes our JSON object can not exceed in size or we
|
||||||
|
# know the upstream server will reject it. We convert these into
|
||||||
|
# attachments
|
||||||
|
ntfy_json_upstream_size_limit = 8000
|
||||||
|
|
||||||
# Allows the user to specify the NotifyImageSize object
|
# Allows the user to specify the NotifyImageSize object
|
||||||
image_size = NotifyImageSize.XY_256
|
image_size = NotifyImageSize.XY_256
|
||||||
|
|
||||||
@ -504,7 +519,7 @@ class NotifyNtfy(NotifyBase):
|
|||||||
# Prepare our Header
|
# Prepare our Header
|
||||||
virt_payload['filename'] = attach.name
|
virt_payload['filename'] = attach.name
|
||||||
|
|
||||||
with open(attach.path, 'rb') as fp:
|
with attach as fp:
|
||||||
data = fp.read()
|
data = fp.read()
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
@ -538,18 +553,39 @@ class NotifyNtfy(NotifyBase):
|
|||||||
self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % (
|
self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % (
|
||||||
notify_url, self.verify_certificate,
|
notify_url, self.verify_certificate,
|
||||||
))
|
))
|
||||||
self.logger.debug('ntfy Payload: %s' % str(virt_payload))
|
|
||||||
self.logger.debug('ntfy Headers: %s' % str(headers))
|
|
||||||
|
|
||||||
# Always call throttle before any remote server i/o is made
|
|
||||||
self.throttle()
|
|
||||||
|
|
||||||
# Default response type
|
# Default response type
|
||||||
response = None
|
response = None
|
||||||
|
|
||||||
if not attach:
|
if not attach:
|
||||||
data = dumps(data)
|
data = dumps(data)
|
||||||
|
if len(data) > self.ntfy_json_upstream_size_limit:
|
||||||
|
# Convert to an attachment
|
||||||
|
|
||||||
|
if self.notify_format == NotifyFormat.MARKDOWN:
|
||||||
|
mimetype = 'text/markdown'
|
||||||
|
|
||||||
|
elif self.notify_format == NotifyFormat.TEXT:
|
||||||
|
mimetype = 'text/plain'
|
||||||
|
|
||||||
|
else: # self.notify_format == NotifyFormat.HTML:
|
||||||
|
mimetype = 'text/html'
|
||||||
|
|
||||||
|
attach = AttachMemory(
|
||||||
|
mimetype=mimetype,
|
||||||
|
content='{title}{body}'.format(
|
||||||
|
title=title + '\n' if title else '', body=body))
|
||||||
|
|
||||||
|
# Recursively send the message body as an attachment instead
|
||||||
|
return self._send(
|
||||||
|
topic=topic, body='', title='', attach=attach,
|
||||||
|
image_url=image_url, **kwargs)
|
||||||
|
|
||||||
|
self.logger.debug('ntfy Payload: %s' % str(virt_payload))
|
||||||
|
self.logger.debug('ntfy Headers: %s' % str(headers))
|
||||||
|
|
||||||
|
# Always call throttle before any remote server i/o is made
|
||||||
|
self.throttle()
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
notify_url,
|
notify_url,
|
||||||
|
@ -1,10 +1,19 @@
|
|||||||
diff -Naur apprise-1.6.0/test/conftest.py apprise-1.6.0-patched/test/conftest.py
|
diff -Naur apprise-1.8.0/test/conftest.py apprise-1.8.0-patched/test/conftest.py
|
||||||
--- apprise-1.6.0/test/conftest.py 2023-12-27 11:20:40.000000000 -0500
|
--- apprise-1.8.0/test/conftest.py 2024-06-01 21:28:32.102470720 -0400
|
||||||
+++ apprise-1.6.0-patched/test/conftest.py 2023-12-27 13:43:22.583100037 -0500
|
+++ apprise-1.8.0-patched/test/conftest.py 2024-06-01 21:29:32.363754277 -0400
|
||||||
@@ -45,8 +45,8 @@
|
@@ -46,7 +46,7 @@
|
||||||
A_MGR = AttachmentManager()
|
A_MGR = AttachmentManager()
|
||||||
|
|
||||||
|
|
||||||
|
-@pytest.fixture(scope="function", autouse=True)
|
||||||
|
+@pytest.fixture(autouse=True)
|
||||||
|
def mimetypes_always_available():
|
||||||
|
"""
|
||||||
|
A pytest session fixture which ensures mimetypes is set correctly
|
||||||
|
@@ -56,8 +56,8 @@
|
||||||
|
mimetypes.init(files=files)
|
||||||
|
|
||||||
|
|
||||||
-@pytest.fixture(scope="function", autouse=True)
|
-@pytest.fixture(scope="function", autouse=True)
|
||||||
-def no_throttling_everywhere(session_mocker):
|
-def no_throttling_everywhere(session_mocker):
|
||||||
+@pytest.fixture(autouse=True)
|
+@pytest.fixture(autouse=True)
|
||||||
@ -12,7 +21,7 @@ diff -Naur apprise-1.6.0/test/conftest.py apprise-1.6.0-patched/test/conftest.py
|
|||||||
"""
|
"""
|
||||||
A pytest session fixture which disables throttling on all notifiers.
|
A pytest session fixture which disables throttling on all notifiers.
|
||||||
It is automatically enabled.
|
It is automatically enabled.
|
||||||
@@ -57,4 +57,4 @@
|
@@ -68,4 +68,4 @@
|
||||||
A_MGR.unload_modules()
|
A_MGR.unload_modules()
|
||||||
|
|
||||||
for plugin in N_MGR.plugins():
|
for plugin in N_MGR.plugins():
|
||||||
|
@ -30,6 +30,7 @@ import sys
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
from apprise import NotificationManager
|
from apprise import NotificationManager
|
||||||
from apprise import ConfigurationManager
|
from apprise import ConfigurationManager
|
||||||
@ -45,6 +46,16 @@ C_MGR = ConfigurationManager()
|
|||||||
A_MGR = AttachmentManager()
|
A_MGR = AttachmentManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function", autouse=True)
|
||||||
|
def mimetypes_always_available():
|
||||||
|
"""
|
||||||
|
A pytest session fixture which ensures mimetypes is set correctly
|
||||||
|
pointing to our temporary mime.types file
|
||||||
|
"""
|
||||||
|
files = (os.path.join(os.path.dirname(__file__), 'var', 'mime.types'), )
|
||||||
|
mimetypes.init(files=files)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function", autouse=True)
|
@pytest.fixture(scope="function", autouse=True)
|
||||||
def no_throttling_everywhere(session_mocker):
|
def no_throttling_everywhere(session_mocker):
|
||||||
"""
|
"""
|
||||||
|
@ -284,9 +284,6 @@ def test_apprise_attachment_truncate(mock_get):
|
|||||||
# Add ourselves an object set to truncate
|
# Add ourselves an object set to truncate
|
||||||
ap_obj.add('json://localhost/?method=GET&overflow=truncate')
|
ap_obj.add('json://localhost/?method=GET&overflow=truncate')
|
||||||
|
|
||||||
# Add ourselves a second object without truncate
|
|
||||||
ap_obj.add('json://localhost/?method=GET&overflow=upstream')
|
|
||||||
|
|
||||||
# Create ourselves an attachment object
|
# Create ourselves an attachment object
|
||||||
aa = AppriseAttachment()
|
aa = AppriseAttachment()
|
||||||
|
|
||||||
@ -304,15 +301,36 @@ def test_apprise_attachment_truncate(mock_get):
|
|||||||
assert mock_get.call_count == 0
|
assert mock_get.call_count == 0
|
||||||
assert ap_obj.notify(body='body', title='title', attach=aa)
|
assert ap_obj.notify(body='body', title='title', attach=aa)
|
||||||
|
|
||||||
assert mock_get.call_count == 2
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
# Our first item was truncated, so only 1 attachment
|
# Our first item was truncated, so only 1 attachment
|
||||||
details = mock_get.call_args_list[0]
|
details = mock_get.call_args_list[0]
|
||||||
dataset = json.loads(details[1]['data'])
|
dataset = json.loads(details[1]['data'])
|
||||||
assert len(dataset['attachments']) == 1
|
assert len(dataset['attachments']) == 1
|
||||||
|
|
||||||
# Our second item was not truncated, so all attachments
|
# Reset our object
|
||||||
details = mock_get.call_args_list[1]
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
# our Apprise Object
|
||||||
|
ap_obj = Apprise()
|
||||||
|
|
||||||
|
# Add ourselves an object set to upstream
|
||||||
|
ap_obj.add('json://localhost/?method=GET&overflow=upstream')
|
||||||
|
|
||||||
|
# Create ourselves an attachment object
|
||||||
|
aa = AppriseAttachment()
|
||||||
|
|
||||||
|
# Add 2 attachments
|
||||||
|
assert aa.add(join(TEST_VAR_DIR, 'apprise-test.gif'))
|
||||||
|
assert aa.add(join(TEST_VAR_DIR, 'apprise-test.png'))
|
||||||
|
|
||||||
|
assert mock_get.call_count == 0
|
||||||
|
assert ap_obj.notify(body='body', title='title', attach=aa)
|
||||||
|
|
||||||
|
assert mock_get.call_count == 1
|
||||||
|
|
||||||
|
# Our item was not truncated, so all attachments
|
||||||
|
details = mock_get.call_args_list[0]
|
||||||
dataset = json.loads(details[1]['data'])
|
dataset = json.loads(details[1]['data'])
|
||||||
assert len(dataset['attachments']) == 2
|
assert len(dataset['attachments']) == 2
|
||||||
|
|
||||||
|
@ -73,15 +73,6 @@ def test_attach_base():
|
|||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
obj.download()
|
obj.download()
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
|
||||||
obj.name
|
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
|
||||||
obj.path
|
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
|
||||||
obj.mimetype
|
|
||||||
|
|
||||||
# Unsupported URLs are not parsed
|
# Unsupported URLs are not parsed
|
||||||
assert AttachBase.parse_url(url='invalid://') is None
|
assert AttachBase.parse_url(url='invalid://') is None
|
||||||
|
|
||||||
|
@ -204,8 +204,7 @@ def test_attach_file():
|
|||||||
assert response.path == path
|
assert response.path == path
|
||||||
assert response.name == 'test.jpeg'
|
assert response.name == 'test.jpeg'
|
||||||
assert response.mimetype == 'image/jpeg'
|
assert response.mimetype == 'image/jpeg'
|
||||||
# We will match on mime type now (%2F = /)
|
assert re.search(r'[?&]mime=image/jpeg', response.url(), re.I)
|
||||||
assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I)
|
|
||||||
assert re.search(r'[?&]name=test\.jpeg', response.url(), re.I)
|
assert re.search(r'[?&]name=test\.jpeg', response.url(), re.I)
|
||||||
|
|
||||||
# Test hosted configuration and that we can't add a valid file
|
# Test hosted configuration and that we can't add a valid file
|
||||||
|
@ -229,7 +229,7 @@ def test_attach_http(mock_get, mock_post):
|
|||||||
attachment = AttachHTTP(**results)
|
attachment = AttachHTTP(**results)
|
||||||
assert isinstance(attachment.url(), str) is True
|
assert isinstance(attachment.url(), str) is True
|
||||||
# both mime and name over-ridden
|
# both mime and name over-ridden
|
||||||
assert re.search(r'[?&]mime=image%2Fjpeg', attachment.url())
|
assert re.search(r'[?&]mime=image/jpeg', attachment.url())
|
||||||
assert re.search(r'[?&]name=usethis.jpg', attachment.url())
|
assert re.search(r'[?&]name=usethis.jpg', attachment.url())
|
||||||
# No Content-Disposition; so we use filename from path
|
# No Content-Disposition; so we use filename from path
|
||||||
assert attachment.name == 'usethis.jpg'
|
assert attachment.name == 'usethis.jpg'
|
||||||
|
205
test/test_attach_memory.py
Normal file
205
test/test_attach_memory.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# BSD 2-Clause License
|
||||||
|
#
|
||||||
|
# Apprise - Push Notification Library.
|
||||||
|
# Copyright (c) 2024, 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 re
|
||||||
|
import urllib
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apprise.attachment.base import AttachBase
|
||||||
|
from apprise.attachment.memory import AttachMemory
|
||||||
|
from apprise import AppriseAttachment
|
||||||
|
from apprise.common import ContentLocation
|
||||||
|
|
||||||
|
# Disable logging for a cleaner testing output
|
||||||
|
import logging
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_memory_parse_url():
|
||||||
|
"""
|
||||||
|
API: AttachMemory().parse_url()
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Bad Entry
|
||||||
|
assert AttachMemory.parse_url(object) is None
|
||||||
|
|
||||||
|
# Our filename is detected automatically
|
||||||
|
assert AttachMemory.parse_url('memory://')
|
||||||
|
|
||||||
|
# pass our content in as a string
|
||||||
|
mem = AttachMemory(content='string')
|
||||||
|
# it loads a string type by default
|
||||||
|
mem.mimetype == 'text/plain'
|
||||||
|
# Our filename is automatically generated (with .txt)
|
||||||
|
assert re.match(r'^[a-z0-9-]+\.txt$', mem.name, re.I)
|
||||||
|
|
||||||
|
# open our file
|
||||||
|
with mem as fp:
|
||||||
|
assert fp.getbuffer().nbytes == len(mem)
|
||||||
|
|
||||||
|
# pass our content in as a string
|
||||||
|
mem = AttachMemory(
|
||||||
|
content='<html/>', name='test.html', mimetype='text/html')
|
||||||
|
# it loads a string type by default
|
||||||
|
mem.mimetype == 'text/html'
|
||||||
|
mem.name == 'test.html'
|
||||||
|
|
||||||
|
# Stub function
|
||||||
|
assert mem.download()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
# garbage in, garbage out
|
||||||
|
AttachMemory(content=3)
|
||||||
|
|
||||||
|
# pointer to our data
|
||||||
|
pointer = mem.open()
|
||||||
|
assert pointer.read() == b'<html/>'
|
||||||
|
|
||||||
|
# pass our content in as a string
|
||||||
|
mem = AttachMemory(content=b'binary-data', name='raw.dat')
|
||||||
|
# it loads a string type by default
|
||||||
|
assert mem.mimetype == 'application/octet-stream'
|
||||||
|
mem.name == 'raw'
|
||||||
|
|
||||||
|
# pass our content in as a string
|
||||||
|
mem = AttachMemory(content=b'binary-data')
|
||||||
|
# it loads a string type by default
|
||||||
|
assert mem.mimetype == 'application/octet-stream'
|
||||||
|
# Our filename is automatically generated (with .dat)
|
||||||
|
assert re.match(r'^[a-z0-9-]+\.dat$', mem.name, re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_memory():
|
||||||
|
"""
|
||||||
|
API: AttachMemory()
|
||||||
|
|
||||||
|
"""
|
||||||
|
# A url we can test with
|
||||||
|
fname = 'testfile'
|
||||||
|
url = 'memory:///ignored/path/{fname}'.format(fname=fname)
|
||||||
|
|
||||||
|
# Simple gif test
|
||||||
|
response = AppriseAttachment.instantiate(url)
|
||||||
|
assert isinstance(response, AttachMemory)
|
||||||
|
|
||||||
|
# There is no path yet as we haven't written anything to our memory object
|
||||||
|
# yet
|
||||||
|
assert response.path is None
|
||||||
|
assert bool(response) is False
|
||||||
|
|
||||||
|
with response as memobj:
|
||||||
|
memobj.write(b'content')
|
||||||
|
|
||||||
|
# Memory object defaults
|
||||||
|
assert response.name == fname
|
||||||
|
assert response.path == response.name
|
||||||
|
assert response.mimetype == 'application/octet-stream'
|
||||||
|
assert bool(response) is True
|
||||||
|
|
||||||
|
#
|
||||||
|
fname_in_url = urllib.parse.quote(response.name)
|
||||||
|
assert response.url().startswith('memory://{}'.format(fname_in_url))
|
||||||
|
|
||||||
|
# Mime is always part of url
|
||||||
|
assert re.search(r'[?&]mime=', response.url()) is not None
|
||||||
|
|
||||||
|
# Test case where location is simply set to INACCESSIBLE
|
||||||
|
# Below is a bad example, but it proves the section of code properly works.
|
||||||
|
# Ideally a server admin may wish to just disable all File based
|
||||||
|
# attachments entirely. In this case, they simply just need to change the
|
||||||
|
# global singleton at the start of their program like:
|
||||||
|
#
|
||||||
|
# import apprise
|
||||||
|
# apprise.attachment.AttachMemory.location = \
|
||||||
|
# apprise.ContentLocation.INACCESSIBLE
|
||||||
|
#
|
||||||
|
response = AppriseAttachment.instantiate(url)
|
||||||
|
assert isinstance(response, AttachMemory)
|
||||||
|
with response as memobj:
|
||||||
|
memobj.write(b'content')
|
||||||
|
|
||||||
|
response.location = ContentLocation.INACCESSIBLE
|
||||||
|
assert response.path is None
|
||||||
|
# Downloads just don't work period
|
||||||
|
assert response.download() is False
|
||||||
|
|
||||||
|
# File handling (even if image is set to maxium allowable)
|
||||||
|
response = AppriseAttachment.instantiate(url)
|
||||||
|
assert isinstance(response, AttachMemory)
|
||||||
|
with response as memobj:
|
||||||
|
memobj.write(b'content')
|
||||||
|
|
||||||
|
# Memory handling when size is to large
|
||||||
|
response = AppriseAttachment.instantiate(url)
|
||||||
|
assert isinstance(response, AttachMemory)
|
||||||
|
with response as memobj:
|
||||||
|
memobj.write(b'content')
|
||||||
|
|
||||||
|
# Test case where we exceed our defined max_file_size in memory
|
||||||
|
prev_value = AttachBase.max_file_size
|
||||||
|
AttachBase.max_file_size = len(response) - 1
|
||||||
|
# We can't work in this case
|
||||||
|
assert response.path is None
|
||||||
|
assert response.download() is False
|
||||||
|
|
||||||
|
# Restore our file_size
|
||||||
|
AttachBase.max_file_size = prev_value
|
||||||
|
|
||||||
|
response = AppriseAttachment.instantiate(
|
||||||
|
'memory://apprise-file.gif?mime=image/gif')
|
||||||
|
assert isinstance(response, AttachMemory)
|
||||||
|
with response as memobj:
|
||||||
|
memobj.write(b'content')
|
||||||
|
|
||||||
|
assert response.name == 'apprise-file.gif'
|
||||||
|
assert response.path == response.name
|
||||||
|
assert response.mimetype == 'image/gif'
|
||||||
|
# No mime-type and/or filename over-ride was specified, so therefore it
|
||||||
|
# won't show up in the generated URL
|
||||||
|
assert re.search(r'[?&]mime=', response.url()) is not None
|
||||||
|
assert 'image/gif' in response.url()
|
||||||
|
|
||||||
|
# Force a mime-type and new name
|
||||||
|
response = AppriseAttachment.instantiate(
|
||||||
|
'memory://{}?mime={}&name={}'.format(
|
||||||
|
'ignored.gif', 'image/jpeg', 'test.jpeg'))
|
||||||
|
assert isinstance(response, AttachMemory)
|
||||||
|
with response as memobj:
|
||||||
|
memobj.write(b'content')
|
||||||
|
|
||||||
|
assert response.name == 'test.jpeg'
|
||||||
|
assert response.path == response.name
|
||||||
|
assert response.mimetype == 'image/jpeg'
|
||||||
|
# We will match on mime type now (%2F = /)
|
||||||
|
assert re.search(r'[?&]mime=image/jpeg', response.url(), re.I)
|
||||||
|
assert response.url().startswith('memory://test.jpeg')
|
||||||
|
|
||||||
|
# Test hosted configuration and that we can't add a valid memory file
|
||||||
|
aa = AppriseAttachment(location=ContentLocation.HOSTED)
|
||||||
|
assert aa.add(response) is False
|
@ -26,6 +26,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.
|
||||||
|
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@ -571,3 +572,47 @@ def test_plugin_ntfy_config_files(mock_post, mock_get):
|
|||||||
assert len([x for x in aobj.find(tag='ntfy_invalid')]) == 1
|
assert len([x for x in aobj.find(tag='ntfy_invalid')]) == 1
|
||||||
assert next(aobj.find(tag='ntfy_invalid')).priority == \
|
assert next(aobj.find(tag='ntfy_invalid')).priority == \
|
||||||
NtfyPriority.NORMAL
|
NtfyPriority.NORMAL
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.post')
|
||||||
|
def test_plugin_ntfy_message_to_attach(mock_post):
|
||||||
|
"""
|
||||||
|
NotifyNtfy() large messages converted into attachments
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Prepare Mock return object
|
||||||
|
response = mock.Mock()
|
||||||
|
response.content = GOOD_RESPONSE_TEXT
|
||||||
|
response.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value = response
|
||||||
|
|
||||||
|
# Create a very, very big message
|
||||||
|
title = 'My Title'
|
||||||
|
body = 'b' * NotifyNtfy.ntfy_json_upstream_size_limit
|
||||||
|
|
||||||
|
for fmt in apprise.NOTIFY_FORMATS:
|
||||||
|
|
||||||
|
# Prepare our object
|
||||||
|
obj = apprise.Apprise.instantiate(
|
||||||
|
'ntfy://user:pass@localhost:8080/topic?format={}'.format(fmt))
|
||||||
|
|
||||||
|
# Our content will actually transfer as an attachment
|
||||||
|
assert obj.notify(title=title, body=body)
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'http://localhost:8080/topic'
|
||||||
|
|
||||||
|
response = mock_post.call_args_list[0][1]
|
||||||
|
assert 'data' in response
|
||||||
|
assert response['data'].decode('utf-8').startswith(title)
|
||||||
|
assert response['data'].decode('utf-8').endswith(body)
|
||||||
|
assert 'params' in response
|
||||||
|
assert 'filename' in response['params']
|
||||||
|
# Our filename is automatically generated (with .txt)
|
||||||
|
assert re.match(
|
||||||
|
r'^[a-z0-9-]+\.txt$', response['params']['filename'], re.I)
|
||||||
|
|
||||||
|
# Reset our mock object
|
||||||
|
mock_post.reset_mock()
|
||||||
|
2264
test/var/mime.types
Normal file
2264
test/var/mime.types
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user