mirror of
https://github.com/caronc/apprise.git
synced 2025-01-07 14:39:51 +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
|
||||
asset = self.asset
|
||||
|
||||
if isinstance(attachments, AttachBase):
|
||||
# Go ahead and just add our attachments into our list
|
||||
self.attachments.append(attachments)
|
||||
return True
|
||||
|
||||
elif isinstance(attachments, str):
|
||||
# Save our path
|
||||
if isinstance(attachments, (AttachBase, str)):
|
||||
# store our instance
|
||||
attachments = (attachments, )
|
||||
|
||||
elif not isinstance(attachments, (tuple, set, list)):
|
||||
|
@ -148,6 +148,9 @@ class AttachBase(URLBase):
|
||||
# Absolute path to attachment
|
||||
self.download_path = None
|
||||
|
||||
# Track open file pointers
|
||||
self.__pointers = set()
|
||||
|
||||
# Set our cache flag; it can be True, False, None, or a (positive)
|
||||
# integer... nothing else
|
||||
if cache is not None:
|
||||
@ -226,15 +229,14 @@ class AttachBase(URLBase):
|
||||
Content is cached once determied to prevent overhead of future
|
||||
calls.
|
||||
"""
|
||||
if not self.exists():
|
||||
# we could not obtain our attachment
|
||||
return None
|
||||
|
||||
if self._mimetype:
|
||||
# return our pre-calculated cached content
|
||||
return self._mimetype
|
||||
|
||||
if not self.exists():
|
||||
# we could not obtain our attachment
|
||||
return None
|
||||
|
||||
if not self.detected_mimetype:
|
||||
# guess_type() returns: (type, encoding) and sets type to None
|
||||
# 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
|
||||
attachment AND the attachment has not expired.
|
||||
"""
|
||||
if self.location == ContentLocation.INACCESSIBLE:
|
||||
# our content is inaccessible
|
||||
return False
|
||||
|
||||
cache = self.template_args['cache']['default'] \
|
||||
if self.cache is None else self.cache
|
||||
@ -295,6 +300,11 @@ class AttachBase(URLBase):
|
||||
- download_path: Must contain a absolute path to content
|
||||
- detected_mimetype: Should identify mimetype of content
|
||||
"""
|
||||
|
||||
# Remove all open pointers
|
||||
while self.__pointers:
|
||||
self.__pointers.pop().close()
|
||||
|
||||
self.detected_name = None
|
||||
self.download_path = None
|
||||
self.detected_mimetype = None
|
||||
@ -314,6 +324,26 @@ class AttachBase(URLBase):
|
||||
raise NotImplementedError(
|
||||
"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
|
||||
def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True):
|
||||
"""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.
|
||||
"""
|
||||
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(
|
||||
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):
|
||||
|
@ -352,7 +352,7 @@ class AttachHTTP(AttachBase):
|
||||
port='' if self.port is None or self.port == default_port
|
||||
else ':{}'.format(self.port),
|
||||
fullpath=self.quote(self.fullpath, safe='/'),
|
||||
params=self.urlencode(params),
|
||||
params=self.urlencode(params, safe='/'),
|
||||
)
|
||||
|
||||
@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 ..url import PrivacyMode
|
||||
from ..attachment.base import AttachBase
|
||||
from ..attachment.memory import AttachMemory
|
||||
|
||||
|
||||
class NtfyMode:
|
||||
@ -172,6 +173,20 @@ class NotifyNtfy(NotifyBase):
|
||||
# Support attachments
|
||||
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
|
||||
image_size = NotifyImageSize.XY_256
|
||||
|
||||
@ -504,7 +519,7 @@ class NotifyNtfy(NotifyBase):
|
||||
# Prepare our Header
|
||||
virt_payload['filename'] = attach.name
|
||||
|
||||
with open(attach.path, 'rb') as fp:
|
||||
with attach as fp:
|
||||
data = fp.read()
|
||||
|
||||
if image_url:
|
||||
@ -538,18 +553,39 @@ class NotifyNtfy(NotifyBase):
|
||||
self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % (
|
||||
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
|
||||
response = None
|
||||
|
||||
if not attach:
|
||||
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:
|
||||
r = requests.post(
|
||||
notify_url,
|
||||
|
@ -1,10 +1,19 @@
|
||||
diff -Naur apprise-1.6.0/test/conftest.py apprise-1.6.0-patched/test/conftest.py
|
||||
--- apprise-1.6.0/test/conftest.py 2023-12-27 11:20:40.000000000 -0500
|
||||
+++ apprise-1.6.0-patched/test/conftest.py 2023-12-27 13:43:22.583100037 -0500
|
||||
@@ -45,8 +45,8 @@
|
||||
diff -Naur apprise-1.8.0/test/conftest.py apprise-1.8.0-patched/test/conftest.py
|
||||
--- apprise-1.8.0/test/conftest.py 2024-06-01 21:28:32.102470720 -0400
|
||||
+++ apprise-1.8.0-patched/test/conftest.py 2024-06-01 21:29:32.363754277 -0400
|
||||
@@ -46,7 +46,7 @@
|
||||
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)
|
||||
-def no_throttling_everywhere(session_mocker):
|
||||
+@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.
|
||||
It is automatically enabled.
|
||||
@@ -57,4 +57,4 @@
|
||||
@@ -68,4 +68,4 @@
|
||||
A_MGR.unload_modules()
|
||||
|
||||
for plugin in N_MGR.plugins():
|
||||
|
@ -30,6 +30,7 @@ import sys
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import mimetypes
|
||||
|
||||
from apprise import NotificationManager
|
||||
from apprise import ConfigurationManager
|
||||
@ -45,6 +46,16 @@ C_MGR = ConfigurationManager()
|
||||
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)
|
||||
def no_throttling_everywhere(session_mocker):
|
||||
"""
|
||||
|
@ -284,9 +284,6 @@ def test_apprise_attachment_truncate(mock_get):
|
||||
# Add ourselves an object set to 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
|
||||
aa = AppriseAttachment()
|
||||
|
||||
@ -304,15 +301,36 @@ def test_apprise_attachment_truncate(mock_get):
|
||||
assert mock_get.call_count == 0
|
||||
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
|
||||
details = mock_get.call_args_list[0]
|
||||
dataset = json.loads(details[1]['data'])
|
||||
assert len(dataset['attachments']) == 1
|
||||
|
||||
# Our second item was not truncated, so all attachments
|
||||
details = mock_get.call_args_list[1]
|
||||
# Reset our object
|
||||
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'])
|
||||
assert len(dataset['attachments']) == 2
|
||||
|
||||
|
@ -73,15 +73,6 @@ def test_attach_base():
|
||||
with pytest.raises(NotImplementedError):
|
||||
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
|
||||
assert AttachBase.parse_url(url='invalid://') is None
|
||||
|
||||
|
@ -204,8 +204,7 @@ def test_attach_file():
|
||||
assert response.path == path
|
||||
assert response.name == 'test.jpeg'
|
||||
assert response.mimetype == 'image/jpeg'
|
||||
# We will match on mime type now (%2F = /)
|
||||
assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I)
|
||||
assert re.search(r'[?&]mime=image/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
|
||||
|
@ -229,7 +229,7 @@ def test_attach_http(mock_get, mock_post):
|
||||
attachment = AttachHTTP(**results)
|
||||
assert isinstance(attachment.url(), str) is True
|
||||
# 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())
|
||||
# No Content-Disposition; so we use filename from path
|
||||
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
|
||||
# POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
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 next(aobj.find(tag='ntfy_invalid')).priority == \
|
||||
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