diff --git a/README.md b/README.md
index 5cbda9a2..c4e4fcfb 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,7 @@ Apprise have some email services built right into it (such as yahoo, fastmail, h
### Custom Notifications
| Post Method | Service ID | Default Port | Example Syntax |
| -------------------- | ---------- | ------------ | -------------- |
+| [Form](https://github.com/caronc/apprise/wiki/Notify_Custom_Form) | form:// or form:// | (TCP) 80 or 443 | form://hostname
form://user@hostname
form://user:password@hostname:port
form://hostname/a/path/to/post/to
| [JSON](https://github.com/caronc/apprise/wiki/Notify_Custom_JSON) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to
| [XML](https://github.com/caronc/apprise/wiki/Notify_Custom_XML) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to
diff --git a/apprise/plugins/NotifyForm.py b/apprise/plugins/NotifyForm.py
new file mode 100644
index 00000000..43463016
--- /dev/null
+++ b/apprise/plugins/NotifyForm.py
@@ -0,0 +1,391 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import six
+import requests
+
+from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
+from ..common import NotifyImageSize
+from ..common import NotifyType
+from ..AppriseLocale import gettext_lazy as _
+
+
+# Defines the method to send the notification
+METHODS = (
+ 'POST',
+ 'GET',
+ 'DELETE',
+ 'PUT',
+ 'HEAD'
+)
+
+
+class NotifyForm(NotifyBase):
+ """
+ A wrapper for Form Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'Form'
+
+ # The default protocol
+ protocol = 'form'
+
+ # The default secure protocol
+ secure_protocol = 'forms'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_Form'
+
+ # Allows the user to specify the NotifyImageSize object
+ image_size = NotifyImageSize.XY_128
+
+ # Disable throttle rate for Form requests since they are normally
+ # local anyway
+ request_rate_per_sec = 0
+
+ # Define object templates
+ templates = (
+ '{schema}://{host}',
+ '{schema}://{host}:{port}',
+ '{schema}://{user}@{host}',
+ '{schema}://{user}@{host}:{port}',
+ '{schema}://{user}:{password}@{host}',
+ '{schema}://{user}:{password}@{host}:{port}',
+ )
+
+ # Define our tokens; these are the minimum tokens required required to
+ # be passed into this function (as arguments). The syntax appends any
+ # previously defined in the base package and builds onto them
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ 'host': {
+ 'name': _('Hostname'),
+ 'type': 'string',
+ 'required': True,
+ },
+ 'port': {
+ 'name': _('Port'),
+ 'type': 'int',
+ 'min': 1,
+ 'max': 65535,
+ },
+ 'user': {
+ 'name': _('Username'),
+ 'type': 'string',
+ },
+ 'password': {
+ 'name': _('Password'),
+ 'type': 'string',
+ 'private': True,
+ },
+
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'method': {
+ 'name': _('Fetch Method'),
+ 'type': 'choice:string',
+ 'values': METHODS,
+ 'default': METHODS[0],
+ },
+ })
+
+ # Define any kwargs we're using
+ template_kwargs = {
+ 'headers': {
+ 'name': _('HTTP Header'),
+ 'prefix': '+',
+ },
+ 'payload': {
+ 'name': _('Payload Extras'),
+ 'prefix': ':',
+ },
+ }
+
+ def __init__(self, headers=None, method=None, payload=None, **kwargs):
+ """
+ Initialize Form Object
+
+ headers can be a dictionary of key/value pairs that you want to
+ additionally include as part of the server headers to post with
+
+ """
+ super(NotifyForm, self).__init__(**kwargs)
+
+ self.fullpath = kwargs.get('fullpath')
+ if not isinstance(self.fullpath, six.string_types):
+ self.fullpath = '/'
+
+ self.method = self.template_args['method']['default'] \
+ if not isinstance(method, six.string_types) else method.upper()
+
+ if self.method not in METHODS:
+ msg = 'The method specified ({}) is invalid.'.format(method)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ self.headers = {}
+ if headers:
+ # Store our extra headers
+ self.headers.update(headers)
+
+ self.payload_extras = {}
+ if payload:
+ # Store our extra payload entries
+ self.payload_extras.update(payload)
+
+ return
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any URL parameters
+ params = {
+ 'method': self.method,
+ }
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+
+ # Append our headers into our parameters
+ params.update({'+{}'.format(k): v for k, v in self.headers.items()})
+
+ # Append our payload extra's into our parameters
+ params.update(
+ {':{}'.format(k): v for k, v in self.payload_extras.items()})
+
+ # Determine Authentication
+ auth = ''
+ if self.user and self.password:
+ auth = '{user}:{password}@'.format(
+ user=NotifyForm.quote(self.user, safe=''),
+ password=self.pprint(
+ self.password, privacy, mode=PrivacyMode.Secret, safe=''),
+ )
+ elif self.user:
+ auth = '{user}@'.format(
+ user=NotifyForm.quote(self.user, safe=''),
+ )
+
+ default_port = 443 if self.secure else 80
+
+ return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format(
+ schema=self.secure_protocol if self.secure else self.protocol,
+ auth=auth,
+ # never encode hostname since we're expecting it to be a valid one
+ hostname=self.host,
+ port='' if self.port is None or self.port == default_port
+ else ':{}'.format(self.port),
+ fullpath=NotifyForm.quote(self.fullpath, safe='/'),
+ params=NotifyForm.urlencode(params),
+ )
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
+ **kwargs):
+ """
+ Perform Form Notification
+ """
+
+ headers = {
+ 'User-Agent': self.app_id,
+ }
+
+ # Apply any/all header over-rides defined
+ headers.update(self.headers)
+
+ # Track our potential attachments
+ files = []
+ if attach:
+ for no, attachment in enumerate(attach, start=1):
+ # Perform some simple error checking
+ if not attachment:
+ # We could not access the attachment
+ self.logger.error(
+ 'Could not access attachment {}.'.format(
+ attachment.url(privacy=True)))
+ return False
+
+ try:
+ files.append((
+ 'file{:02d}'.format(no), (
+ attachment.name,
+ open(attachment.path, 'rb'),
+ attachment.mimetype)
+ ))
+
+ except (OSError, IOError) as e:
+ self.logger.warning(
+ 'An I/O error occurred while opening {}.'.format(
+ attachment.name if attachment else 'attachment'))
+ self.logger.debug('I/O Exception: %s' % str(e))
+ return False
+
+ finally:
+ for file in files:
+ # Ensure all files are closed
+ if file[1][1]:
+ file[1][1].close()
+
+ # prepare Form Object
+ payload = {
+ # Version: Major.Minor, Major is only updated if the entire
+ # schema is changed. If just adding new items (or removing
+ # old ones, only increment the Minor!
+ 'version': '1.0',
+ 'title': title,
+ 'message': body,
+ 'type': notify_type,
+ }
+
+ # Apply any/all payload over-rides defined
+ payload.update(self.payload_extras)
+
+ auth = None
+ if self.user:
+ auth = (self.user, self.password)
+
+ # Set our schema
+ schema = 'https' if self.secure else 'http'
+
+ url = '%s://%s' % (schema, self.host)
+ if isinstance(self.port, int):
+ url += ':%d' % self.port
+
+ url += self.fullpath
+
+ self.logger.debug('Form POST URL: %s (cert_verify=%r)' % (
+ url, self.verify_certificate,
+ ))
+ self.logger.debug('Form Payload: %s' % str(payload))
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ if self.method == 'GET':
+ method = requests.get
+
+ elif self.method == 'PUT':
+ method = requests.put
+
+ elif self.method == 'DELETE':
+ method = requests.delete
+
+ elif self.method == 'HEAD':
+ method = requests.head
+
+ else: # POST
+ method = requests.post
+
+ try:
+ r = method(
+ url,
+ files=None if not files else files,
+ data=payload,
+ headers=headers,
+ auth=auth,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+ if r.status_code != requests.codes.ok:
+ # We had a problem
+ status_str = \
+ NotifyForm.http_response_code_lookup(r.status_code)
+
+ self.logger.warning(
+ 'Failed to send Form %s notification: %s%serror=%s.',
+ self.method,
+ status_str,
+ ', ' if status_str else '',
+ str(r.status_code))
+
+ self.logger.debug('Response Details:\r\n{}'.format(r.content))
+
+ # Return; we're done
+ return False
+
+ else:
+ self.logger.info('Sent Form %s notification.', self.method)
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occurred sending Form '
+ 'notification to %s.' % self.host)
+ self.logger.debug('Socket Exception: %s' % str(e))
+
+ # Return; we're done
+ return False
+
+ except (OSError, IOError) as e:
+ self.logger.warning(
+ 'An I/O error occurred while reading one of the '
+ 'attached files.')
+ self.logger.debug('I/O Exception: %s' % str(e))
+ return False
+
+ finally:
+ for file in files:
+ # Ensure all files are closed
+ file[1][1].close()
+
+ return True
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to re-instantiate this object.
+
+ """
+ results = NotifyBase.parse_url(url)
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ # store any additional payload extra's defined
+ results['payload'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
+ for x, y in results['qsd:'].items()}
+
+ # Add our headers that the user can potentially over-ride if they wish
+ # to to our returned result set
+ results['headers'] = results['qsd+']
+ if results['qsd-']:
+ results['headers'].update(results['qsd-'])
+ NotifyBase.logger.deprecate(
+ "minus (-) based Form header tokens are being "
+ " removed; use the plus (+) symbol instead.")
+
+ # Tidy our header entries by unquoting them
+ results['headers'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
+ for x, y in results['headers'].items()}
+
+ # Set method if not otherwise set
+ if 'method' in results['qsd'] and len(results['qsd']['method']):
+ results['method'] = NotifyForm.unquote(results['qsd']['method'])
+
+ return results
diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py
index c0367033..99adfd9c 100644
--- a/apprise/plugins/NotifyJSON.py
+++ b/apprise/plugins/NotifyJSON.py
@@ -35,6 +35,16 @@ from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
+# Defines the method to send the notification
+METHODS = (
+ 'POST',
+ 'GET',
+ 'DELETE',
+ 'PUT',
+ 'HEAD'
+)
+
+
class NotifyJSON(NotifyBase):
"""
A wrapper for JSON Notifications
@@ -93,6 +103,17 @@ class NotifyJSON(NotifyBase):
'type': 'string',
'private': True,
},
+
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'method': {
+ 'name': _('Fetch Method'),
+ 'type': 'choice:string',
+ 'values': METHODS,
+ 'default': METHODS[0],
+ },
})
# Define any kwargs we're using
@@ -103,7 +124,7 @@ class NotifyJSON(NotifyBase):
},
}
- def __init__(self, headers=None, **kwargs):
+ def __init__(self, headers=None, method=None, **kwargs):
"""
Initialize JSON Object
@@ -117,6 +138,14 @@ class NotifyJSON(NotifyBase):
if not isinstance(self.fullpath, six.string_types):
self.fullpath = '/'
+ self.method = self.template_args['method']['default'] \
+ if not isinstance(method, six.string_types) else method.upper()
+
+ if self.method not in METHODS:
+ msg = 'The method specified ({}) is invalid.'.format(method)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
self.headers = {}
if headers:
# Store our extra headers
@@ -129,8 +158,13 @@ class NotifyJSON(NotifyBase):
Returns the URL built dynamically based on specified arguments.
"""
- # Our URL parameters
- params = self.url_parameters(privacy=privacy, *args, **kwargs)
+ # Define any URL parameters
+ params = {
+ 'method': self.method,
+ }
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Append our headers into our parameters
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
@@ -238,8 +272,23 @@ class NotifyJSON(NotifyBase):
# Always call throttle before any remote server i/o is made
self.throttle()
+ if self.method == 'GET':
+ method = requests.get
+
+ elif self.method == 'PUT':
+ method = requests.put
+
+ elif self.method == 'DELETE':
+ method = requests.delete
+
+ elif self.method == 'HEAD':
+ method = requests.head
+
+ else: # POST
+ method = requests.post
+
try:
- r = requests.post(
+ r = method(
url,
data=dumps(payload),
headers=headers,
@@ -253,11 +302,11 @@ class NotifyJSON(NotifyBase):
NotifyJSON.http_response_code_lookup(r.status_code)
self.logger.warning(
- 'Failed to send JSON notification: '
- '{}{}error={}.'.format(
- status_str,
- ', ' if status_str else '',
- r.status_code))
+ 'Failed to send JSON %s notification: %s%serror=%s.',
+ self.method,
+ status_str,
+ ', ' if status_str else '',
+ str(r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
@@ -265,7 +314,7 @@ class NotifyJSON(NotifyBase):
return False
else:
- self.logger.info('Sent JSON notification.')
+ self.logger.info('Sent JSON %s notification.', self.method)
except requests.RequestException as e:
self.logger.warning(
@@ -303,4 +352,8 @@ class NotifyJSON(NotifyBase):
results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y)
for x, y in results['headers'].items()}
+ # Set method if not otherwise set
+ if 'method' in results['qsd'] and len(results['qsd']['method']):
+ results['method'] = NotifyJSON.unquote(results['qsd']['method'])
+
return results
diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py
index 39438fad..f963b66b 100644
--- a/apprise/plugins/NotifyXML.py
+++ b/apprise/plugins/NotifyXML.py
@@ -35,6 +35,16 @@ from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
+# Defines the method to send the notification
+METHODS = (
+ 'POST',
+ 'GET',
+ 'DELETE',
+ 'PUT',
+ 'HEAD'
+)
+
+
class NotifyXML(NotifyBase):
"""
A wrapper for XML Notifications
@@ -98,6 +108,17 @@ class NotifyXML(NotifyBase):
'type': 'string',
'private': True,
},
+
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'method': {
+ 'name': _('Fetch Method'),
+ 'type': 'choice:string',
+ 'values': METHODS,
+ 'default': METHODS[0],
+ },
})
# Define any kwargs we're using
@@ -108,7 +129,7 @@ class NotifyXML(NotifyBase):
},
}
- def __init__(self, headers=None, **kwargs):
+ def __init__(self, headers=None, method=None, **kwargs):
"""
Initialize XML Object
@@ -138,6 +159,14 @@ class NotifyXML(NotifyBase):
if not isinstance(self.fullpath, six.string_types):
self.fullpath = '/'
+ self.method = self.template_args['method']['default'] \
+ if not isinstance(method, six.string_types) else method.upper()
+
+ if self.method not in METHODS:
+ msg = 'The method specified ({}) is invalid.'.format(method)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
self.headers = {}
if headers:
# Store our extra headers
@@ -150,12 +179,17 @@ class NotifyXML(NotifyBase):
Returns the URL built dynamically based on specified arguments.
"""
- # Store our defined headers into our URL parameters
- params = {'+{}'.format(k): v for k, v in self.headers.items()}
+ # Define any URL parameters
+ params = {
+ 'method': self.method,
+ }
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+ # Append our headers into our parameters
+ params.update({'+{}'.format(k): v for k, v in self.headers.items()})
+
# Determine Authentication
auth = ''
if self.user and self.password:
@@ -277,8 +311,23 @@ class NotifyXML(NotifyBase):
# Always call throttle before any remote server i/o is made
self.throttle()
+ if self.method == 'GET':
+ method = requests.get
+
+ elif self.method == 'PUT':
+ method = requests.put
+
+ elif self.method == 'DELETE':
+ method = requests.delete
+
+ elif self.method == 'HEAD':
+ method = requests.head
+
+ else: # POST
+ method = requests.post
+
try:
- r = requests.post(
+ r = method(
url,
data=payload,
headers=headers,
@@ -292,11 +341,11 @@ class NotifyXML(NotifyBase):
NotifyXML.http_response_code_lookup(r.status_code)
self.logger.warning(
- 'Failed to send XML notification: '
- '{}{}error={}.'.format(
- status_str,
- ', ' if status_str else '',
- r.status_code))
+ 'Failed to send JSON %s notification: %s%serror=%s.',
+ self.method,
+ status_str,
+ ', ' if status_str else '',
+ str(r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
@@ -304,7 +353,7 @@ class NotifyXML(NotifyBase):
return False
else:
- self.logger.info('Sent XML notification.')
+ self.logger.info('Sent XML %s notification.', self.method)
except requests.RequestException as e:
self.logger.warning(
@@ -342,4 +391,8 @@ class NotifyXML(NotifyBase):
results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y)
for x, y in results['headers'].items()}
+ # Set method if not otherwise set
+ if 'method' in results['qsd'] and len(results['qsd']['method']):
+ results['method'] = NotifyXML.unquote(results['qsd']['method'])
+
return results
diff --git a/test/helpers/rest.py b/test/helpers/rest.py
index 6f039d8d..30eb891d 100644
--- a/test/helpers/rest.py
+++ b/test/helpers/rest.py
@@ -298,7 +298,11 @@ class AppriseURLTester(object):
@mock.patch('requests.get')
@mock.patch('requests.post')
- def __notify(self, url, obj, meta, asset, mock_post, mock_get):
+ @mock.patch('requests.head')
+ @mock.patch('requests.put')
+ @mock.patch('requests.delete')
+ def __notify(self, url, obj, meta, asset, mock_del, mock_put, mock_head,
+ mock_post, mock_get):
"""
Perform notification testing against object specified
"""
@@ -344,20 +348,36 @@ class AppriseURLTester(object):
robj.content = u''
mock_get.return_value = robj
mock_post.return_value = robj
+ mock_head.return_value = robj
+ mock_del.return_value = robj
+ mock_put.return_value = robj
if test_requests_exceptions is False:
# Handle our default response
+ mock_put.return_value.status_code = requests_response_code
+ mock_head.return_value.status_code = requests_response_code
+ mock_del.return_value.status_code = requests_response_code
mock_post.return_value.status_code = requests_response_code
mock_get.return_value.status_code = requests_response_code
# Handle our default text response
mock_get.return_value.content = requests_response_text
mock_post.return_value.content = requests_response_text
+ mock_del.return_value.content = requests_response_text
+ mock_put.return_value.content = requests_response_text
+ mock_head.return_value.content = requests_response_text
+
mock_get.return_value.text = requests_response_text
mock_post.return_value.text = requests_response_text
+ mock_put.return_value.text = requests_response_text
+ mock_del.return_value.text = requests_response_text
+ mock_head.return_value.text = requests_response_text
# Ensure there is no side effect set
mock_post.side_effect = None
+ mock_del.side_effect = None
+ mock_put.side_effect = None
+ mock_head.side_effect = None
mock_get.side_effect = None
else:
@@ -457,6 +477,9 @@ class AppriseURLTester(object):
for _exception in self.req_exceptions:
mock_post.side_effect = _exception
+ mock_head.side_effect = _exception
+ mock_del.side_effect = _exception
+ mock_put.side_effect = _exception
mock_get.side_effect = _exception
try:
@@ -498,6 +521,9 @@ class AppriseURLTester(object):
else:
for _exception in self.req_exceptions:
mock_post.side_effect = _exception
+ mock_del.side_effect = _exception
+ mock_put.side_effect = _exception
+ mock_head.side_effect = _exception
mock_get.side_effect = _exception
try:
diff --git a/test/test_plugin_custom_form.py b/test/test_plugin_custom_form.py
new file mode 100644
index 00000000..90a4a160
--- /dev/null
+++ b/test/test_plugin_custom_form.py
@@ -0,0 +1,226 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2021 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+import os
+import sys
+import mock
+import requests
+from apprise import plugins
+from helpers import AppriseURLTester
+from apprise import Apprise
+from apprise import NotifyType
+from apprise import AppriseAttachment
+
+# Disable logging for a cleaner testing output
+import logging
+logging.disable(logging.CRITICAL)
+
+# Attachment Directory
+TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var')
+
+# Our Testing URLs
+apprise_url_tests = (
+ ('form://:@/', {
+ 'instance': None,
+ }),
+ ('form://', {
+ 'instance': None,
+ }),
+ ('forms://', {
+ 'instance': None,
+ }),
+ ('form://localhost', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user@localhost?method=invalid', {
+ 'instance': TypeError,
+ }),
+ ('form://user:pass@localhost', {
+ 'instance': plugins.NotifyForm,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'form://user:****@localhost',
+ }),
+ ('form://user@localhost', {
+ 'instance': plugins.NotifyForm,
+ }),
+
+ # Test method variations
+ ('form://user@localhost?method=put', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user@localhost?method=get', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user@localhost?method=post', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user@localhost?method=head', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user@localhost?method=delete', {
+ 'instance': plugins.NotifyForm,
+ }),
+
+ # Custom payload options
+ ('form://localhost:8080?:key=value&:key2=value2', {
+ 'instance': plugins.NotifyForm,
+ }),
+
+ # Continue testing other cases
+ ('form://localhost:8080', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user:pass@localhost:8080', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('forms://localhost', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('forms://user:pass@localhost', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('forms://localhost:8080/path/', {
+ 'instance': plugins.NotifyForm,
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'forms://localhost:8080/path/',
+ }),
+ ('forms://user:password@localhost:8080', {
+ 'instance': plugins.NotifyForm,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'forms://user:****@localhost:8080',
+ }),
+ ('form://localhost:8080/path?-HeaderKey=HeaderValue', {
+ 'instance': plugins.NotifyForm,
+ }),
+ ('form://user:pass@localhost:8081', {
+ 'instance': plugins.NotifyForm,
+ # force a failure
+ 'response': False,
+ 'requests_response_code': requests.codes.internal_server_error,
+ }),
+ ('form://user:pass@localhost:8082', {
+ 'instance': plugins.NotifyForm,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ }),
+ ('form://user:pass@localhost:8083', {
+ 'instance': plugins.NotifyForm,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+)
+
+
+def test_plugin_custom_form_urls():
+ """
+ NotifyForm() Apprise URLs
+
+ """
+
+ # Run our general tests
+ AppriseURLTester(tests=apprise_url_tests).run_all()
+
+
+@mock.patch('requests.post')
+def test_plugin_custom_form_attachments(mock_post):
+ """
+ NotifyForm() Attachments
+
+ """
+ # Disable Throttling to speed testing
+ plugins.NotifyBase.request_rate_per_sec = 0
+
+ okay_response = requests.Request()
+ okay_response.status_code = requests.codes.ok
+ okay_response.content = ""
+
+ # Assign our mock object our return value
+ mock_post.return_value = okay_response
+
+ obj = Apprise.instantiate(
+ 'form://user@localhost.localdomain/?method=post')
+ assert isinstance(obj, plugins.NotifyForm)
+
+ # Test Valid Attachment
+ path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
+ attach = AppriseAttachment(path)
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is True
+
+ # Test invalid attachment
+ path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg')
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=path) is False
+
+ mock_post.return_value = None
+ mock_post.side_effect = OSError()
+ # We can't send the message if we can't read the attachment
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
+
+ # Get a appropriate "builtin" module name for pythons 2/3.
+ if sys.version_info.major >= 3:
+ builtin_open_function = 'builtins.open'
+
+ else:
+ builtin_open_function = '__builtin__.open'
+
+ # Test Valid Attachment (load 3)
+ path = (
+ os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+ os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+ os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+ )
+ attach = AppriseAttachment(path)
+
+ # Return our good configuration
+ mock_post.side_effect = None
+ mock_post.return_value = okay_response
+ with mock.patch(builtin_open_function, side_effect=OSError()):
+ # We can't send the message we can't open the attachment for reading
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
+
+ # Fail on the 2nd attempt (but not the first)
+ with mock.patch(builtin_open_function,
+ side_effect=[None, OSError(), None]):
+ # We can't send the message we can't open the attachment for reading
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
+
+ # Test file exception handling when performing post
+ mock_post.return_value = None
+ mock_post.side_effect = OSError()
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
diff --git a/test/test_plugin_custom_json.py b/test/test_plugin_custom_json.py
index fcd94d65..c9824285 100644
--- a/test/test_plugin_custom_json.py
+++ b/test/test_plugin_custom_json.py
@@ -44,6 +44,9 @@ apprise_url_tests = (
('json://localhost', {
'instance': plugins.NotifyJSON,
}),
+ ('json://user@localhost?method=invalid', {
+ 'instance': TypeError,
+ }),
('json://user:pass@localhost', {
'instance': plugins.NotifyJSON,
@@ -53,6 +56,25 @@ apprise_url_tests = (
('json://user@localhost', {
'instance': plugins.NotifyJSON,
}),
+
+ # Test method variations
+ ('json://user@localhost?method=put', {
+ 'instance': plugins.NotifyJSON,
+ }),
+ ('json://user@localhost?method=get', {
+ 'instance': plugins.NotifyJSON,
+ }),
+ ('json://user@localhost?method=post', {
+ 'instance': plugins.NotifyJSON,
+ }),
+ ('json://user@localhost?method=head', {
+ 'instance': plugins.NotifyJSON,
+ }),
+ ('json://user@localhost?method=delete', {
+ 'instance': plugins.NotifyJSON,
+ }),
+
+ # Continue testing other cases
('json://localhost:8080', {
'instance': plugins.NotifyJSON,
}),
diff --git a/test/test_plugin_custom_xml.py b/test/test_plugin_custom_xml.py
index 237ffd61..86f8914f 100644
--- a/test/test_plugin_custom_xml.py
+++ b/test/test_plugin_custom_xml.py
@@ -47,12 +47,34 @@ apprise_url_tests = (
('xml://user@localhost', {
'instance': plugins.NotifyXML,
}),
+ ('xml://user@localhost?method=invalid', {
+ 'instance': TypeError,
+ }),
('xml://user:pass@localhost', {
'instance': plugins.NotifyXML,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'xml://user:****@localhost',
}),
+
+ # Test method variations
+ ('xml://user@localhost?method=put', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xml://user@localhost?method=get', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xml://user@localhost?method=post', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xml://user@localhost?method=head', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xml://user@localhost?method=delete', {
+ 'instance': plugins.NotifyXML,
+ }),
+
+ # Continue testing other cases
('xml://localhost:8080', {
'instance': plugins.NotifyXML,
}),
@@ -64,10 +86,26 @@ apprise_url_tests = (
}),
('xmls://user:pass@localhost', {
'instance': plugins.NotifyXML,
-
+ }),
+ # Continue testing other cases
+ ('xml://localhost:8080', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xml://user:pass@localhost:8080', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xml://localhost', {
+ 'instance': plugins.NotifyXML,
+ }),
+ ('xmls://user:pass@localhost', {
+ 'instance': plugins.NotifyXML,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'xmls://user:****@localhost',
}),
+ ('xml://user@localhost:8080/path/', {
+ 'instance': plugins.NotifyXML,
+ 'privacy_url': 'xml://user@localhost:8080/path',
+ }),
('xmls://localhost:8080/path/', {
'instance': plugins.NotifyXML,
# Our expected url(privacy=True) startswith() response: