Turn large ntfy messages into a attachments (#1136)

This commit is contained in:
Chris Caron 2024-06-01 21:43:38 -04:00 committed by GitHub
parent e289279896
commit de3acafef9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2864 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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():

View File

@ -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):
""" """

View File

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

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff