Refactored code base; modernized with pyproject.toml (#255)

This commit is contained in:
Chris Caron
2025-08-05 16:53:42 -04:00
committed by GitHub
parent d1108216a0
commit c571bb0671
54 changed files with 3689 additions and 3066 deletions

View File

@@ -1,24 +0,0 @@
[run]
data_file = .coverage-reports/.coverage
parallel = False
concurrency = multiprocessing
include = apprise_api
omit =
*apps.py,
*/migrations/*,
*/core/settings/*,
*/*/tests/*,
lib/*,
lib64/*,
*urls.py,
*/core/wsgi.py,
gunicorn.conf.py,
*/manage.py
disable_warnings = no-data-collected
[report]
show_missing = True
skip_covered = True
skip_empty = True
fail_under = 75.0

View File

@@ -1,9 +1,9 @@
## Description:
**Related issue (if applicable):** refs #<!--apprise issue number goes here-->
**Related issue (if applicable):** refs #<!--apprise-api issue number goes here-->
## Checklist
<!-- The following must be completed or your PR can't be merged -->
* [ ] The code change is tested and works locally.
* [ ] There is no commented out code in this PR.
* [ ] No lint errors (use `flake8`)
* [ ] No lint errors (use `ruff`)
* [ ] Tests added

View File

@@ -1,71 +1,55 @@
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# 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.
name: Tests
on:
# On which repository actions to trigger the build.
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Allow job to be triggered manually.
workflow_dispatch:
# Cancel in-progress jobs when pushing to the same branch.
concurrency:
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
jobs:
tests:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
# Run all jobs to completion (false), or cancel
# all jobs once the first one fails (true).
fail-fast: true
# Define a minimal test matrix, it will be
# expanded using subsequent `include` items.
matrix:
os: ["ubuntu-latest"]
python-version: ["3.12"]
bare: [false]
defaults:
run:
shell: bash
env:
OS: ${{ matrix.os }}
PYTHON: ${{ matrix.python-version }}
BARE: ${{ matrix.bare }}
name: Python ${{ matrix.python-version }} on ${{ matrix.os }} ${{ matrix.bare && '(bare)' || '' }}
steps:
- name: Acquire sources
uses: actions/checkout@v4
- name: Install prerequisites (Linux)
if: runner.os == 'Linux'
- name: Install prerequisites
run: |
sudo apt-get update
- name: Install project dependencies (Baseline)
- name: Install dependencies
run: |
pip install -r requirements.txt -r dev-requirements.txt
# For saving resources, code style checking is
# only invoked within the `bare` environment.
- name: Check code style
if: matrix.bare == true
- name: Lint with Ruff
run: |
flake8 apprise_api --count --show-source --statistics
ruff check apprise_api
- name: Run tests
- name: Run tests with coverage
run: |
coverage run -m pytest apprise_api
@@ -74,9 +58,10 @@ jobs:
coverage xml
coverage report
- name: Upload coverage data
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage.xml
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ dist/
downloads/
eggs/
.eggs/
.local/
lib64
lib
var/

View File

@@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise-API - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, 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:

View File

@@ -616,33 +616,67 @@ spec:
```
## Development Environment
The following should get you a working development environment to test with:
The following should get you a working development environment (min requirements are Python v3.12) to test with.
### Setup
```bash
# Create a virtual environment in the same directory you
# cloned this repository to:
python -m venv .
# Create and activate a Python 3.12 virtual environment:
python3.12 -m venv .venv
. .venv/bin/activate
# Activate it now:
. ./bin/activate
# install dependencies
pip install -r dev-requirements.txt -r requirements.txt
# Run a dev server (debug mode) accessible from your browser at:
# -> http://localhost:8000/
./manage.py runserver
# Install core dependencies:
pip install -e '.[dev]'
```
Some other useful development notes:
### Running the Dev Server
```bash
# Check for any lint errors
flake8 apprise_api
# Start the development server in debug mode:
./manage.py runserver
# Then visit: http://localhost:8000/
```
Or use Docker:
```bash
# It's this simple:
docker compose up
```
### Quality Assurance and Testing (via Tox)
The project uses `tox` to manage linting, testing, and formatting in a reproducible way.
```bash
# Run unit tests
tox -e test
# Test structure; calls ruff under the hood
tox -e lint
```
**Note**: You can combine environments, e.g.:
```bash
tox -e test,lint
```
Automatically format your code if possible to pass linting after changes:
```bash
tox -e format
```
### Manual Tools (optional)
The following also works assuming you have provided all development dependencies (`pip install .[dev]`)
```bash
# Run unit tests manually (if needed)
pytest apprise_api
# Lint code with Ruff
ruff check .
# Format code with Ruff
ruff format .
```
## Apprise Integration
@@ -790,4 +824,6 @@ The colon `:` prefix is the switch that starts the re-mapping rule engine. You
## Metrics Collection & Analysis
Basic Prometheus support added through `/metrics` reference point.

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -26,4 +25,4 @@ from django.apps import AppConfig
class ApiConfig(AppConfig):
name = 'api'
name = "api"

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,49 +21,50 @@
# 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.
from .utils import gen_unique_config_id
from .utils import ConfigCache
from django.conf import settings
import apprise
from .utils import ConfigCache, gen_unique_config_id
def stateful_mode(request):
"""
Returns our loaded Stateful Mode
"""
return {'STATEFUL_MODE': ConfigCache.mode}
return {"STATEFUL_MODE": ConfigCache.mode}
def config_lock(request):
"""
Returns the state of our global configuration lock
"""
return {'CONFIG_LOCK': settings.APPRISE_CONFIG_LOCK}
return {"CONFIG_LOCK": settings.APPRISE_CONFIG_LOCK}
def admin_enabled(request):
"""
Returns whether we allow the config list to be displayed
"""
return {'APPRISE_ADMIN': settings.APPRISE_ADMIN}
return {"APPRISE_ADMIN": settings.APPRISE_ADMIN}
def apprise_version(request):
"""
Returns the current version of apprise loaded under the hood
"""
return {'APPRISE_VERSION': apprise.__version__}
return {"APPRISE_VERSION": apprise.__version__}
def default_config_id(request):
"""
Returns a unique config identifier
"""
return {'DEFAULT_CONFIG_ID': request.default_config_id}
return {"DEFAULT_CONFIG_ID": request.default_config_id}
def unique_config_id(request):
"""
Returns a unique config identifier
"""
return {'UNIQUE_CONFIG_ID': gen_unique_config_id()}
return {"UNIQUE_CONFIG_ID": gen_unique_config_id()}

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -23,39 +22,39 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import apprise
from django import forms
from django.utils.translation import gettext_lazy as _
import apprise
# Auto-Detect Keyword
AUTO_DETECT_CONFIG_KEYWORD = 'auto'
AUTO_DETECT_CONFIG_KEYWORD = "auto"
# Define our potential configuration types
CONFIG_FORMATS = (
(AUTO_DETECT_CONFIG_KEYWORD, _('Auto-Detect')),
(apprise.ConfigFormat.TEXT, _('TEXT')),
(apprise.ConfigFormat.YAML, _('YAML')),
(AUTO_DETECT_CONFIG_KEYWORD, _("Auto-Detect")),
(apprise.ConfigFormat.TEXT.value, _("TEXT")),
(apprise.ConfigFormat.YAML.value, _("YAML")),
)
NOTIFICATION_TYPES = (
(apprise.NotifyType.INFO, _('Info')),
(apprise.NotifyType.SUCCESS, _('Success')),
(apprise.NotifyType.WARNING, _('Warning')),
(apprise.NotifyType.FAILURE, _('Failure')),
(apprise.NotifyType.INFO.value, _("Info")),
(apprise.NotifyType.SUCCESS.value, _("Success")),
(apprise.NotifyType.WARNING.value, _("Warning")),
(apprise.NotifyType.FAILURE.value, _("Failure")),
)
# Define our potential input text categories
INPUT_FORMATS = (
(apprise.NotifyFormat.TEXT, _('TEXT')),
(apprise.NotifyFormat.MARKDOWN, _('MARKDOWN')),
(apprise.NotifyFormat.HTML, _('HTML')),
(apprise.NotifyFormat.TEXT.value, _("TEXT")),
(apprise.NotifyFormat.MARKDOWN.value, _("MARKDOWN")),
(apprise.NotifyFormat.HTML.value, _("HTML")),
# As-is - do not interpret it
(None, _('IGNORE')),
(None, _("IGNORE")),
)
URLS_MAX_LEN = 1024
URLS_PLACEHOLDER = 'mailto://user:pass@domain.com, ' \
'slack://tokena/tokenb/tokenc, ...'
URLS_PLACEHOLDER = "mailto://user:pass@domain.com, slack://tokena/tokenb/tokenc, ..."
class AddByUrlForm(forms.Form):
@@ -66,9 +65,10 @@ class AddByUrlForm(forms.Form):
This content can just be directly fed straight into Apprise
"""
urls = forms.CharField(
label=_('URLs'),
widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}),
label=_("URLs"),
widget=forms.TextInput(attrs={"placeholder": URLS_PLACEHOLDER}),
max_length=URLS_MAX_LEN,
)
@@ -80,14 +80,14 @@ class AddByConfigForm(forms.Form):
"""
format = forms.ChoiceField(
label=_('Format'),
label=_("Format"),
choices=CONFIG_FORMATS,
initial=CONFIG_FORMATS[0][0],
required=False,
)
config = forms.CharField(
label=_('Configuration'),
label=_("Configuration"),
widget=forms.Textarea(),
max_length=4096,
required=False,
@@ -97,7 +97,7 @@ class AddByConfigForm(forms.Form):
"""
We just ensure there is a format always set and it defaults to auto
"""
data = self.cleaned_data['format']
data = self.cleaned_data["format"]
if not data:
# Set to auto
data = CONFIG_FORMATS[0][0]
@@ -111,44 +111,42 @@ class NotifyForm(forms.Form):
"""
format = forms.ChoiceField(
label=_('Process As'),
label=_("Process As"),
initial=INPUT_FORMATS[0][0],
choices=INPUT_FORMATS,
required=False,
)
type = forms.ChoiceField(
label=_('Type'),
label=_("Type"),
choices=NOTIFICATION_TYPES,
initial=NOTIFICATION_TYPES[0][0],
required=False,
)
title = forms.CharField(
label=_('Title'),
widget=forms.TextInput(attrs={'placeholder': _('Optional Title')}),
label=_("Title"),
widget=forms.TextInput(attrs={"placeholder": _("Optional Title")}),
max_length=apprise.NotifyBase.title_maxlen,
required=False,
)
body = forms.CharField(
label=_('Body'),
widget=forms.Textarea(
attrs={'placeholder': _('Define your message body here...')}),
label=_("Body"),
widget=forms.Textarea(attrs={"placeholder": _("Define your message body here...")}),
max_length=apprise.NotifyBase.body_maxlen,
required=False,
)
# Attachment Support
attachment = forms.FileField(
label=_('Attachment'),
label=_("Attachment"),
required=False,
)
tag = forms.CharField(
label=_('Tags'),
widget=forms.TextInput(
attrs={'placeholder': _('Optional_Tag1, Optional_Tag2, ...')}),
label=_("Tags"),
widget=forms.TextInput(attrs={"placeholder": _("Optional_Tag1, Optional_Tag2, ...")}),
required=False,
)
@@ -156,7 +154,7 @@ class NotifyForm(forms.Form):
# always take priority over this however adding `tags` gives the user more
# flexibilty to use either/or keyword
tags = forms.CharField(
label=_('Tags'),
label=_("Tags"),
widget=forms.HiddenInput(),
required=False,
)
@@ -165,20 +163,20 @@ class NotifyForm(forms.Form):
"""
We just ensure there is a type always set
"""
data = self.cleaned_data['type']
data = self.cleaned_data["type"]
if not data:
# Always set a type
data = apprise.NotifyType.INFO
data = apprise.NotifyType.INFO.value
return data
def clean_format(self):
"""
We just ensure there is a format always set
"""
data = self.cleaned_data['format']
data = self.cleaned_data["format"]
if not data:
# Always set a type
data = apprise.NotifyFormat.TEXT
data = apprise.NotifyFormat.TEXT.value
return data
@@ -187,9 +185,10 @@ class NotifyByUrlForm(NotifyForm):
Same as the NotifyForm but additionally processes a string of URLs to
notify directly.
"""
urls = forms.CharField(
label=_('URLs'),
widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}),
label=_("URLs"),
widget=forms.TextInput(attrs={"placeholder": URLS_PLACEHOLDER}),
max_length=URLS_MAX_LEN,
required=False,
)

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -23,8 +22,9 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.core.management.base import BaseCommand
from django.conf import settings
from django.core.management.base import BaseCommand
import apprise
@@ -32,14 +32,18 @@ class Command(BaseCommand):
help = f"Prune all persistent content older then {settings.APPRISE_STORAGE_PRUNE_DAYS} days()"
def add_arguments(self, parser):
parser.add_argument("-d", "--days", type=int, default=settings.APPRISE_STORAGE_PRUNE_DAYS)
parser.add_argument(
"-d",
"--days",
type=int,
default=settings.APPRISE_STORAGE_PRUNE_DAYS,
)
def handle(self, *args, **options):
# Persistent Storage cleanup
apprise.PersistentStore.disk_prune(
path=settings.APPRISE_STORAGE_DIR,
expires=options["days"] * 86400, action=True,
)
self.stdout.write(
self.style.SUCCESS('Successfully pruned persistent storeage (days: %d)' % options["days"])
expires=options["days"] * 86400,
action=True,
)
self.stdout.write(self.style.SUCCESS(f"Successfully pruned persistent storeage (days: {options['days']})"))

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,13 +21,13 @@
# 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.
from api.forms import NotifyForm
# import the logging library
import logging
from api.forms import NotifyForm
# Get an instance of a logger
logger = logging.getLogger('django')
logger = logging.getLogger("django")
def remap_fields(rules, payload, form=None):
@@ -54,7 +53,6 @@ def remap_fields(rules, payload, form=None):
# First generate our expected keys; only these can be mapped
expected_keys = set(form.fields.keys())
for _key, value in rules.items():
key = _key.lower()
if key in payload and not value:
# Remove element

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,24 +21,26 @@
# 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.
from django.test import SimpleTestCase
from django.core.exceptions import RequestDataTooBig
from apprise import ConfigFormat
from unittest.mock import patch
from unittest import mock
from django.test.utils import override_settings
from ..forms import AUTO_DETECT_CONFIG_KEYWORD
import json
import hashlib
import json
from unittest import mock
from unittest.mock import patch
from django.core.exceptions import RequestDataTooBig
from django.test import SimpleTestCase
from django.test.utils import override_settings
from apprise import ConfigFormat
from ..forms import AUTO_DETECT_CONFIG_KEYWORD
class AddTests(SimpleTestCase):
def test_add_invalid_key_status_code(self):
"""
Test GET requests to invalid key
"""
response = self.client.get('/add/**invalid-key**')
response = self.client.get("/add/**invalid-key**")
assert response.status_code == 404
def test_key_lengths(self):
@@ -49,22 +50,22 @@ class AddTests(SimpleTestCase):
# our key to use
h = hashlib.sha512()
h.update(b'string')
h.update(b"string")
key = h.hexdigest()
# Our limit
assert len(key) == 128
# Add our URL
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 200
# However adding just 1 more character exceeds our limit and the save
# will fail
response = self.client.post(
'/add/{}'.format(key + 'x'),
{'urls': 'mailto://user:pass@yahoo.ca'})
"/add/{}".format(key + "x"),
{"urls": "mailto://user:pass@yahoo.ca"},
)
assert response.status_code == 404
@override_settings(APPRISE_CONFIG_LOCK=True)
@@ -73,11 +74,10 @@ class AddTests(SimpleTestCase):
Test adding a configuration by URLs with lock set won't work
"""
# our key to use
key = 'test_save_config_by_urls_with_lock'
key = "test_save_config_by_urls_with_lock"
# We simply do not have permission to do so
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 403
def test_save_config_by_urls(self):
@@ -86,68 +86,68 @@ class AddTests(SimpleTestCase):
"""
# our key to use
key = 'test_save_config_by_urls'
key = "test_save_config_by_urls"
# GET returns 405 (not allowed)
response = self.client.get('/add/{}'.format(key))
response = self.client.get("/add/{}".format(key))
assert response.status_code == 405
# no data
response = self.client.post('/add/{}'.format(key))
response = self.client.post("/add/{}".format(key))
assert response.status_code == 400
# No entries specified
response = self.client.post(
'/add/{}'.format(key), {'urls': ''})
response = self.client.post("/add/{}".format(key), {"urls": ""})
assert response.status_code == 400
# Added successfully
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 200
# No URLs loaded
response = self.client.post(
'/add/{}'.format(key),
{'config': 'invalid content', 'format': 'text'})
"/add/{}".format(key),
{"config": "invalid content", "format": "text"},
)
assert response.status_code == 400
# Test a case where we fail to load a valid configuration file
with patch('apprise.AppriseConfig.add', return_value=False):
with patch("apprise.AppriseConfig.add", return_value=False):
response = self.client.post(
'/add/{}'.format(key),
{'config': 'garbage://', 'format': 'text'})
"/add/{}".format(key),
{"config": "garbage://", "format": "text"},
)
assert response.status_code == 400
with patch('os.remove', side_effect=OSError):
with patch("os.remove", side_effect=OSError):
# We will fail to remove the device first prior to placing a new
# one; This will result in a 500 error
response = self.client.post(
'/add/{}'.format(key), {
'urls': 'mailto://user:newpass@gmail.com'})
"/add/{}".format(key),
{"urls": "mailto://user:newpass@gmail.com"},
)
assert response.status_code == 500
# URL is actually not a valid one (invalid Slack tokens specified
# below)
response = self.client.post(
'/add/{}'.format(key), {'urls': 'slack://-/-/-'})
response = self.client.post("/add/{}".format(key), {"urls": "slack://-/-/-"})
assert response.status_code == 400
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
content_type="application/json",
)
assert response.status_code == 200
with mock.patch('json.loads') as mock_loads:
with mock.patch("json.loads") as mock_loads:
mock_loads.side_effect = RequestDataTooBig()
# Send our notification by specifying the tag in the parameters
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
content_type="application/json",
)
# Our notification failed
@@ -155,49 +155,49 @@ class AddTests(SimpleTestCase):
# Test with JSON (and no payload provided)
response = self.client.post(
'/add/{}'.format(key),
"/add/{}".format(key),
data=json.dumps({}),
content_type='application/json',
content_type="application/json",
)
assert response.status_code == 400
# Test with XML which simply isn't supported
response = self.client.post(
'/add/{}'.format(key),
data='<urls><url>mailto://user:pass@yahoo.ca</url></urls>',
content_type='application/xml',
"/add/{}".format(key),
data="<urls><url>mailto://user:pass@yahoo.ca</url></urls>",
content_type="application/xml",
)
assert response.status_code == 400
# Invalid JSON
response = self.client.post(
'/add/{}'.format(key),
data='{',
content_type='application/json',
"/add/{}".format(key),
data="{",
content_type="application/json",
)
assert response.status_code == 400
# Test the handling of underlining disk/write exceptions
with patch('os.makedirs') as mock_mkdirs:
with patch("os.makedirs") as mock_mkdirs:
mock_mkdirs.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
content_type="application/json",
)
# internal errors are correctly identified
assert response.status_code == 500
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
with patch("gzip.open") as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'urls': 'mailto://user:pass@yahoo.ca'}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"urls": "mailto://user:pass@yahoo.ca"}),
content_type="application/json",
)
# internal errors are correctly identified
@@ -209,15 +209,16 @@ class AddTests(SimpleTestCase):
"""
# our key to use
key = 'test_save_config_by_config'
key = "test_save_config_by_config"
# Empty Text Configuration
config = """
""" # noqa W293
"""
response = self.client.post(
'/add/{}'.format(key), {
'format': ConfigFormat.TEXT, 'config': config})
"/add/{}".format(key),
{"format": ConfigFormat.TEXT.value, "config": config},
)
assert response.status_code == 400
# Valid Text Configuration
@@ -226,15 +227,16 @@ class AddTests(SimpleTestCase):
home=mailto://user:pass@hotmail.com
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': ConfigFormat.TEXT, 'config': config})
"/add/{}".format(key),
{"format": ConfigFormat.TEXT.value, "config": config},
)
assert response.status_code == 200
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": ConfigFormat.TEXT.value, "config": config}),
content_type="application/json",
)
assert response.status_code == 200
@@ -247,35 +249,35 @@ class AddTests(SimpleTestCase):
tag: home
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': ConfigFormat.YAML, 'config': config})
"/add/{}".format(key),
{"format": ConfigFormat.YAML.value, "config": config},
)
assert response.status_code == 200
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'format': ConfigFormat.YAML, 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": ConfigFormat.YAML.value, "config": config}),
content_type="application/json",
)
assert response.status_code == 200
# Test invalid config format
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'format': 'INVALID', 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": "INVALID", "config": config}),
content_type="application/json",
)
assert response.status_code == 400
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_open:
with patch("gzip.open") as mock_open:
mock_open.side_effect = OSError()
# We'll fail to write our key now
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps(
{'format': ConfigFormat.YAML, 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": ConfigFormat.YAML.value, "config": config}),
content_type="application/json",
)
# internal errors are correctly identified
@@ -287,15 +289,16 @@ class AddTests(SimpleTestCase):
"""
# our key to use
key = 'test_save_auto_detect_config_format'
key = "test_save_auto_detect_config_format"
# Empty Text Configuration
config = """
""" # noqa W293
"""
response = self.client.post(
'/add/{}'.format(key), {
'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
"/add/{}".format(key),
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
)
assert response.status_code == 400
# Valid Text Configuration
@@ -304,15 +307,16 @@ class AddTests(SimpleTestCase):
home=mailto://user:pass@hotmail.com
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
"/add/{}".format(key),
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
)
assert response.status_code == 200
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'format': ConfigFormat.TEXT, 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": ConfigFormat.TEXT.value, "config": config}),
content_type="application/json",
)
assert response.status_code == 200
@@ -326,16 +330,16 @@ class AddTests(SimpleTestCase):
tag: home
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
"/add/{}".format(key),
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
)
assert response.status_code == 200
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps(
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}),
content_type="application/json",
)
assert response.status_code == 200
@@ -344,16 +348,16 @@ class AddTests(SimpleTestCase):
42
"""
response = self.client.post(
'/add/{}'.format(key),
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config})
"/add/{}".format(key),
{"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config},
)
assert response.status_code == 400
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps(
{'format': AUTO_DETECT_CONFIG_KEYWORD, 'config': config}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"format": AUTO_DETECT_CONFIG_KEYWORD, "config": config}),
content_type="application/json",
)
assert response.status_code == 400
@@ -363,11 +367,11 @@ class AddTests(SimpleTestCase):
"""
# our key to use
key = 'test_save_with_bad_input'
key = "test_save_with_bad_input"
# Test with JSON
response = self.client.post(
'/add/{}'.format(key),
data=json.dumps({'garbage': 'input'}),
content_type='application/json',
"/add/{}".format(key),
data=json.dumps({"garbage": "input"}),
content_type="application/json",
)
assert response.status_code == 400

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,24 +21,25 @@
# 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 requests
from django.test import SimpleTestCase
from unittest import mock
from unittest.mock import mock_open
from ..utils import Attachment, HTTPAttachment
from ..utils import parse_attachments
from ..urlfilter import AppriseURLFilter
from .. import utils
from django.test.utils import override_settings
from django.conf import settings
from tempfile import TemporaryDirectory
from shutil import rmtree
import base64
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from os.path import dirname, join, getsize
from contextlib import suppress
from os.path import dirname, getsize, join
from shutil import rmtree
from tempfile import TemporaryDirectory
from unittest import mock
from unittest.mock import mock_open, patch
SAMPLE_FILE = join(dirname(dirname(dirname(__file__))), 'static', 'logo.png')
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase
from django.test.utils import override_settings
import requests
from .. import utils
from ..urlfilter import AppriseURLFilter
from ..utils import Attachment, HTTPAttachment, parse_attachments
SAMPLE_FILE = join(dirname(dirname(dirname(__file__))), "static", "logo.png")
class AttachmentTests(SimpleTestCase):
@@ -48,12 +48,9 @@ class AttachmentTests(SimpleTestCase):
self.tmp_dir = TemporaryDirectory()
def tearDown(self):
# Clear directory
try:
# Clear content if possible
with suppress(FileNotFoundError):
rmtree(self.tmp_dir.name)
except FileNotFoundError:
# no worries
pass
self.tmp_dir = None
@@ -63,32 +60,32 @@ class AttachmentTests(SimpleTestCase):
"""
with override_settings(APPRISE_ATTACH_DIR=self.tmp_dir.name):
with mock.patch('os.makedirs', side_effect=OSError):
with mock.patch("os.makedirs", side_effect=OSError):
with self.assertRaises(ValueError):
Attachment('file')
Attachment("file")
with self.assertRaises(ValueError):
HTTPAttachment('web')
HTTPAttachment("web")
with mock.patch('tempfile.mkstemp', side_effect=FileNotFoundError):
with mock.patch("tempfile.mkstemp", side_effect=FileNotFoundError):
with self.assertRaises(ValueError):
Attachment('file')
Attachment("file")
with self.assertRaises(ValueError):
HTTPAttachment('web')
HTTPAttachment("web")
with mock.patch('os.remove', side_effect=FileNotFoundError):
a = Attachment('file')
with mock.patch("os.remove", side_effect=FileNotFoundError):
a = Attachment("file")
# Force __del__ call to throw an exception which we gracefully
# handle
del a
a = HTTPAttachment('web')
a._path = 'abcd'
assert a.filename == 'web'
a = HTTPAttachment("web")
a._path = "abcd"
assert a.filename == "web"
# Force __del__ call to throw an exception which we gracefully
# handle
del a
a = Attachment('file')
a = Attachment("file")
assert a.filename
def test_form_file_attachment_parsing(self):
@@ -114,19 +111,13 @@ class AttachmentTests(SimpleTestCase):
assert len(result) == 0
# Get ourselves a file to work with
files_request = {
'file1': SimpleUploadedFile(
"attach.txt", b"content here", content_type="text/plain")
}
files_request = {"file1": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")}
result = parse_attachments(None, files_request)
assert isinstance(result, list)
assert len(result) == 1
# Test case where no filename was specified
files_request = {
'file1': SimpleUploadedFile(
" ", b"content here", content_type="text/plain")
}
files_request = {"file1": SimpleUploadedFile(" ", b"content here", content_type="text/plain")}
result = parse_attachments(None, files_request)
assert isinstance(result, list)
assert len(result) == 1
@@ -135,19 +126,19 @@ class AttachmentTests(SimpleTestCase):
# attachment to disk
m = mock_open()
m.side_effect = OSError()
with patch('builtins.open', m):
with self.assertRaises(ValueError):
parse_attachments(None, files_request)
with patch("builtins.open", m), self.assertRaises(ValueError):
parse_attachments(None, files_request)
# Test a case where our attachment exceeds the maximum size we allow
# for
with override_settings(APPRISE_ATTACH_SIZE=1):
files_request = {
'file1': SimpleUploadedFile(
"file1": SimpleUploadedFile(
"attach.txt",
# More then 1 MB in size causing error to trip
("content" * 1024 * 1024).encode('utf-8'),
content_type="text/plain")
("content" * 1024 * 1024).encode("utf-8"),
content_type="text/plain",
)
}
with self.assertRaises(ValueError):
parse_attachments(None, files_request)
@@ -155,24 +146,22 @@ class AttachmentTests(SimpleTestCase):
# Test Attachment Size seto t zer0
with override_settings(APPRISE_ATTACH_SIZE=0):
files_request = {
'file1': SimpleUploadedFile(
"file1": SimpleUploadedFile(
"attach.txt",
# More then 1 MB in size causing error to trip
("content" * 1024 * 1024).encode('utf-8'),
content_type="text/plain")
("content" * 1024 * 1024).encode("utf-8"),
content_type="text/plain",
)
}
with self.assertRaises(ValueError):
parse_attachments(None, files_request)
# Bad data provided in filename field
files_request = {
'file1': SimpleUploadedFile(
None, b"content here", content_type="text/plain")
}
files_request = {"file1": SimpleUploadedFile(None, b"content here", content_type="text/plain")}
with self.assertRaises(ValueError):
parse_attachments(None, files_request)
@patch('requests.get')
@patch("requests.get")
def test_direct_attachment_parsing(self, mock_get):
"""
Test the parsing of file attachments
@@ -187,33 +176,33 @@ class AttachmentTests(SimpleTestCase):
response.status_code = requests.codes.ok
response.raise_for_status.return_value = True
response.headers = {
'Content-Length': getsize(SAMPLE_FILE),
"Content-Length": getsize(SAMPLE_FILE),
}
ref = {
'io': None,
"io": None,
}
def iter_content(chunk_size=1024, *args, **kwargs):
if not ref['io']:
ref['io'] = open(SAMPLE_FILE, 'rb')
block = ref['io'].read(chunk_size)
if not ref["io"]:
ref["io"] = open(SAMPLE_FILE, "rb") # noqa: SIM115
block = ref["io"].read(chunk_size)
if not block:
# Close for re-use
ref['io'].close()
ref['io'] = None
ref["io"].close()
ref["io"] = None
yield block
response.iter_content = iter_content
def test(*args, **kwargs):
return response
response.__enter__ = test
response.__exit__ = test
mock_get.return_value = response
# Support base64 encoding
attachment_payload = {
'base64': base64.b64encode(b'data to be encoded').decode('utf-8')
}
attachment_payload = {"base64": base64.b64encode(b"data to be encoded").decode("utf-8")}
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
assert len(result) == 1
@@ -221,15 +210,14 @@ class AttachmentTests(SimpleTestCase):
# Support multi entries
attachment_payload = [
{
'base64': base64.b64encode(
b'data to be encoded 1').decode('utf-8'),
}, {
'base64': base64.b64encode(
b'data to be encoded 2').decode('utf-8'),
}, {
'base64': base64.b64encode(
b'data to be encoded 3').decode('utf-8'),
}
"base64": base64.b64encode(b"data to be encoded 1").decode("utf-8"),
},
{
"base64": base64.b64encode(b"data to be encoded 2").decode("utf-8"),
},
{
"base64": base64.b64encode(b"data to be encoded 3").decode("utf-8"),
},
]
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
@@ -238,21 +226,24 @@ class AttachmentTests(SimpleTestCase):
# Support multi entries
attachment_payload = [
{
'url': 'http://myserver/my.attachment.3',
}, {
'url': 'http://myserver/my.attachment.2',
}, {
'url': 'http://myserver/my.attachment.1',
}
"url": "http://myserver/my.attachment.3",
},
{
"url": "http://myserver/my.attachment.2",
},
{
"url": "http://myserver/my.attachment.1",
},
]
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
assert len(result) == 3
with override_settings(APPRISE_ATTACH_DENY_URLS='*'):
with override_settings(APPRISE_ATTACH_DENY_URLS="*"):
utils.ATTACH_URL_FILTER = AppriseURLFilter(
settings.APPRISE_ATTACH_ALLOW_URLS,
settings.APPRISE_ATTACH_DENY_URLS)
settings.APPRISE_ATTACH_DENY_URLS,
)
# We will fail to parse our URL based attachment
with self.assertRaises(ValueError):
@@ -261,7 +252,8 @@ class AttachmentTests(SimpleTestCase):
# Reload our configuration to default values
utils.ATTACH_URL_FILTER = AppriseURLFilter(
settings.APPRISE_ATTACH_ALLOW_URLS,
settings.APPRISE_ATTACH_DENY_URLS)
settings.APPRISE_ATTACH_DENY_URLS,
)
# Garbage handling (integer, float, object, etc is invalid)
attachment_payload = 5
@@ -279,8 +271,8 @@ class AttachmentTests(SimpleTestCase):
# filename provided, but its empty (and/or contains whitespace)
attachment_payload = {
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
'filename': ' '
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
"filename": " ",
}
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
@@ -288,30 +280,30 @@ class AttachmentTests(SimpleTestCase):
# filename too long
attachment_payload = {
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
'filename': 'a' * 1000,
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
"filename": "a" * 1000,
}
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})
# filename invalid
attachment_payload = {
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
'filename': 1,
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
"filename": 1,
}
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})
attachment_payload = {
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
'filename': None,
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
"filename": None,
}
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})
attachment_payload = {
'base64': base64.b64encode(b'data to be encoded').decode('utf-8'),
'filename': object(),
"base64": base64.b64encode(b"data to be encoded").decode("utf-8"),
"filename": object(),
}
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})
@@ -332,19 +324,18 @@ class AttachmentTests(SimpleTestCase):
# We allow empty entries, this is okay; there is just nothing
# returned at the end of the day
assert parse_attachments({''}, {}) == []
assert parse_attachments({""}, {}) == []
# We can't parse entries that are not base64 but specified as
# though they are
attachment_payload = {
'base64': 'not-base-64',
"base64": "not-base-64",
}
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})
# Support string; these become web requests
attachment_payload = \
"https://avatars.githubusercontent.com/u/850374?v=4"
attachment_payload = "https://avatars.githubusercontent.com/u/850374?v=4"
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
assert len(result) == 1
@@ -364,17 +355,15 @@ class AttachmentTests(SimpleTestCase):
# to disk
m = mock_open()
m.side_effect = OSError()
with patch('builtins.open', m):
with self.assertRaises(ValueError):
attachment_payload = b"some data to work with."
parse_attachments(attachment_payload, {})
with patch("builtins.open", m), self.assertRaises(ValueError):
attachment_payload = b"some data to work with."
parse_attachments(attachment_payload, {})
# Test a case where our attachment exceeds the maximum size we allow
# for
with override_settings(APPRISE_ATTACH_SIZE=1):
# More then 1 MB in size causing error to trip
attachment_payload = \
("content" * 1024 * 1024).encode('utf-8')
attachment_payload = ("content" * 1024 * 1024).encode("utf-8")
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})
@@ -387,7 +376,7 @@ class AttachmentTests(SimpleTestCase):
attachment_payload = [
# Request several images
"https://myserver/myotherfile.png",
"https://myserver/myfile.png"
"https://myserver/myfile.png",
]
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
@@ -411,12 +400,13 @@ class AttachmentTests(SimpleTestCase):
# We have hosts that will be blocked
parse_attachments([ap], {})
attachment_payload = [{
# Request several images
'url': "https://myserver/myotherfile.png",
}, {
'url': "https://myserver/myfile.png"
}]
attachment_payload = [
{
# Request several images
"url": "https://myserver/myotherfile.png",
},
{"url": "https://myserver/myfile.png"},
]
result = parse_attachments(attachment_payload, {})
assert isinstance(result, list)
assert len(result) == 2
@@ -447,10 +437,13 @@ class AttachmentTests(SimpleTestCase):
parse_attachments(attachment_payload, {})
# Support url encoding
attachment_payload = [{
'url': "https://myserver/garbage/abcd1.png",
}, {
'url': "https://myserver/garbage/abcd2.png",
}]
attachment_payload = [
{
"url": "https://myserver/garbage/abcd1.png",
},
{
"url": "https://myserver/garbage/abcd2.png",
},
]
with self.assertRaises(ValueError):
parse_attachments(attachment_payload, {})

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -24,12 +23,12 @@
# THE SOFTWARE.
import io
from django.test import SimpleTestCase
from django.core import management
from django.test import SimpleTestCase
class CommandTests(SimpleTestCase):
def test_command_style(self):
out = io.StringIO()
management.call_command('storeprune', days=40, stdout=out)
management.call_command("storeprune", days=40, stdout=out)

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,38 +21,36 @@
# 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
from ..utils import AppriseConfigCache
from ..utils import AppriseStoreMode
from ..utils import SimpleFileExtension
from apprise import ConfigFormat
from unittest.mock import patch
from unittest.mock import mock_open
import errno
import os
from unittest.mock import mock_open, patch
from apprise import ConfigFormat
from ..utils import AppriseConfigCache, AppriseStoreMode, SimpleFileExtension
def test_apprise_config_io_hash_mode(tmpdir):
"""
Test Apprise Config Disk Put/Get using HASH mode
"""
content = 'mailto://test:pass@gmail.com'
key = 'test_apprise_config_io_hash'
content = "mailto://test:pass@gmail.com"
key = "test_apprise_config_io_hash"
# Create our object to work with
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
# Verify that the content doesn't already exist
assert acc_obj.get(key) == (None, '')
assert acc_obj.get(key) == (None, "")
# Write our content assigned to our key
assert acc_obj.put(key, content, ConfigFormat.TEXT)
assert acc_obj.put(key, content, ConfigFormat.TEXT.value)
# Test the handling of underlining disk/write exceptions
with patch('gzip.open') as mock_gzopen:
with patch("gzip.open") as mock_gzopen:
mock_gzopen.side_effect = OSError()
# We'll fail to write our key now
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
# Get path details
conf_dir, _ = acc_obj.path(key)
@@ -63,13 +60,13 @@ def test_apprise_config_io_hash_mode(tmpdir):
# There should be just 1 new file in this directory
assert len(contents) == 1
assert contents[0].endswith('.{}'.format(ConfigFormat.TEXT))
assert contents[0].endswith(".{}".format(ConfigFormat.TEXT.value))
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
assert acc_obj.get(key) == (content, ConfigFormat.TEXT.value)
# Test the handling of underlining disk/read exceptions
with patch('gzip.open') as mock_gzopen:
with patch("gzip.open") as mock_gzopen:
mock_gzopen.side_effect = OSError()
# We'll fail to read our key now
assert acc_obj.get(key) == (None, None)
@@ -80,14 +77,14 @@ def test_apprise_config_io_hash_mode(tmpdir):
# But the second time is okay as it no longer exists
assert acc_obj.clear(key) is None
with patch('os.remove') as mock_remove:
with patch("os.remove") as mock_remove:
mock_remove.side_effect = OSError(errno.EPERM)
# OSError
assert acc_obj.clear(key) is False
# If we try to put the same file, we'll fail since
# one exists there already
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
# Now test with YAML file
content = """
@@ -100,17 +97,17 @@ def test_apprise_config_io_hash_mode(tmpdir):
# Write our content assigned to our key
# This should gracefully clear the TEXT entry that was
# previously in the spot
assert acc_obj.put(key, content, ConfigFormat.YAML)
assert acc_obj.put(key, content, ConfigFormat.YAML.value)
# List content of directory
contents = os.listdir(conf_dir)
# There should STILL be just 1 new file in this directory
assert len(contents) == 1
assert contents[0].endswith('.{}'.format(ConfigFormat.YAML))
assert contents[0].endswith(".{}".format(ConfigFormat.YAML.value))
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.YAML)
assert acc_obj.get(key) == (content, ConfigFormat.YAML.value)
def test_apprise_config_list_simple_mode(tmpdir):
@@ -121,26 +118,26 @@ def test_apprise_config_list_simple_mode(tmpdir):
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
# Add a hidden file to the config directory (which should be ignored)
hidden_file = os.path.join(str(tmpdir), '.hidden')
with open(hidden_file, 'w') as f:
f.write('hidden file')
hidden_file = os.path.join(str(tmpdir), ".hidden")
with open(hidden_file, "w") as f:
f.write("hidden file")
# Write 5 text configs and 5 yaml configs
content_text = 'mailto://test:pass@gmail.com'
content_text = "mailto://test:pass@gmail.com"
content_yaml = """
version: 1
urls:
- windows://
"""
text_key_tpl = 'test_apprise_config_list_simple_text_{}'
yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}'
text_key_tpl = "test_apprise_config_list_simple_text_{}"
yaml_key_tpl = "test_apprise_config_list_simple_yaml_{}"
text_keys = [text_key_tpl.format(i) for i in range(5)]
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
key = None
for key in text_keys:
assert acc_obj.put(key, content_text, ConfigFormat.TEXT)
assert acc_obj.put(key, content_text, ConfigFormat.TEXT.value)
for key in yaml_keys:
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML)
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML.value)
# Ensure the 10 configuration files (plus the hidden file) are the only
# contents of the directory
@@ -161,26 +158,26 @@ def test_apprise_config_list_hash_mode(tmpdir):
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.HASH)
# Add a hidden file to the config directory (which should be ignored)
hidden_file = os.path.join(str(tmpdir), '.hidden')
with open(hidden_file, 'w') as f:
f.write('hidden file')
hidden_file = os.path.join(str(tmpdir), ".hidden")
with open(hidden_file, "w") as f:
f.write("hidden file")
# Write 5 text configs and 5 yaml configs
content_text = 'mailto://test:pass@gmail.com'
content_text = "mailto://test:pass@gmail.com"
content_yaml = """
version: 1
urls:
- windows://
"""
text_key_tpl = 'test_apprise_config_list_simple_text_{}'
yaml_key_tpl = 'test_apprise_config_list_simple_yaml_{}'
text_key_tpl = "test_apprise_config_list_simple_text_{}"
yaml_key_tpl = "test_apprise_config_list_simple_yaml_{}"
text_keys = [text_key_tpl.format(i) for i in range(5)]
yaml_keys = [yaml_key_tpl.format(i) for i in range(5)]
key = None
for key in text_keys:
assert acc_obj.put(key, content_text, ConfigFormat.TEXT)
assert acc_obj.put(key, content_text, ConfigFormat.TEXT.value)
for key in yaml_keys:
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML)
assert acc_obj.put(key, content_yaml, ConfigFormat.YAML.value)
# Ensure the 10 configuration files (plus the hidden file) are the only
# contents of the directory
@@ -197,23 +194,23 @@ def test_apprise_config_io_simple_mode(tmpdir):
"""
Test Apprise Config Disk Put/Get using SIMPLE mode
"""
content = 'mailto://test:pass@gmail.com'
key = 'test_apprise_config_io_simple'
content = "mailto://test:pass@gmail.com"
key = "test_apprise_config_io_simple"
# Create our object to work with
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.SIMPLE)
# Verify that the content doesn't already exist
assert acc_obj.get(key) == (None, '')
assert acc_obj.get(key) == (None, "")
# Write our content assigned to our key
assert acc_obj.put(key, content, ConfigFormat.TEXT)
assert acc_obj.put(key, content, ConfigFormat.TEXT.value)
m = mock_open()
m.side_effect = OSError()
with patch('builtins.open', m):
with patch("builtins.open", m):
# We'll fail to write our key now
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
# Get path details
conf_dir, _ = acc_obj.path(key)
@@ -223,13 +220,13 @@ def test_apprise_config_io_simple_mode(tmpdir):
# There should be just 1 new file in this directory
assert len(contents) == 1
assert contents[0].endswith('.{}'.format(SimpleFileExtension.TEXT))
assert contents[0].endswith(".{}".format(SimpleFileExtension.TEXT))
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.TEXT)
assert acc_obj.get(key) == (content, ConfigFormat.TEXT.value)
# Test the handling of underlining disk/read exceptions
with patch('builtins.open', m) as mock__open:
with patch("builtins.open", m) as mock__open:
mock__open.side_effect = OSError()
# We'll fail to read our key now
assert acc_obj.get(key) == (None, None)
@@ -240,14 +237,14 @@ def test_apprise_config_io_simple_mode(tmpdir):
# But the second time is okay as it no longer exists
assert acc_obj.clear(key) is None
with patch('os.remove') as mock_remove:
with patch("os.remove") as mock_remove:
mock_remove.side_effect = OSError(errno.EPERM)
# OSError
assert acc_obj.clear(key) is False
# If we try to put the same file, we'll fail since
# one exists there already
assert not acc_obj.put(key, content, ConfigFormat.TEXT)
assert not acc_obj.put(key, content, ConfigFormat.TEXT.value)
# Now test with YAML file
content = """
@@ -260,25 +257,25 @@ def test_apprise_config_io_simple_mode(tmpdir):
# Write our content assigned to our key
# This should gracefully clear the TEXT entry that was
# previously in the spot
assert acc_obj.put(key, content, ConfigFormat.YAML)
assert acc_obj.put(key, content, ConfigFormat.YAML.value)
# List content of directory
contents = os.listdir(conf_dir)
# There should STILL be just 1 new file in this directory
assert len(contents) == 1
assert contents[0].endswith('.{}'.format(SimpleFileExtension.YAML))
assert contents[0].endswith(".{}".format(SimpleFileExtension.YAML))
# Verify that the content is retrievable
assert acc_obj.get(key) == (content, ConfigFormat.YAML)
assert acc_obj.get(key) == (content, ConfigFormat.YAML.value)
def test_apprise_config_io_disabled_mode(tmpdir):
"""
Test Apprise Config Disk Put/Get using DISABLED mode
"""
content = 'mailto://test:pass@gmail.com'
key = 'test_apprise_config_io_disabled'
content = "mailto://test:pass@gmail.com"
key = "test_apprise_config_io_disabled"
# Create our object to work with using an invalid mode
acc_obj = AppriseConfigCache(str(tmpdir), mode="invalid")
@@ -290,11 +287,11 @@ def test_apprise_config_io_disabled_mode(tmpdir):
acc_obj = AppriseConfigCache(str(tmpdir), mode=AppriseStoreMode.DISABLED)
# Verify that the content doesn't already exist
assert acc_obj.get(key) == (None, '')
assert acc_obj.get(key) == (None, "")
# Write our content assigned to our key
# This isn't allowed
assert acc_obj.put(key, content, ConfigFormat.TEXT) is False
assert acc_obj.put(key, content, ConfigFormat.TEXT.value) is False
# Get path details
conf_dir, _ = acc_obj.path(key)

View File

@@ -0,0 +1,52 @@
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# 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.
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase, override_settings
from apprise_api.core.middleware.config import DetectConfigMiddleware
class DetectConfigMiddlewareTest(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
@override_settings(APPRISE_DEFAULT_CONFIG_ID="")
def test_middleware_falls_back_when_config_absent(self):
"""
Ensure we trigger the `if not config:` fallback
"""
# Create request to path that does NOT match /cfg/<key>
request = self.factory.get("/")
request.COOKIES = {} # no 'key' cookie
# Patch middleware to capture the 'config' result
def get_response(req):
req._config = getattr(req, "_config", None)
return HttpResponse() # simulate response
middleware = DetectConfigMiddleware(get_response)
response = middleware(request)
# Validate we entered the `if not config:` branch
# You can't test the internal `config` variable directly unless it's exposed
self.assertTrue(hasattr(response, "_config") is False)

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,19 +21,19 @@
# 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 hashlib
from unittest.mock import patch
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch
import hashlib
class DelTests(SimpleTestCase):
def test_del_get_invalid_key_status_code(self):
"""
Test GET requests to invalid key
"""
response = self.client.get('/del/**invalid-key**')
response = self.client.get("/del/**invalid-key**")
assert response.status_code == 404
def test_key_lengths(self):
@@ -44,27 +43,26 @@ class DelTests(SimpleTestCase):
# our key to use
h = hashlib.sha512()
h.update(b'string')
h.update(b"string")
key = h.hexdigest()
# Our limit
assert len(key) == 128
# Add our URL
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 200
# remove a key that is too long
response = self.client.post('/del/{}'.format(key + 'x'))
response = self.client.post("/del/{}".format(key + "x"))
assert response.status_code == 404
# remove the key
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 200
# Test again; key is gone
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 204
@override_settings(APPRISE_CONFIG_LOCK=True)
@@ -73,10 +71,10 @@ class DelTests(SimpleTestCase):
Test deleting a configuration by URLs with lock set won't work
"""
# our key to use
key = 'test_delete_with_lock'
key = "test_delete_with_lock"
# We simply do not have permission to do so
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 403
def test_del_post(self):
@@ -84,31 +82,30 @@ class DelTests(SimpleTestCase):
Test DEL POST
"""
# our key to use
key = 'test_delete'
key = "test_delete"
# Invalid Key
response = self.client.post('/del/**invalid-key**')
response = self.client.post("/del/**invalid-key**")
assert response.status_code == 404
# A key that just simply isn't present
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 204
# Add our key
response = self.client.post(
'/add/{}'.format(key), {'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 200
# Test removing key when the OS just can't do it:
with patch('os.remove', side_effect=OSError):
with patch("os.remove", side_effect=OSError):
# We can now remove the key
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 500
# We can now remove the key
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 200
# Key has already been removed
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 204

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -26,12 +25,11 @@ from django.test import SimpleTestCase
class DetailTests(SimpleTestCase):
def test_post_not_supported(self):
"""
Test POST requests
"""
response = self.client.post('/details')
response = self.client.post("/details")
# 405 as posting is not allowed
assert response.status_code == 405
@@ -41,38 +39,46 @@ class DetailTests(SimpleTestCase):
"""
# Nothing to return
response = self.client.get('/details')
response = self.client.get("/details")
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('text/html')
assert response["Content-Type"].startswith("text/html")
# JSON Response
response = self.client.get(
'/details', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/details",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
assert response["Content-Type"].startswith("application/json")
# JSON Response
response = self.client.get(
'/details', content_type='application/json',
**{'HTTP_ACCEPT': 'application/json'})
"/details",
content_type="application/json",
**{"HTTP_ACCEPT": "application/json"},
)
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
assert response["Content-Type"].startswith("application/json")
response = self.client.get('/details?all=yes')
response = self.client.get("/details?all=yes")
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('text/html')
assert response["Content-Type"].startswith("text/html")
# JSON Response
response = self.client.get(
'/details?all=yes', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/details?all=yes",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
assert response["Content-Type"].startswith("application/json")
# JSON Response
response = self.client.get(
'/details?all=yes', content_type='application/json',
**{'HTTP_ACCEPT': 'application/json'})
"/details?all=yes",
content_type="application/json",
**{"HTTP_ACCEPT": "application/json"},
)
self.assertEqual(response.status_code, 200)
assert response['Content-Type'].startswith('application/json')
assert response["Content-Type"].startswith("application/json")

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,17 +21,17 @@
# 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.
from django.test import SimpleTestCase
from unittest.mock import patch
from django.test import SimpleTestCase
class GetTests(SimpleTestCase):
def test_get_invalid_key_status_code(self):
"""
Test GET requests to invalid key
"""
response = self.client.get('/get/**invalid-key**')
response = self.client.get("/get/**invalid-key**")
assert response.status_code == 404
def test_get_config(self):
@@ -41,47 +40,48 @@ class GetTests(SimpleTestCase):
"""
# our key to use
key = 'test_get_config_'
key = "test_get_config_"
# GET returns 405 (not allowed)
response = self.client.get('/get/{}'.format(key))
response = self.client.get("/get/{}".format(key))
assert response.status_code == 405
# No content saved to the location yet
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
self.assertEqual(response.status_code, 204)
# Add some content
response = self.client.post(
'/add/{}'.format(key),
{'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 200
# Handle case when we try to retrieve our content but we have no idea
# what the format is in. Essentialy there had to have been disk
# corruption here or someone meddling with the backend.
with patch('gzip.open', side_effect=OSError):
response = self.client.post('/get/{}'.format(key))
with patch("gzip.open", side_effect=OSError):
response = self.client.post("/get/{}".format(key))
assert response.status_code == 500
# Now we should be able to see our content
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 200
# Add a YAML file
response = self.client.post(
'/add/{}'.format(key), {
'format': 'yaml',
'config': """
"/add/{}".format(key),
{
"format": "yaml",
"config": """
urls:
- dbus://"""})
- dbus://""",
},
)
assert response.status_code == 200
# Now retrieve our YAML configuration
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 200
# Verify that the correct Content-Type is set in the header of the
# response
assert 'Content-Type' in response
assert response['Content-Type'].startswith('text/yaml')
assert "Content-Type" in response
assert response["Content-Type"].startswith("text/yaml")

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,20 +21,21 @@
# 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 mock
from django.test import SimpleTestCase
from json import loads
from unittest import mock
from django.test import SimpleTestCase
from django.test.utils import override_settings
from ..utils import healthcheck
class HealthCheckTests(SimpleTestCase):
def test_post_not_supported(self):
"""
Test POST requests
"""
response = self.client.post('/status')
response = self.client.post("/status")
# 405 as posting is not allowed
assert response.status_code == 405
@@ -45,129 +45,139 @@ class HealthCheckTests(SimpleTestCase):
"""
# First Status Check
response = self.client.get('/status')
response = self.client.get("/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'OK')
assert response['Content-Type'].startswith('text/plain')
self.assertEqual(response.content, b"OK")
assert response["Content-Type"].startswith("text/plain")
# Second Status Check (Lazy Mode kicks in)
response = self.client.get('/status')
response = self.client.get("/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'OK')
assert response['Content-Type'].startswith('text/plain')
self.assertEqual(response.content, b"OK")
assert response["Content-Type"].startswith("text/plain")
# JSON Response
response = self.client.get(
'/status', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/status",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': False,
'status': {
'persistent_storage': True,
'can_write_config': True,
'can_write_attach': True,
'details': ['OK']
}
"config_lock": False,
"attach_lock": False,
"status": {
"persistent_storage": True,
"can_write_config": True,
"can_write_attach": True,
"details": ["OK"],
},
}
assert response['Content-Type'].startswith('application/json')
assert response["Content-Type"].startswith("application/json")
with override_settings(APPRISE_CONFIG_LOCK=True):
# Status Check (Form based)
response = self.client.get('/status')
response = self.client.get("/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'OK')
assert response['Content-Type'].startswith('text/plain')
self.assertEqual(response.content, b"OK")
assert response["Content-Type"].startswith("text/plain")
# JSON Response
response = self.client.get(
'/status', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/status",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
content = loads(response.content)
assert content == {
'config_lock': True,
'attach_lock': False,
'status': {
'persistent_storage': True,
'can_write_config': False,
'can_write_attach': True,
'details': ['OK']
}
"config_lock": True,
"attach_lock": False,
"status": {
"persistent_storage": True,
"can_write_config": False,
"can_write_attach": True,
"details": ["OK"],
},
}
with override_settings(APPRISE_STATEFUL_MODE='disabled'):
with override_settings(APPRISE_STATEFUL_MODE="disabled"):
# Status Check (Form based)
response = self.client.get('/status')
response = self.client.get("/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'OK')
assert response['Content-Type'].startswith('text/plain')
self.assertEqual(response.content, b"OK")
assert response["Content-Type"].startswith("text/plain")
# JSON Response
response = self.client.get(
'/status', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/status",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': False,
'status': {
'persistent_storage': True,
'can_write_config': False,
'can_write_attach': True,
'details': ['OK']
}
"config_lock": False,
"attach_lock": False,
"status": {
"persistent_storage": True,
"can_write_config": False,
"can_write_attach": True,
"details": ["OK"],
},
}
with override_settings(APPRISE_ATTACH_SIZE=0):
# Status Check (Form based)
response = self.client.get('/status')
response = self.client.get("/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'OK')
assert response['Content-Type'].startswith('text/plain')
self.assertEqual(response.content, b"OK")
assert response["Content-Type"].startswith("text/plain")
# JSON Response
response = self.client.get(
'/status', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/status",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': True,
'status': {
'persistent_storage': True,
'can_write_config': True,
'can_write_attach': False,
'details': ['OK']
}
"config_lock": False,
"attach_lock": True,
"status": {
"persistent_storage": True,
"can_write_config": True,
"can_write_attach": False,
"details": ["OK"],
},
}
with override_settings(APPRISE_MAX_ATTACHMENTS=0):
# Status Check (Form based)
response = self.client.get('/status')
response = self.client.get("/status")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'OK')
assert response['Content-Type'].startswith('text/plain')
self.assertEqual(response.content, b"OK")
assert response["Content-Type"].startswith("text/plain")
# JSON Response
response = self.client.get(
'/status', content_type='application/json',
**{'HTTP_CONTENT_TYPE': 'application/json'})
"/status",
content_type="application/json",
**{"HTTP_CONTENT_TYPE": "application/json"},
)
self.assertEqual(response.status_code, 200)
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': False,
'status': {
'persistent_storage': True,
'can_write_config': True,
'can_write_attach': True,
'details': ['OK']
}
"config_lock": False,
"attach_lock": False,
"status": {
"persistent_storage": True,
"can_write_config": True,
"can_write_attach": True,
"details": ["OK"],
},
}
def test_healthcheck_library(self):
@@ -177,115 +187,129 @@ class HealthCheckTests(SimpleTestCase):
result = healthcheck(lazy=True)
assert result == {
'persistent_storage': True,
'can_write_config': True,
'can_write_attach': True,
'details': ['OK']
"persistent_storage": True,
"can_write_config": True,
"can_write_attach": True,
"details": ["OK"],
}
# A Double lazy check
result = healthcheck(lazy=True)
assert result == {
'persistent_storage': True,
'can_write_config': True,
'can_write_attach': True,
'details': ['OK']
"persistent_storage": True,
"can_write_config": True,
"can_write_attach": True,
"details": ["OK"],
}
# Force a lazy check where we can't acquire the modify time
with mock.patch('os.path.getmtime') as mock_getmtime:
with mock.patch("os.path.getmtime") as mock_getmtime:
mock_getmtime.side_effect = FileNotFoundError()
result = healthcheck(lazy=True)
# We still succeed; we just don't leverage our lazy check
# which prevents addition (unnessisary) writes
assert result == {
'persistent_storage': True,
'can_write_config': True,
'can_write_attach': True,
'details': ['OK'],
"persistent_storage": True,
"can_write_config": True,
"can_write_attach": True,
"details": ["OK"],
}
# Force a lazy check where we can't acquire the modify time
with mock.patch('os.path.getmtime') as mock_getmtime:
with mock.patch("os.path.getmtime") as mock_getmtime:
mock_getmtime.side_effect = OSError()
result = healthcheck(lazy=True)
# We still succeed; we just don't leverage our lazy check
# which prevents addition (unnessisary) writes
assert result == {
'persistent_storage': True,
'can_write_config': False,
'can_write_attach': False,
'details': [
'CONFIG_PERMISSION_ISSUE',
'ATTACH_PERMISSION_ISSUE',
]}
"persistent_storage": True,
"can_write_config": False,
"can_write_attach": False,
"details": [
"CONFIG_PERMISSION_ISSUE",
"ATTACH_PERMISSION_ISSUE",
],
}
# Force a non-lazy check
with mock.patch('os.makedirs') as mock_makedirs:
with mock.patch("os.makedirs") as mock_makedirs:
mock_makedirs.side_effect = OSError()
result = healthcheck(lazy=False)
assert result == {
'persistent_storage': False,
'can_write_config': False,
'can_write_attach': False,
'details': [
'CONFIG_PERMISSION_ISSUE',
'ATTACH_PERMISSION_ISSUE',
'STORE_PERMISSION_ISSUE',
]}
"persistent_storage": False,
"can_write_config": False,
"can_write_attach": False,
"details": [
"CONFIG_PERMISSION_ISSUE",
"ATTACH_PERMISSION_ISSUE",
"STORE_PERMISSION_ISSUE",
],
}
with mock.patch('os.path.getmtime') as mock_getmtime:
with mock.patch('os.fdopen', side_effect=OSError()):
mock_getmtime.side_effect = OSError()
mock_makedirs.side_effect = None
result = healthcheck(lazy=False)
assert result == {
'persistent_storage': True,
'can_write_config': False,
'can_write_attach': False,
'details': [
'CONFIG_PERMISSION_ISSUE',
'ATTACH_PERMISSION_ISSUE',
]}
with mock.patch('apprise.PersistentStore.flush', return_value=False):
with mock.patch("os.path.getmtime") as mock_getmtime, \
mock.patch("os.fdopen", side_effect=OSError()):
mock_getmtime.side_effect = OSError()
mock_makedirs.side_effect = None
result = healthcheck(lazy=False)
assert result == {
'persistent_storage': False,
'can_write_config': True,
'can_write_attach': True,
'details': [
'STORE_PERMISSION_ISSUE',
]}
"persistent_storage": True,
"can_write_config": False,
"can_write_attach": False,
"details": [
"CONFIG_PERMISSION_ISSUE",
"ATTACH_PERMISSION_ISSUE",
],
}
with mock.patch("apprise.PersistentStore.flush", return_value=False):
result = healthcheck(lazy=False)
assert result == {
"persistent_storage": False,
"can_write_config": True,
"can_write_attach": True,
"details": [
"STORE_PERMISSION_ISSUE",
],
}
# Test a case where we simply do not define a persistent store path
# health checks will always disable persistent storage
with override_settings(APPRISE_STORAGE_DIR=""):
with mock.patch('apprise.PersistentStore.flush', return_value=False):
result = healthcheck(lazy=False)
assert result == {
'persistent_storage': False,
'can_write_config': True,
'can_write_attach': True,
'details': ['OK']}
with override_settings(APPRISE_STORAGE_DIR=""), \
mock.patch("apprise.PersistentStore.flush", return_value=False):
result = healthcheck(lazy=False)
assert result == {
"persistent_storage": False,
"can_write_config": True,
"can_write_attach": True,
"details": ["OK"],
}
mock_makedirs.side_effect = (OSError(), OSError(), None, None, None, None)
mock_makedirs.side_effect = (
OSError(),
OSError(),
None,
None,
None,
None,
)
result = healthcheck(lazy=False)
assert result == {
'persistent_storage': True,
'can_write_config': False,
'can_write_attach': False,
'details': [
'CONFIG_PERMISSION_ISSUE',
'ATTACH_PERMISSION_ISSUE',
]}
"persistent_storage": True,
"can_write_config": False,
"can_write_attach": False,
"details": [
"CONFIG_PERMISSION_ISSUE",
"ATTACH_PERMISSION_ISSUE",
],
}
mock_makedirs.side_effect = (OSError(), None, None, None, None)
result = healthcheck(lazy=False)
assert result == {
'persistent_storage': True,
'can_write_config': False,
'can_write_attach': True,
'details': [
'CONFIG_PERMISSION_ISSUE',
]}
"persistent_storage": True,
"can_write_config": False,
"can_write_attach": True,
"details": [
"CONFIG_PERMISSION_ISSUE",
],
}

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,25 +21,25 @@
# 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.
from unittest.mock import patch
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch
class JsonUrlsTests(SimpleTestCase):
def test_get_invalid_key_status_code(self):
"""
Test GET requests to invalid key
"""
response = self.client.get('/get/**invalid-key**')
response = self.client.get("/get/**invalid-key**")
assert response.status_code == 404
def test_post_not_supported(self):
"""
Test POST requests with key
"""
response = self.client.post('/json/urls/test')
response = self.client.post("/json/urls/test")
# 405 as posting is not allowed
assert response.status_code == 405
@@ -50,111 +49,108 @@ class JsonUrlsTests(SimpleTestCase):
"""
# our key to use
key = 'test_json_urls_config'
key = "test_json_urls_config"
# Nothing to return
response = self.client.get('/json/urls/{}'.format(key))
response = self.client.get("/json/urls/{}".format(key))
self.assertEqual(response.status_code, 204)
# Add some content
response = self.client.post(
'/add/{}'.format(key),
{'urls': 'mailto://user:pass@yahoo.ca'})
response = self.client.post("/add/{}".format(key), {"urls": "mailto://user:pass@yahoo.ca"})
assert response.status_code == 200
# Handle case when we try to retrieve our content but we have no idea
# what the format is in. Essentialy there had to have been disk
# corruption here or someone meddling with the backend.
with patch('gzip.open', side_effect=OSError):
response = self.client.get('/json/urls/{}'.format(key))
with patch("gzip.open", side_effect=OSError):
response = self.client.get("/json/urls/{}".format(key))
assert response.status_code == 500
assert response['Content-Type'].startswith('application/json')
assert 'tags' in response.json()
assert 'urls' in response.json()
assert response["Content-Type"].startswith("application/json")
assert "tags" in response.json()
assert "urls" in response.json()
# has error directive
assert 'error' in response.json()
assert "error" in response.json()
# entries exist by are empty
assert len(response.json()['tags']) == 0
assert len(response.json()['urls']) == 0
assert len(response.json()["tags"]) == 0
assert len(response.json()["urls"]) == 0
# Now we should be able to see our content
response = self.client.get('/json/urls/{}'.format(key))
response = self.client.get("/json/urls/{}".format(key))
assert response.status_code == 200
assert response['Content-Type'].startswith('application/json')
assert 'tags' in response.json()
assert 'urls' in response.json()
assert response["Content-Type"].startswith("application/json")
assert "tags" in response.json()
assert "urls" in response.json()
# No errors occurred, therefore no error entry
assert 'error' not in response.json()
assert "error" not in response.json()
# No tags (but can be assumed "all") is always present
assert len(response.json()['tags']) == 0
assert len(response.json()["tags"]) == 0
# Same request as above but we set the privacy flag
response = self.client.get('/json/urls/{}?privacy=1'.format(key))
response = self.client.get("/json/urls/{}?privacy=1".format(key))
assert response.status_code == 200
assert response['Content-Type'].startswith('application/json')
assert 'tags' in response.json()
assert 'urls' in response.json()
assert response["Content-Type"].startswith("application/json")
assert "tags" in response.json()
assert "urls" in response.json()
# No errors occurred, therefore no error entry
assert 'error' not in response.json()
assert "error" not in response.json()
# No tags (but can be assumed "all") is always present
assert len(response.json()['tags']) == 0
assert len(response.json()["tags"]) == 0
# One URL loaded
assert len(response.json()['urls']) == 1
assert 'url' in response.json()['urls'][0]
assert 'tags' in response.json()['urls'][0]
assert len(response.json()['urls'][0]['tags']) == 0
assert len(response.json()["urls"]) == 1
assert "url" in response.json()["urls"][0]
assert "tags" in response.json()["urls"][0]
assert len(response.json()["urls"][0]["tags"]) == 0
# We can see that th URLs are not the same when the privacy flag is set
without_privacy = \
self.client.get('/json/urls/{}?privacy=1'.format(key))
with_privacy = self.client.get('/json/urls/{}'.format(key))
assert with_privacy.json()['urls'][0] != \
without_privacy.json()['urls'][0]
without_privacy = self.client.get("/json/urls/{}?privacy=1".format(key))
with_privacy = self.client.get("/json/urls/{}".format(key))
assert with_privacy.json()["urls"][0] != without_privacy.json()["urls"][0]
with override_settings(APPRISE_CONFIG_LOCK=True):
# When our configuration lock is set, our result set enforces the
# privacy flag even if it was otherwise set:
with_privacy = \
self.client.get('/json/urls/{}?privacy=1'.format(key))
with_privacy = self.client.get("/json/urls/{}?privacy=1".format(key))
# But now they're the same under this new condition
assert with_privacy.json()['urls'][0] == \
without_privacy.json()['urls'][0]
assert with_privacy.json()["urls"][0] == without_privacy.json()["urls"][0]
# Add a YAML file
response = self.client.post(
'/add/{}'.format(key), {
'format': 'yaml',
'config': """
"/add/{}".format(key),
{
"format": "yaml",
"config": """
urls:
- dbus://:
- tag: tag1, tag2"""})
- tag: tag1, tag2""",
},
)
assert response.status_code == 200
# Now retrieve our JSON resonse
response = self.client.get('/json/urls/{}'.format(key))
response = self.client.get("/json/urls/{}".format(key))
assert response.status_code == 200
# No errors occured, therefore no error entry
assert 'error' not in response.json()
assert "error" not in response.json()
# No tags (but can be assumed "all") is always present
assert len(response.json()['tags']) == 2
assert len(response.json()["tags"]) == 2
# One URL loaded
assert len(response.json()['urls']) == 1
assert 'url' in response.json()['urls'][0]
assert 'tags' in response.json()['urls'][0]
assert len(response.json()['urls'][0]['tags']) == 2
assert len(response.json()["urls"]) == 1
assert "url" in response.json()["urls"][0]
assert "tags" in response.json()["urls"][0]
assert len(response.json()["urls"][0]["tags"]) == 2
# Verify that the correct Content-Type is set in the header of the
# response
assert 'Content-Type' in response
assert response['Content-Type'].startswith('application/json')
assert "Content-Type" in response
assert response["Content-Type"].startswith("application/json")

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,8 +21,7 @@
# 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.
from django.test import SimpleTestCase
from django.test import override_settings
from django.test import SimpleTestCase, override_settings
class ManagerPageTests(SimpleTestCase):
@@ -36,34 +34,34 @@ class ManagerPageTests(SimpleTestCase):
General testing of management page
"""
# No permission to get keys
response = self.client.get('/cfg/')
response = self.client.get("/cfg/")
assert response.status_code == 403
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='hash'):
response = self.client.get('/cfg/')
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="hash"):
response = self.client.get("/cfg/")
assert response.status_code == 403
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='simple'):
response = self.client.get('/cfg/')
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE="simple"):
response = self.client.get("/cfg/")
assert response.status_code == 403
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE='disabled'):
response = self.client.get('/cfg/')
with override_settings(APPRISE_ADMIN=False, APPRISE_STATEFUL_MODE="disabled"):
response = self.client.get("/cfg/")
assert response.status_code == 403
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='disabled'):
response = self.client.get('/cfg/')
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="disabled"):
response = self.client.get("/cfg/")
assert response.status_code == 403
# But only when the setting is enabled
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE='simple'):
response = self.client.get('/cfg/')
with override_settings(APPRISE_ADMIN=True, APPRISE_STATEFUL_MODE="simple"):
response = self.client.get("/cfg/")
assert response.status_code == 200
# An invalid key was specified
response = self.client.get('/cfg/**invalid-key**')
response = self.client.get("/cfg/**invalid-key**")
assert response.status_code == 404
# An invalid key was specified
response = self.client.get('/cfg/valid-key')
response = self.client.get("/cfg/valid-key")
assert response.status_code == 200

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -23,6 +22,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
from ..payload_mapper import remap_fields
@@ -41,9 +41,9 @@ class NotifyPayloadMapper(SimpleTestCase):
#
rules = {}
payload = {
'format': 'markdown',
'title': 'title',
'body': '# body',
"format": "markdown",
"title": "title",
"body": "# body",
}
payload_orig = payload.copy()
@@ -58,32 +58,29 @@ class NotifyPayloadMapper(SimpleTestCase):
#
rules = {
# map 'as' to 'format'
'as': 'format',
"as": "format",
# map 'subject' to 'title'
'subject': 'title',
"subject": "title",
# map 'content' to 'body'
'content': 'body',
"content": "body",
# 'missing' is an invalid entry so this will be skipped
'unknown': 'missing',
"unknown": "missing",
# Empty field
'attachment': '',
"attachment": "",
# Garbage is an field that can be removed since it doesn't
# conflict with the form
'garbage': '',
"garbage": "",
# Tag
'tag': 'test',
"tag": "test",
}
payload = {
'as': 'markdown',
'subject': 'title',
'content': '# body',
'tag': '',
'unknown': 'hmm',
'attachment': '',
'garbage': '',
"as": "markdown",
"subject": "title",
"content": "# body",
"tag": "",
"unknown": "hmm",
"attachment": "",
"garbage": "",
}
# Map our fields
@@ -91,11 +88,11 @@ class NotifyPayloadMapper(SimpleTestCase):
# Our field mappings have taken place
assert payload == {
'tag': 'test',
'unknown': 'missing',
'format': 'markdown',
'title': 'title',
'body': '# body',
"tag": "test",
"unknown": "missing",
"format": "markdown",
"title": "title",
"body": "# body",
}
#
@@ -104,18 +101,18 @@ class NotifyPayloadMapper(SimpleTestCase):
rules = {
#
# map 'content' to 'body'
'content': 'body',
"content": "body",
# a double mapping to body will trigger an error
'message': 'body',
"message": "body",
# Swapping fields
'body': 'another set of data',
"body": "another set of data",
}
payload = {
'as': 'markdown',
'subject': 'title',
'content': '# content body',
'message': '# message body',
'body': 'another set of data',
"as": "markdown",
"subject": "title",
"content": "# content body",
"message": "# message body",
"body": "another set of data",
}
# Map our fields
@@ -123,9 +120,9 @@ class NotifyPayloadMapper(SimpleTestCase):
# Our information gets swapped
assert payload == {
'as': 'markdown',
'subject': 'title',
'body': 'another set of data',
"as": "markdown",
"subject": "title",
"body": "another set of data",
}
#
@@ -134,12 +131,12 @@ class NotifyPayloadMapper(SimpleTestCase):
rules = {
#
# map 'content' to 'body'
'title': 'body',
"title": "body",
}
payload = {
'format': 'markdown',
'title': 'body',
'body': '# title',
"format": "markdown",
"title": "body",
"body": "# title",
}
# Map our fields
@@ -147,9 +144,9 @@ class NotifyPayloadMapper(SimpleTestCase):
# Our information gets swapped
assert payload == {
'format': 'markdown',
'title': '# title',
'body': 'body',
"format": "markdown",
"title": "# title",
"body": "body",
}
#
@@ -158,11 +155,11 @@ class NotifyPayloadMapper(SimpleTestCase):
rules = {
#
# map 'content' to 'body'
'title': 'body',
"title": "body",
}
payload = {
'format': 'markdown',
'title': 'body',
"format": "markdown",
"title": "body",
}
# Map our fields
@@ -170,8 +167,8 @@ class NotifyPayloadMapper(SimpleTestCase):
# Our information gets swapped
assert payload == {
'format': 'markdown',
'body': 'body',
"format": "markdown",
"body": "body",
}
#
@@ -180,12 +177,12 @@ class NotifyPayloadMapper(SimpleTestCase):
rules = {
#
# map 'content' to 'body'
'content': 'body',
"content": "body",
}
payload = {
'format': 'markdown',
'content': 'the message',
'body': 'to-be-replaced',
"format": "markdown",
"content": "the message",
"body": "to-be-replaced",
}
# Map our fields
@@ -193,26 +190,26 @@ class NotifyPayloadMapper(SimpleTestCase):
# Our information gets swapped
assert payload == {
'format': 'markdown',
'body': 'the message',
"format": "markdown",
"body": "the message",
}
#
# mapping of fields don't align - test 6
#
rules = {
'payload': 'body',
'fmt': 'format',
'extra': 'tag',
"payload": "body",
"fmt": "format",
"extra": "tag",
}
payload = {
'format': 'markdown',
'type': 'info',
'title': '',
'body': '## test notifiction',
'attachment': None,
'tag': 'general',
'tags': '',
"format": "markdown",
"type": "info",
"title": "",
"body": "## test notifiction",
"attachment": None,
"tag": "general",
"tags": "",
}
# Make a copy of our original payload

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,17 +21,20 @@
# 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.
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch, Mock
from ..forms import NotifyForm
from ..utils import ConfigCache
import inspect
from json import dumps
import os
import re
import apprise
from unittest.mock import Mock, patch
from django.test import SimpleTestCase
from django.test.utils import override_settings
import requests
import inspect
import apprise
from ..forms import NotifyForm
from ..utils import ConfigCache
# Grant access to our Notification Manager Singleton
N_MGR = apprise.manager_plugins.NotificationManager()
@@ -49,15 +51,15 @@ class StatefulNotifyTests(SimpleTestCase):
Test the retrieval of configuration when the lock is set
"""
# our key to use
key = 'test_stateful_with_lock'
key = "test_stateful_with_lock"
# It doesn't matter if there is or isn't any configuration; when this
# flag is set. All that overhead is skipped and we're denied access
# right off the bat
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 403
@patch('requests.post')
@patch("requests.post")
def test_stateful_configuration_io(self, mock_post):
"""
Test the writing, removal, writing and removal of configuration to
@@ -65,50 +67,48 @@ class StatefulNotifyTests(SimpleTestCase):
"""
# our key to use
key = 'test_stateful_01'
key = "test_stateful_01"
request = Mock()
request.content = b'ok'
request.content = b"ok"
request.status_code = requests.codes.ok
mock_post.return_value = request
# Monkey Patch
N_MGR['mailto'].enabled = True
N_MGR["mailto"].enabled = True
# Preare our list of URLs we want to save
urls = [
'pushbullet=pbul://tokendetails',
'general,json=json://hostname',
"pushbullet=pbul://tokendetails",
"general,json=json://hostname",
]
# Monkey Patch
N_MGR['pbul'].enabled = True
N_MGR['json'].enabled = True
N_MGR["pbul"].enabled = True
N_MGR["json"].enabled = True
# For 10 iterations, repeat these tests to verify that don't change
# and our saved content is not different on subsequent calls.
for _ in range(10):
# No content saved to the location yet
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 204
# Add our content
response = self.client.post(
'/add/{}'.format(key),
{'config': '\r\n'.join(urls)})
response = self.client.post("/add/{}".format(key), {"config": "\r\n".join(urls)})
assert response.status_code == 200
# Now we should be able to see our content
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 200
entries = re.split(r'[\r*\n]+', response.content.decode('utf-8'))
entries = re.split(r"[\r*\n]+", response.content.decode("utf-8"))
assert len(entries) == 2
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': 'general',
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": "general",
}
form = NotifyForm(data=form_data)
@@ -116,53 +116,54 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# We sent the notification successfully
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
assert mock_post.call_count == 1
mock_post.reset_mock()
form_data = {
'payload': '## test notification',
'fmt': apprise.NotifyFormat.MARKDOWN,
'extra': 'general',
"payload": "## test notification",
"fmt": apprise.NotifyFormat.MARKDOWN.value,
"extra": "general",
}
# We sent the notification successfully (use our rule mapping)
# FORM
response = self.client.post(
f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag',
form_data)
f"/notify/{key}/?:payload=body&:fmt=format&:extra=tag",
form_data,
)
assert response.status_code == 200
assert mock_post.call_count == 1
mock_post.reset_mock()
form_data = {
'payload': '## test notification',
'fmt': apprise.NotifyFormat.MARKDOWN,
'extra': 'general',
"payload": "## test notification",
"fmt": apprise.NotifyFormat.MARKDOWN.value,
"extra": "general",
}
# We sent the notification successfully (use our rule mapping)
# JSON
response = self.client.post(
f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag',
f"/notify/{key}/?:payload=body&:fmt=format&:extra=tag",
dumps(form_data),
content_type="application/json")
content_type="application/json",
)
assert response.status_code == 200
assert mock_post.call_count == 1
mock_post.reset_mock()
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': 'no-on-with-this-tag',
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": "no-on-with-this-tag",
}
form = NotifyForm(data=form_data)
@@ -170,22 +171,21 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# No one to notify
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 424
assert mock_post.call_count == 0
mock_post.reset_mock()
# Now empty our data
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 200
# A second call; but there is nothing to remove
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 204
# Reset our count
@@ -193,28 +193,26 @@ class StatefulNotifyTests(SimpleTestCase):
# Now we do a similar approach as the above except we remove the
# configuration from under the application
key = 'test_stateful_02'
key = "test_stateful_02"
for _ in range(10):
# No content saved to the location yet
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 204
# Add our content
response = self.client.post(
'/add/{}'.format(key),
{'config': '\r\n'.join(urls)})
response = self.client.post("/add/{}".format(key), {"config": "\r\n".join(urls)})
assert response.status_code == 200
# Now we should be able to see our content
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 200
entries = re.split(r'[\r*\n]+', response.content.decode('utf-8'))
entries = re.split(r"[\r*\n]+", response.content.decode("utf-8"))
assert len(entries) == 2
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
}
form = NotifyForm(data=form_data)
@@ -222,11 +220,10 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# No one to notify (no tag specified)
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 424
assert mock_post.call_count == 0
@@ -237,9 +234,9 @@ class StatefulNotifyTests(SimpleTestCase):
# Test tagging now
#
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': 'general+json',
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": "general+json",
}
form = NotifyForm(data=form_data)
@@ -247,10 +244,9 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
# + (plus) not supported at this time
assert response.status_code == 400
assert mock_post.call_count == 0
@@ -259,10 +255,10 @@ class StatefulNotifyTests(SimpleTestCase):
mock_post.reset_mock()
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
# Plus with space inbetween
'tag': 'general + json',
"tag": "general + json",
}
form = NotifyForm(data=form_data)
@@ -270,10 +266,9 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
# + (plus) not supported at this time
assert response.status_code == 400
assert mock_post.call_count == 0
@@ -281,10 +276,10 @@ class StatefulNotifyTests(SimpleTestCase):
mock_post.reset_mock()
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
# Space (AND)
'tag': 'general json',
"tag": "general json",
}
form = NotifyForm(data=form_data)
@@ -292,20 +287,19 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
assert mock_post.call_count == 1
mock_post.reset_mock()
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
# Comma (OR)
'tag': 'pushbullet, json',
"tag": "pushbullet, json",
}
form = NotifyForm(data=form_data)
@@ -313,10 +307,9 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
# 2 endpoints hit
@@ -325,7 +318,7 @@ class StatefulNotifyTests(SimpleTestCase):
# Now remove the file directly (as though one
# removed the configuration directory)
result = ConfigCache.path(key)
entry = os.path.join(result[0], '{}.text'.format(result[1]))
entry = os.path.join(result[0], "{}.text".format(result[1]))
assert os.path.isfile(entry)
# The removal
os.unlink(entry)
@@ -333,30 +326,31 @@ class StatefulNotifyTests(SimpleTestCase):
assert not os.path.isfile(entry)
# Call /del/ but now there is nothing to remove
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 204
# Reset our count
mock_post.reset_mock()
@patch('requests.post')
@patch("requests.post")
def test_stateful_group_dict_notify(self, mock_post):
"""
Test the handling of a group defined as a dictionary
"""
# our key to use
key = 'test_stateful_group_notify_dict'
key = "test_stateful_group_notify_dict"
request = Mock()
request.content = b'ok'
request.content = b"ok"
request.status_code = requests.codes.ok
mock_post.return_value = request
# Monkey Patch
N_MGR['mailto'].enabled = True
N_MGR["mailto"].enabled = True
config = inspect.cleandoc("""
config = inspect.cleandoc(
"""
version: 1
groups:
mygroup: user1, user2
@@ -367,37 +361,35 @@ class StatefulNotifyTests(SimpleTestCase):
tag: user1
- to: user2@example.com
tag: user2
""")
"""
)
# Monkey Patch
N_MGR['json'].enabled = True
N_MGR["json"].enabled = True
# Add our content
response = self.client.post(
'/add/{}'.format(key),
{'config': config})
response = self.client.post("/add/{}".format(key), {"config": config})
assert response.status_code == 200
# Now we should be able to see our content
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 200
for tag in ('user1', 'user2'):
for tag in ("user1", "user2"):
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': tag,
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": tag,
}
form = NotifyForm(data=form_data)
assert form.is_valid()
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# We sent the notification successfully
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
# Our single endpoint is notified
@@ -407,9 +399,9 @@ class StatefulNotifyTests(SimpleTestCase):
# Now let's notify by our group
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': 'mygroup',
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": "mygroup",
}
form = NotifyForm(data=form_data)
@@ -417,11 +409,10 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# We sent the notification successfully
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
# Our 2 endpoints are notified
@@ -430,30 +421,31 @@ class StatefulNotifyTests(SimpleTestCase):
mock_post.reset_mock()
# Now empty our data
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 200
# Reset our count
mock_post.reset_mock()
@patch('requests.post')
@patch("requests.post")
def test_stateful_group_dictlist_notify(self, mock_post):
"""
Test the handling of a group defined as a list of dictionaries
"""
# our key to use
key = 'test_stateful_group_notify_list_dict'
key = "test_stateful_group_notify_list_dict"
request = Mock()
request.content = b'ok'
request.content = b"ok"
request.status_code = requests.codes.ok
mock_post.return_value = request
# Monkey Patch
N_MGR['mailto'].enabled = True
N_MGR["mailto"].enabled = True
config = inspect.cleandoc("""
config = inspect.cleandoc(
"""
version: 1
groups:
- mygroup: user1, user2
@@ -464,37 +456,35 @@ class StatefulNotifyTests(SimpleTestCase):
tag: user1
- to: user2@example.com
tag: user2
""")
"""
)
# Monkey Patch
N_MGR['json'].enabled = True
N_MGR["json"].enabled = True
# Add our content
response = self.client.post(
'/add/{}'.format(key),
{'config': config})
response = self.client.post("/add/{}".format(key), {"config": config})
assert response.status_code == 200
# Now we should be able to see our content
response = self.client.post('/get/{}'.format(key))
response = self.client.post("/get/{}".format(key))
assert response.status_code == 200
for tag in ('user1', 'user2'):
for tag in ("user1", "user2"):
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': tag,
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": tag,
}
form = NotifyForm(data=form_data)
assert form.is_valid()
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# We sent the notification successfully
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
# Our single endpoint is notified
@@ -504,9 +494,9 @@ class StatefulNotifyTests(SimpleTestCase):
# Now let's notify by our group
form_data = {
'body': '## test notification',
'format': apprise.NotifyFormat.MARKDOWN,
'tag': 'mygroup',
"body": "## test notification",
"format": apprise.NotifyFormat.MARKDOWN.value,
"tag": "mygroup",
}
form = NotifyForm(data=form_data)
@@ -514,11 +504,10 @@ class StatefulNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# We sent the notification successfully
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
response = self.client.post("/notify/{}".format(key), form.cleaned_data)
assert response.status_code == 200
# Our 2 endpoints are notified
@@ -527,7 +516,7 @@ class StatefulNotifyTests(SimpleTestCase):
mock_post.reset_mock()
# Now empty our data
response = self.client.post('/del/{}'.format(key))
response = self.client.post("/del/{}".format(key))
assert response.status_code == 200
# Reset our count

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,16 +21,19 @@
# 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.
from django.test import SimpleTestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.exceptions import RequestDataTooBig
from django.test.utils import override_settings
from unittest import mock
from ..forms import NotifyByUrlForm
import requests
import json
from unittest import mock
from django.core.exceptions import RequestDataTooBig
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase
from django.test.utils import override_settings
import requests
import apprise
from ..forms import NotifyByUrlForm
# Grant access to our Notification Manager Singleton
N_MGR = apprise.manager_plugins.NotificationManager()
@@ -41,7 +43,7 @@ class StatelessNotifyTests(SimpleTestCase):
Test stateless notifications
"""
@mock.patch('apprise.Apprise.notify')
@mock.patch("apprise.Apprise.notify")
def test_notify(self, mock_notify):
"""
Test sending a simple notification
@@ -52,8 +54,8 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our form data
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
"urls": "mailto://user:pass@hotmail.com",
"body": "test notifiction",
}
# At a minimum 'body' is requred
@@ -61,26 +63,26 @@ class StatelessNotifyTests(SimpleTestCase):
assert form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
# Reset our count
mock_notify.reset_mock()
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
'format': apprise.NotifyFormat.MARKDOWN,
"urls": "mailto://user:pass@hotmail.com",
"body": "test notifiction",
"format": apprise.NotifyFormat.MARKDOWN.value,
}
form = NotifyByUrlForm(data=form_data)
assert form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
@@ -88,19 +90,22 @@ class StatelessNotifyTests(SimpleTestCase):
mock_notify.reset_mock()
# Test Headers
for level in ('CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG',
'TRACE', 'INVALID'):
for level in (
"CRITICAL",
"ERROR",
"WARNING",
"INFO",
"DEBUG",
"TRACE",
"INVALID",
):
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
'format': apprise.NotifyFormat.MARKDOWN,
"urls": "mailto://user:pass@hotmail.com",
"body": "test notifiction",
"format": apprise.NotifyFormat.MARKDOWN.value,
}
attach_data = {
'attachment': SimpleUploadedFile(
"attach.txt", b"content here", content_type="text/plain")
}
attach_data = {"attachment": SimpleUploadedFile("attach.txt", b"content here", content_type="text/plain")}
# At a minimum, just a body is required
form = NotifyByUrlForm(form_data, attach_data)
@@ -108,12 +113,11 @@ class StatelessNotifyTests(SimpleTestCase):
# Prepare our header
headers = {
'HTTP_X-APPRISE-LOG-LEVEL': level,
"HTTP_X-APPRISE-LOG-LEVEL": level,
}
# Send our notification
response = self.client.post(
'/notify', form.cleaned_data, **headers)
response = self.client.post("/notify", form.cleaned_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
@@ -121,33 +125,32 @@ class StatelessNotifyTests(SimpleTestCase):
mock_notify.reset_mock()
form_data = {
'payload': '## test notification',
'fmt': apprise.NotifyFormat.MARKDOWN,
'extra': 'mailto://user:pass@hotmail.com',
"payload": "## test notification",
"fmt": apprise.NotifyFormat.MARKDOWN.value,
"extra": "mailto://user:pass@hotmail.com",
}
# We sent the notification successfully (use our rule mapping)
# FORM
response = self.client.post(
'/notify/?:payload=body&:fmt=format&:extra=urls',
form_data)
response = self.client.post("/notify/?:payload=body&:fmt=format&:extra=urls", form_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
mock_notify.reset_mock()
form_data = {
'payload': '## test notification',
'fmt': apprise.NotifyFormat.MARKDOWN,
'extra': 'mailto://user:pass@hotmail.com',
"payload": "## test notification",
"fmt": apprise.NotifyFormat.MARKDOWN.value,
"extra": "mailto://user:pass@hotmail.com",
}
# We sent the notification successfully (use our rule mapping)
# JSON
response = self.client.post(
'/notify/?:payload=body&:fmt=format&:extra=urls',
"/notify/?:payload=body&:fmt=format&:extra=urls",
json.dumps(form_data),
content_type="application/json")
content_type="application/json",
)
assert response.status_code == 200
assert mock_notify.call_count == 1
@@ -155,9 +158,11 @@ class StatelessNotifyTests(SimpleTestCase):
# Long Filename
attach_data = {
'attachment': SimpleUploadedFile(
"{}.txt".format('a' * 2000),
b"content here", content_type="text/plain")
"attachment": SimpleUploadedFile(
"{}.txt".format("a" * 2000),
b"content here",
content_type="text/plain",
)
}
# At a minimum, just a body is required
@@ -165,7 +170,7 @@ class StatelessNotifyTests(SimpleTestCase):
assert form.is_valid()
# Send our notification
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
# We fail because the filename is too long
assert response.status_code == 400
@@ -175,20 +180,18 @@ class StatelessNotifyTests(SimpleTestCase):
mock_notify.reset_mock()
# Test Webhooks
with mock.patch('requests.post') as mock_post:
with mock.patch("requests.post") as mock_post:
# Response object
response = mock.Mock()
response.status_code = requests.codes.ok
mock_post.return_value = response
with override_settings(
APPRISE_WEBHOOK_URL='http://localhost/webhook/'):
with override_settings(APPRISE_WEBHOOK_URL="http://localhost/webhook/"):
# Preare our form data
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
'format': apprise.NotifyFormat.MARKDOWN,
"urls": "mailto://user:pass@hotmail.com",
"body": "test notifiction",
"format": apprise.NotifyFormat.MARKDOWN.value,
}
# At a minimum, just a body is required
@@ -197,10 +200,10 @@ class StatelessNotifyTests(SimpleTestCase):
# Required to prevent None from being passed into
# self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# Send our notification
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
# Test our results
assert response.status_code == 200
@@ -214,32 +217,32 @@ class StatelessNotifyTests(SimpleTestCase):
mock_notify.reset_mock()
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
"urls": "mailto://user:pass@hotmail.com",
"body": "test notifiction",
# Invalid formats cause an error
'format': 'invalid'
"format": "invalid",
}
form = NotifyByUrlForm(data=form_data)
assert not form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# Send our notification
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
# Test our results
assert response.status_code == 200
assert mock_notify.call_count == 1
@mock.patch('apprise.NotifyBase.notify')
@mock.patch("apprise.NotifyBase.notify")
def test_partial_notify(self, mock_notify):
"""
Test sending multiple notifications where one fails
"""
# Ensure we're enabled for the purpose of our testing
N_MGR['mailto'].enabled = True
N_MGR["mailto"].enabled = True
# Set our return value; first we return a true, then we fail
# on the second call
@@ -247,11 +250,13 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our form data
form_data = {
'urls': ', '.join([
'mailto://user:pass@hotmail.com',
'mailto://user:pass@gmail.com',
]),
'body': 'test notifiction',
"urls": ", ".join(
[
"mailto://user:pass@hotmail.com",
"mailto://user:pass@gmail.com",
]
),
"body": "test notifiction",
}
# At a minimum 'body' is requred
@@ -259,9 +264,9 @@ class StatelessNotifyTests(SimpleTestCase):
assert form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
assert response.status_code == 424
assert mock_notify.call_count == 2
@@ -270,16 +275,18 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our form data
form_data = {
'body': 'test notifiction',
'urls': ', '.join([
'mailto://user:pass@hotmail.com',
'mailto://user:pass@gmail.com',
]),
'attachment': 'https://localhost/invalid/path/to/image.png',
"body": "test notifiction",
"urls": ", ".join(
[
"mailto://user:pass@hotmail.com",
"mailto://user:pass@gmail.com",
]
),
"attachment": "https://localhost/invalid/path/to/image.png",
}
# Send our notification
response = self.client.post('/notify', form_data)
response = self.client.post("/notify", form_data)
# We fail because we couldn't retrieve our attachment
assert response.status_code == 400
assert mock_notify.call_count == 0
@@ -289,16 +296,18 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our form data (support attach keyword)
form_data = {
'body': 'test notifiction',
'urls': ', '.join([
'mailto://user:pass@hotmail.com',
'mailto://user:pass@gmail.com',
]),
'attach': 'https://localhost/invalid/path/to/image.png',
"body": "test notifiction",
"urls": ", ".join(
[
"mailto://user:pass@hotmail.com",
"mailto://user:pass@gmail.com",
]
),
"attach": "https://localhost/invalid/path/to/image.png",
}
# Send our notification
response = self.client.post('/notify', form_data)
response = self.client.post("/notify", form_data)
# We fail because we couldn't retrieve our attachment
assert response.status_code == 400
assert mock_notify.call_count == 0
@@ -308,19 +317,21 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our json data (and support attach keyword as alias)
json_data = {
'body': 'test notifiction',
'urls': ', '.join([
'mailto://user:pass@hotmail.com',
'mailto://user:pass@gmail.com',
]),
'attach': 'https://localhost/invalid/path/to/image.png',
"body": "test notifiction",
"urls": ", ".join(
[
"mailto://user:pass@hotmail.com",
"mailto://user:pass@gmail.com",
]
),
"attach": "https://localhost/invalid/path/to/image.png",
}
# Same results
response = self.client.post(
'/notify/',
"/notify/",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# We fail because we couldn't retrieve our attachment
@@ -328,7 +339,7 @@ class StatelessNotifyTests(SimpleTestCase):
assert mock_notify.call_count == 0
@override_settings(APPRISE_RECURSION_MAX=1)
@mock.patch('apprise.Apprise.notify')
@mock.patch("apprise.Apprise.notify")
def test_stateless_notify_recursion(self, mock_notify):
"""
Test recursion an id header details as part of post
@@ -338,15 +349,15 @@ class StatelessNotifyTests(SimpleTestCase):
mock_notify.return_value = True
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
'HTTP_X-APPRISE-RECURSION-COUNT': str(1),
"HTTP_X-APPRISE-ID": "abc123",
"HTTP_X-APPRISE-RECURSION-COUNT": str(1),
}
# Preare our form data (without url specified)
# content will fall back to default configuration
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
"urls": "mailto://user:pass@hotmail.com",
"body": "test notifiction",
}
# Monkey Patch
@@ -357,16 +368,16 @@ class StatelessNotifyTests(SimpleTestCase):
assert form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# recursion value is within correct limits
response = self.client.post('/notify', form.cleaned_data, **headers)
response = self.client.post("/notify", form.cleaned_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
headers = {
# Header specified but with whitespace
'HTTP_X-APPRISE-ID': ' ',
"HTTP_X-APPRISE-ID": " ",
# No Recursion value specified
}
@@ -374,54 +385,54 @@ class StatelessNotifyTests(SimpleTestCase):
mock_notify.reset_mock()
# Recursion limit reached
response = self.client.post('/notify', form.cleaned_data, **headers)
response = self.client.post("/notify", form.cleaned_data, **headers)
assert response.status_code == 200
assert mock_notify.call_count == 1
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
"HTTP_X-APPRISE-ID": "abc123",
# Recursion Limit hit
'HTTP_X-APPRISE-RECURSION-COUNT': str(2),
"HTTP_X-APPRISE-RECURSION-COUNT": str(2),
}
# Reset our count
mock_notify.reset_mock()
# Recursion limit reached
response = self.client.post('/notify', form.cleaned_data, **headers)
response = self.client.post("/notify", form.cleaned_data, **headers)
assert response.status_code == 406
assert mock_notify.call_count == 0
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
"HTTP_X-APPRISE-ID": "abc123",
# Negative recursion value (bad request)
'HTTP_X-APPRISE-RECURSION-COUNT': str(-1),
"HTTP_X-APPRISE-RECURSION-COUNT": str(-1),
}
# Reset our count
mock_notify.reset_mock()
# invalid recursion specified
response = self.client.post('/notify', form.cleaned_data, **headers)
response = self.client.post("/notify", form.cleaned_data, **headers)
assert response.status_code == 400
assert mock_notify.call_count == 0
headers = {
'HTTP_X-APPRISE-ID': 'abc123',
"HTTP_X-APPRISE-ID": "abc123",
# Invalid recursion value (bad request)
'HTTP_X-APPRISE-RECURSION-COUNT': 'invalid',
"HTTP_X-APPRISE-RECURSION-COUNT": "invalid",
}
# Reset our count
mock_notify.reset_mock()
# invalid recursion specified
response = self.client.post('/notify', form.cleaned_data, **headers)
response = self.client.post("/notify", form.cleaned_data, **headers)
assert response.status_code == 400
assert mock_notify.call_count == 0
@override_settings(APPRISE_STATELESS_URLS="mailto://user:pass@localhost")
@mock.patch('apprise.Apprise.notify')
@mock.patch("apprise.Apprise.notify")
def test_notify_default_urls(self, mock_notify):
"""
Test fallback to default URLS if none were otherwise specified
@@ -434,7 +445,7 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our form data (without url specified)
# content will fall back to default configuration
form_data = {
'body': 'test notifiction',
"body": "test notifiction",
}
# At a minimum 'body' is requred
@@ -442,14 +453,14 @@ class StatelessNotifyTests(SimpleTestCase):
assert form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
del form.cleaned_data["attachment"]
# This still works as the environment variable kicks in
response = self.client.post('/notify', form.cleaned_data)
response = self.client.post("/notify", form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
@mock.patch('apprise.Apprise.notify')
@mock.patch("apprise.Apprise.notify")
def test_notify_with_get_parameters(self, mock_notify):
"""
Test sending a simple notification using JSON with GET
@@ -461,15 +472,15 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our JSON data
json_data = {
'urls': 'json://user@my.domain.ca',
'body': 'test notifiction',
"urls": "json://user@my.domain.ca",
"body": "test notifiction",
}
# Send our notification as a JSON object
response = self.client.post(
'/notify/?title=my%20title&format=text&type=info',
"/notify/?title=my%20title&format=text&type=info",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# Still supported
@@ -479,48 +490,48 @@ class StatelessNotifyTests(SimpleTestCase):
# Reset our count
mock_notify.reset_mock()
with mock.patch('json.loads') as mock_loads:
with mock.patch("json.loads") as mock_loads:
mock_loads.side_effect = RequestDataTooBig()
# Send our notification
response = self.client.post(
'/notify/?title=my%20title&format=text&type=info',
"/notify/?title=my%20title&format=text&type=info",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# Our notification failed
assert response.status_code == 431
assert mock_notify.call_count == 0
@mock.patch('apprise.Apprise.notify')
@mock.patch("apprise.Apprise.notify")
def test_notify_html_response_block(self, mock_notify):
"""
Test HTML log formatting block is triggered in StatelessNotifyView
"""
mock_notify.return_value = True
form_data = {
'urls': 'json://user@localhost',
'body': 'Testing HTML block',
'type': apprise.NotifyType.INFO,
"urls": "json://user@localhost",
"body": "Testing HTML block",
"type": apprise.NotifyType.INFO.value,
}
headers = {
'HTTP_Accept': 'text/html',
"HTTP_Accept": "text/html",
}
response = self.client.post(
'/notify',
"/notify",
data=form_data,
**headers,
)
assert response.status_code == 200
assert mock_notify.call_count == 1
assert response['Content-Type'].startswith('text/html')
assert response["Content-Type"].startswith("text/html")
assert b'<ul class="logs">' in response.content
assert b'class="logs"' in response.content
@mock.patch('apprise.Apprise.notify')
@mock.patch("apprise.Apprise.notify")
def test_notify_by_loaded_urls_with_json(self, mock_notify):
"""
Test sending a simple notification using JSON
@@ -531,16 +542,16 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our JSON data without any urls
json_data = {
'urls': '',
'body': 'test notifiction',
'type': apprise.NotifyType.WARNING,
"urls": "",
"body": "test notifiction",
"type": apprise.NotifyType.WARNING.value,
}
# Send our empty notification as a JSON object
response = self.client.post(
'/notify',
"/notify",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# Nothing notified
@@ -549,16 +560,16 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our JSON data
json_data = {
'urls': 'mailto://user:pass@yahoo.ca',
'body': 'test notifiction',
'type': apprise.NotifyType.WARNING,
"urls": "mailto://user:pass@yahoo.ca",
"body": "test notifiction",
"type": apprise.NotifyType.WARNING.value,
}
# Send our notification as a JSON object
response = self.client.post(
'/notify',
"/notify",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# Still supported
@@ -570,9 +581,9 @@ class StatelessNotifyTests(SimpleTestCase):
# Test sending a garbage JSON object
response = self.client.post(
'/notify/',
"/notify/",
data="{",
content_type='application/json',
content_type="application/json",
)
assert response.status_code == 400
@@ -580,9 +591,9 @@ class StatelessNotifyTests(SimpleTestCase):
# Test sending with an invalid content type
response = self.client.post(
'/notify',
"/notify",
data="{}",
content_type='application/xml',
content_type="application/xml",
)
assert response.status_code == 400
@@ -590,9 +601,9 @@ class StatelessNotifyTests(SimpleTestCase):
# Test sending without any content at all
response = self.client.post(
'/notify/',
"/notify/",
data="{}",
content_type='application/json',
content_type="application/json",
)
assert response.status_code == 400
@@ -600,13 +611,13 @@ class StatelessNotifyTests(SimpleTestCase):
# Test sending without a body
json_data = {
'type': apprise.NotifyType.WARNING,
"type": apprise.NotifyType.WARNING.value,
}
response = self.client.post(
'/notify',
"/notify",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
assert response.status_code == 400
@@ -617,24 +628,24 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our JSON data
json_data = {
'urls': 'mailto://user:pass@yahoo.ca',
'body': 'test notifiction',
"urls": "mailto://user:pass@yahoo.ca",
"body": "test notifiction",
# invalid server side format
'format': 'invalid'
"format": "invalid",
}
# Send our notification as a JSON object
response = self.client.post(
'/notify',
"/notify",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# Still supported
assert response.status_code == 400
assert mock_notify.call_count == 0
@mock.patch('apprise.plugins.custom_json.NotifyJSON.send')
@mock.patch("apprise.plugins.custom_json.NotifyJSON.send")
def test_notify_with_filters(self, mock_send):
"""
Test workings of APPRISE_DENY_SERVICES and APPRISE_ALLOW_SERVICES
@@ -645,145 +656,143 @@ class StatelessNotifyTests(SimpleTestCase):
# Preare our JSON data
json_data = {
'urls': 'json://user:pass@yahoo.ca',
'body': 'test notifiction',
'type': apprise.NotifyType.WARNING,
"urls": "json://user:pass@yahoo.ca",
"body": "test notifiction",
"type": apprise.NotifyType.WARNING.value,
}
# Send our notification as a JSON object
response = self.client.post(
'/notify',
"/notify",
data=json.dumps(json_data),
content_type='application/json',
content_type="application/json",
)
# Ensure we're enabled for the purpose of our testing
N_MGR['json'].enabled = True
N_MGR["json"].enabled = True
# Reset Mock
mock_send.reset_mock()
# Send our service with the `json://` denied
with override_settings(APPRISE_ALLOW_SERVICES=""):
# Test our stateless storage setting (just to kill 2 birds with 1 stone)
with override_settings(APPRISE_STATELESS_STORAGE="yes"):
with override_settings(APPRISE_DENY_SERVICES="json"):
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
with override_settings(APPRISE_ALLOW_SERVICES=""), \
override_settings(APPRISE_STATELESS_STORAGE="yes"), \
override_settings(APPRISE_DENY_SERVICES="json"):
# json:// is disabled
assert response.status_code == 204
assert mock_send.call_count == 0
# Send our notification as a JSON object
response = self.client.post(
"/notify",
data=json.dumps(json_data),
content_type="application/json",
)
# What actually took place behind close doors:
assert N_MGR['json'].enabled is False
# json:// is disabled
assert response.status_code == 204
assert mock_send.call_count == 0
# Reset our flag (for next test)
N_MGR['json'].enabled = True
# What actually took place behind close doors:
assert N_MGR["json"].enabled is False
# Reset our flag (for next test)
N_MGR["json"].enabled = True
# Reset Mock
mock_send.reset_mock()
# Send our service with the `json://` denied
with override_settings(APPRISE_ALLOW_SERVICES=""):
with override_settings(APPRISE_DENY_SERVICES="invalid, syslog"):
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
with override_settings(APPRISE_ALLOW_SERVICES=""), \
override_settings(APPRISE_DENY_SERVICES="invalid, syslog"):
# json:// is enabled
assert response.status_code == 200
assert mock_send.call_count == 1
# Send our notification as a JSON object
response = self.client.post(
"/notify",
data=json.dumps(json_data),
content_type="application/json",
)
# Verify that json was never turned off
assert N_MGR['json'].enabled is True
# json:// is enabled
assert response.status_code == 200
assert mock_send.call_count == 1
# Verify that json was never turned off
assert N_MGR["json"].enabled is True
# Reset Mock
mock_send.reset_mock()
# Send our service with the `json://` being the only accepted type
with override_settings(APPRISE_ALLOW_SERVICES="json"):
with override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
with override_settings(APPRISE_ALLOW_SERVICES="json"), override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
"/notify",
data=json.dumps(json_data),
content_type="application/json",
)
# json:// is enabled
assert response.status_code == 200
assert mock_send.call_count == 1
# json:// is enabled
assert response.status_code == 200
assert mock_send.call_count == 1
# Verify email was never turned off
assert N_MGR['json'].enabled is True
# Verify email was never turned off
assert N_MGR["json"].enabled is True
# Reset Mock
mock_send.reset_mock()
# Send our service with the `json://` being the only accepted type
with override_settings(APPRISE_ALLOW_SERVICES="invalid, jsons"):
with override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
with override_settings(APPRISE_ALLOW_SERVICES="invalid, jsons"), \
override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
"/notify",
data=json.dumps(json_data),
content_type="application/json",
)
# json:// is enabled
assert response.status_code == 200
assert mock_send.call_count == 1
# json:// is enabled
assert response.status_code == 200
assert mock_send.call_count == 1
# Verify email was never turned off
assert N_MGR['json'].enabled is True
# Verify email was never turned off
assert N_MGR["json"].enabled is True
# Reset Mock
mock_send.reset_mock()
# Send our service with the `json://` being the only accepted type
with override_settings(APPRISE_ALLOW_SERVICES="syslog"):
with override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
with override_settings(APPRISE_ALLOW_SERVICES="syslog"), override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
"/notify",
data=json.dumps(json_data),
content_type="application/json",
)
# json:// is disabled
assert response.status_code == 204
assert mock_send.call_count == 0
# json:// is disabled
assert response.status_code == 204
assert mock_send.call_count == 0
# What actually took place behind close doors:
assert N_MGR['json'].enabled is False
# What actually took place behind close doors:
assert N_MGR["json"].enabled is False
# Reset our flag (for next test)
N_MGR['json'].enabled = True
# Reset our flag (for next test)
N_MGR["json"].enabled = True
# Reset Mock
mock_send.reset_mock()
# Test case where there is simply no over-rides defined
with override_settings(APPRISE_ALLOW_SERVICES=""):
with override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
with override_settings(APPRISE_ALLOW_SERVICES=""), override_settings(APPRISE_DENY_SERVICES=""):
# Send our notification as a JSON object
response = self.client.post(
"/notify",
data=json.dumps(json_data),
content_type="application/json",
)
# json:// is disabled
assert response.status_code == 200
assert mock_send.call_count == 1
# json:// is disabled
assert response.status_code == 200
assert mock_send.call_count == 1
# nothing was changed
assert N_MGR['json'].enabled is True
# nothing was changed
assert N_MGR["json"].enabled is True

View File

@@ -0,0 +1,43 @@
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# 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.
from django.http import HttpResponse
from django.test import RequestFactory, SimpleTestCase
from apprise_api.core.middleware.theme import AutoThemeMiddleware, SiteTheme
class AutoThemeMiddlewareTest(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
def test_theme_fallback_when_invalid_theme_given(self):
request = self.factory.get("/", {"theme": "foobar"})
request.COOKIES = {}
def get_response(req):
return HttpResponse()
middleware = AutoThemeMiddleware(get_response)
_response = middleware(request)
self.assertEqual(request.theme, SiteTheme.LIGHT)

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -23,6 +22,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
from ..urlfilter import AppriseURLFilter
@@ -32,289 +32,295 @@ class AttachmentTests(SimpleTestCase):
Test the apprise url filter
"""
# empty allow and deny lists
af = AppriseURLFilter('', '')
af = AppriseURLFilter("", "")
# Test garbage entries
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# These ar blocked too since we have no allow list
self.assertFalse(af.is_allowed('http://localhost'))
self.assertFalse(af.is_allowed('http://localhost'))
self.assertFalse(af.is_allowed("http://localhost"))
self.assertFalse(af.is_allowed("http://localhost"))
#
# We have a wildcard for accept all in our allow list
#
af = AppriseURLFilter('*', '')
af = AppriseURLFilter("*", "")
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# We however allow localhost now (caught with *)
self.assertTrue(af.is_allowed('http://localhost'))
self.assertTrue(af.is_allowed('http://localhost/resources'))
self.assertTrue(af.is_allowed('http://localhost/images'))
self.assertTrue(af.is_allowed("http://localhost"))
self.assertTrue(af.is_allowed("http://localhost/resources"))
self.assertTrue(af.is_allowed("http://localhost/images"))
#
# Allow list accepts all, except we want to explicitely block https://localhost/resources
#
af = AppriseURLFilter('*', 'https://localhost/resources')
af = AppriseURLFilter("*", "https://localhost/resources")
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# Takeaway is https:// was blocked to resources, but not http:// request
# because it was explicitly identify as so:
self.assertTrue(af.is_allowed('http://localhost'))
self.assertTrue(af.is_allowed('http://localhost/resources'))
self.assertTrue(af.is_allowed('http://localhost/resources/sub/path/'))
self.assertFalse(af.is_allowed('https://localhost/resources'))
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path/'))
self.assertTrue(af.is_allowed('http://localhost/images'))
self.assertTrue(af.is_allowed("http://localhost"))
self.assertTrue(af.is_allowed("http://localhost/resources"))
self.assertTrue(af.is_allowed("http://localhost/resources/sub/path/"))
self.assertFalse(af.is_allowed("https://localhost/resources"))
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path/"))
self.assertTrue(af.is_allowed("http://localhost/images"))
#
# Allow list accepts all, except we want to explicitely block both
# https://localhost/resources and http://localhost/resources
#
af = AppriseURLFilter('*', 'localhost/resources')
af = AppriseURLFilter("*", "localhost/resources")
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# Takeaway is https:// was blocked to resources, but not http:// request
# because it was explicitly identify as so:
self.assertTrue(af.is_allowed('http://localhost'))
self.assertFalse(af.is_allowed('http://localhost/resources'))
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path'))
self.assertFalse(af.is_allowed('https://localhost/resources'))
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path/'))
self.assertTrue(af.is_allowed('http://localhost/images'))
self.assertTrue(af.is_allowed("http://localhost"))
self.assertFalse(af.is_allowed("http://localhost/resources"))
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path"))
self.assertFalse(af.is_allowed("https://localhost/resources"))
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path/"))
self.assertTrue(af.is_allowed("http://localhost/images"))
#
# A more restrictive allow/block list
# https://localhost/resources and http://localhost/resources
#
af = AppriseURLFilter('https://localhost, http://myserver.*', 'localhost/resources')
af = AppriseURLFilter("https://localhost, http://myserver.*", "localhost/resources")
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# Explicitly only allows https
self.assertFalse(af.is_allowed('http://localhost'))
self.assertTrue(af.is_allowed('https://localhost'))
self.assertFalse(af.is_allowed('https://localhost:8000'))
self.assertTrue(af.is_allowed('https://localhost/images'))
self.assertFalse(af.is_allowed('https://localhost/resources'))
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path/'))
self.assertFalse(af.is_allowed('http://localhost/resources'))
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path'))
self.assertFalse(af.is_allowed('http://not-in-list'))
self.assertFalse(af.is_allowed("http://localhost"))
self.assertTrue(af.is_allowed("https://localhost"))
self.assertFalse(af.is_allowed("https://localhost:8000"))
self.assertTrue(af.is_allowed("https://localhost/images"))
self.assertFalse(af.is_allowed("https://localhost/resources"))
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path/"))
self.assertFalse(af.is_allowed("http://localhost/resources"))
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path"))
self.assertFalse(af.is_allowed("http://not-in-list"))
# Explicitly definition of allowed hostname prohibits the below from working:
self.assertFalse(af.is_allowed('localhost'))
self.assertFalse(af.is_allowed("localhost"))
#
# Testing of hostnames only and ports
#
af = AppriseURLFilter('localhost, myserver:3000', 'localhost/resources')
af = AppriseURLFilter("localhost, myserver:3000", "localhost/resources")
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# all forms of localhost is allowed (provided there is no port)
self.assertTrue(af.is_allowed('http://localhost'))
self.assertTrue(af.is_allowed('https://localhost'))
self.assertFalse(af.is_allowed('https://localhost:8000'))
self.assertFalse(af.is_allowed('https://localhost:80'))
self.assertFalse(af.is_allowed('https://localhost:443'))
self.assertTrue(af.is_allowed('https://localhost/images'))
self.assertFalse(af.is_allowed('https://localhost/resources'))
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path'))
self.assertFalse(af.is_allowed('http://localhost/resources'))
self.assertTrue(af.is_allowed('http://localhost/resourcesssssssss'))
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path/'))
self.assertFalse(af.is_allowed('http://not-in-list'))
self.assertTrue(af.is_allowed("http://localhost"))
self.assertTrue(af.is_allowed("https://localhost"))
self.assertFalse(af.is_allowed("https://localhost:8000"))
self.assertFalse(af.is_allowed("https://localhost:80"))
self.assertFalse(af.is_allowed("https://localhost:443"))
self.assertTrue(af.is_allowed("https://localhost/images"))
self.assertFalse(af.is_allowed("https://localhost/resources"))
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path"))
self.assertFalse(af.is_allowed("http://localhost/resources"))
self.assertTrue(af.is_allowed("http://localhost/resourcesssssssss"))
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path/"))
self.assertFalse(af.is_allowed("http://not-in-list"))
# myserver is only allowed if port is provided
self.assertFalse(af.is_allowed('http://myserver'))
self.assertFalse(af.is_allowed('https://myserver'))
self.assertTrue(af.is_allowed('http://myserver:3000'))
self.assertTrue(af.is_allowed('https://myserver:3000'))
self.assertFalse(af.is_allowed("http://myserver"))
self.assertFalse(af.is_allowed("https://myserver"))
self.assertTrue(af.is_allowed("http://myserver:3000"))
self.assertTrue(af.is_allowed("https://myserver:3000"))
# Open range of hosts allows these to be accepted:
self.assertTrue(af.is_allowed('localhost'))
self.assertTrue(af.is_allowed('myserver:3000'))
self.assertTrue(af.is_allowed('https://myserver:3000'))
self.assertTrue(af.is_allowed('http://myserver:3000'))
self.assertTrue(af.is_allowed("localhost"))
self.assertTrue(af.is_allowed("myserver:3000"))
self.assertTrue(af.is_allowed("https://myserver:3000"))
self.assertTrue(af.is_allowed("http://myserver:3000"))
#
# Testing of hostnames only and ports but via URLs (explicit http://)
# Also tests path ending with `/` (slash)
#
af = AppriseURLFilter('http://localhost, http://myserver:3000', 'http://localhost/resources/')
af = AppriseURLFilter(
"http://localhost, http://myserver:3000",
"http://localhost/resources/",
)
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# http://localhost acceptance only
self.assertTrue(af.is_allowed('http://localhost'))
self.assertFalse(af.is_allowed('https://localhost'))
self.assertFalse(af.is_allowed('http://localhost:8000'))
self.assertFalse(af.is_allowed('http://localhost:80'))
self.assertTrue(af.is_allowed('http://localhost/images'))
self.assertFalse(af.is_allowed('https://localhost/images'))
self.assertFalse(af.is_allowed('https://localhost/resources'))
self.assertFalse(af.is_allowed('https://localhost/resources/sub/path'))
self.assertFalse(af.is_allowed('http://localhost/resources'))
self.assertFalse(af.is_allowed('http://not-in-list'))
self.assertTrue(af.is_allowed("http://localhost"))
self.assertFalse(af.is_allowed("https://localhost"))
self.assertFalse(af.is_allowed("http://localhost:8000"))
self.assertFalse(af.is_allowed("http://localhost:80"))
self.assertTrue(af.is_allowed("http://localhost/images"))
self.assertFalse(af.is_allowed("https://localhost/images"))
self.assertFalse(af.is_allowed("https://localhost/resources"))
self.assertFalse(af.is_allowed("https://localhost/resources/sub/path"))
self.assertFalse(af.is_allowed("http://localhost/resources"))
self.assertFalse(af.is_allowed("http://not-in-list"))
# myserver is only allowed if port is provided and http://
self.assertFalse(af.is_allowed('http://myserver'))
self.assertFalse(af.is_allowed('https://myserver'))
self.assertTrue(af.is_allowed('http://myserver:3000'))
self.assertFalse(af.is_allowed('https://myserver:3000'))
self.assertTrue(af.is_allowed('http://myserver:3000/path/'))
self.assertFalse(af.is_allowed("http://myserver"))
self.assertFalse(af.is_allowed("https://myserver"))
self.assertTrue(af.is_allowed("http://myserver:3000"))
self.assertFalse(af.is_allowed("https://myserver:3000"))
self.assertTrue(af.is_allowed("http://myserver:3000/path/"))
# Open range of hosts is no longer allowed due to explicit http:// reference
self.assertFalse(af.is_allowed('localhost'))
self.assertFalse(af.is_allowed('myserver:3000'))
self.assertFalse(af.is_allowed('https://myserver:3000'))
self.assertFalse(af.is_allowed("localhost"))
self.assertFalse(af.is_allowed("myserver:3000"))
self.assertFalse(af.is_allowed("https://myserver:3000"))
#
# Testing of hostnames only and ports but via URLs (explicit https://)
# Also tests path ending with `/` (slash)
#
af = AppriseURLFilter('https://localhost, https://myserver:3000', 'https://localhost/resources/')
af = AppriseURLFilter(
"https://localhost, https://myserver:3000",
"https://localhost/resources/",
)
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# http://localhost acceptance only
self.assertTrue(af.is_allowed('https://localhost'))
self.assertFalse(af.is_allowed('http://localhost'))
self.assertFalse(af.is_allowed('localhost'))
self.assertFalse(af.is_allowed('https://localhost:8000'))
self.assertFalse(af.is_allowed('https://localhost:80'))
self.assertTrue(af.is_allowed('https://localhost/images'))
self.assertFalse(af.is_allowed('http://localhost/images'))
self.assertFalse(af.is_allowed('http://localhost/resources'))
self.assertFalse(af.is_allowed('http://localhost/resources/sub/path'))
self.assertFalse(af.is_allowed('https://localhost/resources'))
self.assertFalse(af.is_allowed('https://not-in-list'))
self.assertTrue(af.is_allowed("https://localhost"))
self.assertFalse(af.is_allowed("http://localhost"))
self.assertFalse(af.is_allowed("localhost"))
self.assertFalse(af.is_allowed("https://localhost:8000"))
self.assertFalse(af.is_allowed("https://localhost:80"))
self.assertTrue(af.is_allowed("https://localhost/images"))
self.assertFalse(af.is_allowed("http://localhost/images"))
self.assertFalse(af.is_allowed("http://localhost/resources"))
self.assertFalse(af.is_allowed("http://localhost/resources/sub/path"))
self.assertFalse(af.is_allowed("https://localhost/resources"))
self.assertFalse(af.is_allowed("https://not-in-list"))
# myserver is only allowed if port is provided and http://
self.assertFalse(af.is_allowed('https://myserver'))
self.assertFalse(af.is_allowed('http://myserver'))
self.assertFalse(af.is_allowed('myserver'))
self.assertTrue(af.is_allowed('https://myserver:3000'))
self.assertFalse(af.is_allowed('http://myserver:3000'))
self.assertTrue(af.is_allowed('https://myserver:3000/path/'))
self.assertFalse(af.is_allowed("https://myserver"))
self.assertFalse(af.is_allowed("http://myserver"))
self.assertFalse(af.is_allowed("myserver"))
self.assertTrue(af.is_allowed("https://myserver:3000"))
self.assertFalse(af.is_allowed("http://myserver:3000"))
self.assertTrue(af.is_allowed("https://myserver:3000/path/"))
# Open range of hosts is no longer allowed due to explicit http:// reference
self.assertFalse(af.is_allowed('localhost'))
self.assertFalse(af.is_allowed('myserver:3000'))
self.assertFalse(af.is_allowed('http://myserver:3000'))
self.assertFalse(af.is_allowed("localhost"))
self.assertFalse(af.is_allowed("myserver:3000"))
self.assertFalse(af.is_allowed("http://myserver:3000"))
#
# Testing Regular Expressions
#
af = AppriseURLFilter('https://localhost/incoming/*/*', 'https://localhost/*/*/var')
af = AppriseURLFilter("https://localhost/incoming/*/*", "https://localhost/*/*/var")
# We still block junk
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# Very specific paths are supported now in https://localhost only:
self.assertFalse(af.is_allowed('https://localhost'))
self.assertFalse(af.is_allowed('http://localhost'))
self.assertFalse(af.is_allowed('https://localhost/incoming'))
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1'))
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/'))
self.assertFalse(af.is_allowed("https://localhost"))
self.assertFalse(af.is_allowed("http://localhost"))
self.assertFalse(af.is_allowed("https://localhost/incoming"))
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1"))
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/"))
self.assertTrue(af.is_allowed('https://localhost/incoming/dir1/dir2'))
self.assertTrue(af.is_allowed('https://localhost/incoming/dir1/dir2/'))
self.assertFalse(af.is_allowed('http://localhost/incoming/dir1/dir2'))
self.assertFalse(af.is_allowed('http://localhost/incoming/dir1/dir2/'))
self.assertTrue(af.is_allowed("https://localhost/incoming/dir1/dir2"))
self.assertTrue(af.is_allowed("https://localhost/incoming/dir1/dir2/"))
self.assertFalse(af.is_allowed("http://localhost/incoming/dir1/dir2"))
self.assertFalse(af.is_allowed("http://localhost/incoming/dir1/dir2/"))
# our incoming directory we restricted
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var'))
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/'))
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/sub/dir'))
self.assertFalse(af.is_allowed('https://localhost/incoming/dir1/var/sub'))
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var"))
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var/"))
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var/sub/dir"))
self.assertFalse(af.is_allowed("https://localhost/incoming/dir1/var/sub"))
# Test the ? out
af = AppriseURLFilter('localhost?', '')
af = AppriseURLFilter("localhost?", "")
# Test garbage entries
self.assertFalse(af.is_allowed('$'))
self.assertFalse(af.is_allowed(b'13'))
self.assertFalse(af.is_allowed(u'Mālō e Lelei'))
self.assertFalse(af.is_allowed(''))
self.assertFalse(af.is_allowed("$"))
self.assertFalse(af.is_allowed(b"13"))
self.assertFalse(af.is_allowed("Mālō e Lelei"))
self.assertFalse(af.is_allowed(""))
self.assertFalse(af.is_allowed(None))
self.assertFalse(af.is_allowed(True))
self.assertFalse(af.is_allowed(42))
# These are blocked too since we have no allow list
self.assertFalse(af.is_allowed('http://localhost'))
self.assertTrue(af.is_allowed('http://localhost1'))
self.assertTrue(af.is_allowed('https://localhost1'))
self.assertFalse(af.is_allowed('http://localhost%'))
self.assertFalse(af.is_allowed('http://localhost10'))
self.assertFalse(af.is_allowed("http://localhost"))
self.assertTrue(af.is_allowed("http://localhost1"))
self.assertTrue(af.is_allowed("https://localhost1"))
self.assertFalse(af.is_allowed("http://localhost%"))
self.assertFalse(af.is_allowed("http://localhost10"))
# conflicting elements cancel one another
af = AppriseURLFilter('localhost', 'localhost')
af = AppriseURLFilter("localhost", "localhost")
# These are blocked too since we have no allow list
self.assertFalse(af.is_allowed('localhost'))
self.assertFalse(af.is_allowed("localhost"))

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -23,41 +22,41 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import os
import mock
import tempfile
from unittest import mock
from django.test import SimpleTestCase
from .. import utils
class UtilsTests(SimpleTestCase):
def test_touchdir(self):
"""
Test touchdir()
"""
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch('os.makedirs', side_effect=OSError()):
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
with mock.patch("os.makedirs", side_effect=OSError()):
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is False
with mock.patch('os.makedirs', side_effect=FileExistsError()):
with mock.patch("os.makedirs", side_effect=FileExistsError()):
# Dir doesn't exist
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is False
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is True
# Date is updated
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is True
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is True
with mock.patch('os.utime', side_effect=OSError()):
with mock.patch("os.utime", side_effect=OSError()):
# Fails to update file
assert utils.touchdir(os.path.join(tmpdir, 'tmp-file')) is False
assert utils.touchdir(os.path.join(tmpdir, "tmp-file")) is False
def test_touch(self):
"""
Test touch()
"""
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch('os.fdopen', side_effect=OSError()):
assert utils.touch(os.path.join(tmpdir, 'tmp-file')) is False
with tempfile.TemporaryDirectory() as tmpdir, mock.patch("os.fdopen", side_effect=OSError()):
assert utils.touch(os.path.join(tmpdir, "tmp-file")) is False

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,17 +21,18 @@
# 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.
from django.test import SimpleTestCase
from unittest import mock
from json import loads
import requests
from ..utils import send_webhook
from unittest import mock
from django.test import SimpleTestCase
from django.test.utils import override_settings
import requests
from ..utils import send_webhook
class WebhookTests(SimpleTestCase):
@mock.patch('requests.post')
@mock.patch("requests.post")
def test_webhook_testing(self, mock_post):
"""
Test webhook handling
@@ -43,48 +43,45 @@ class WebhookTests(SimpleTestCase):
response.status_code = requests.codes.ok
mock_post.return_value = response
with override_settings(
APPRISE_WEBHOOK_URL='https://'
'user:pass@localhost/webhook'):
with override_settings(APPRISE_WEBHOOK_URL="https://user:pass@localhost/webhook"):
send_webhook({})
assert mock_post.call_count == 1
details = mock_post.call_args_list[0]
assert details[0][0] == 'https://localhost/webhook'
assert loads(details[1]['data']) == {}
assert 'User-Agent' in details[1]['headers']
assert 'Content-Type' in details[1]['headers']
assert details[1]['headers']['User-Agent'] == 'Apprise-API'
assert details[1]['headers']['Content-Type'] == 'application/json'
assert details[1]['auth'] == ('user', 'pass')
assert details[1]['verify'] is True
assert details[1]['params'] == {}
assert details[1]['timeout'] == (4.0, 4.0)
assert details[0][0] == "https://localhost/webhook"
assert loads(details[1]["data"]) == {}
assert "User-Agent" in details[1]["headers"]
assert "Content-Type" in details[1]["headers"]
assert details[1]["headers"]["User-Agent"] == "Apprise-API"
assert details[1]["headers"]["Content-Type"] == "application/json"
assert details[1]["auth"] == ("user", "pass")
assert details[1]["verify"] is True
assert details[1]["params"] == {}
assert details[1]["timeout"] == (4.0, 4.0)
mock_post.reset_mock()
with override_settings(
APPRISE_WEBHOOK_URL='http://'
'user@localhost/webhook/here'
'?verify=False&key=value&cto=2.0&rto=1.0'):
APPRISE_WEBHOOK_URL="http://user@localhost/webhook/here?verify=False&key=value&cto=2.0&rto=1.0"
):
send_webhook({})
assert mock_post.call_count == 1
details = mock_post.call_args_list[0]
assert details[0][0] == 'http://localhost/webhook/here'
assert loads(details[1]['data']) == {}
assert 'User-Agent' in details[1]['headers']
assert 'Content-Type' in details[1]['headers']
assert details[1]['headers']['User-Agent'] == 'Apprise-API'
assert details[1]['headers']['Content-Type'] == 'application/json'
assert details[1]['auth'] == ('user', None)
assert details[1]['verify'] is False
assert details[1]['params'] == {'key': 'value'}
assert details[1]['timeout'] == (2.0, 1.0)
assert details[0][0] == "http://localhost/webhook/here"
assert loads(details[1]["data"]) == {}
assert "User-Agent" in details[1]["headers"]
assert "Content-Type" in details[1]["headers"]
assert details[1]["headers"]["User-Agent"] == "Apprise-API"
assert details[1]["headers"]["Content-Type"] == "application/json"
assert details[1]["auth"] == ("user", None)
assert details[1]["verify"] is False
assert details[1]["params"] == {"key": "value"}
assert details[1]["timeout"] == (2.0, 1.0)
mock_post.reset_mock()
with override_settings(APPRISE_WEBHOOK_URL='invalid'):
with override_settings(APPRISE_WEBHOOK_URL="invalid"):
# Invalid webhook defined
send_webhook({})
assert mock_post.call_count == 0
@@ -98,15 +95,14 @@ class WebhookTests(SimpleTestCase):
mock_post.reset_mock()
with override_settings(APPRISE_WEBHOOK_URL='http://$#@'):
with override_settings(APPRISE_WEBHOOK_URL="http://$#@"):
# Invalid hostname defined
send_webhook({})
assert mock_post.call_count == 0
mock_post.reset_mock()
with override_settings(
APPRISE_WEBHOOK_URL='invalid://hostname'):
with override_settings(APPRISE_WEBHOOK_URL="invalid://hostname"):
# Invalid webhook defined
send_webhook({})
assert mock_post.call_count == 0
@@ -116,9 +112,7 @@ class WebhookTests(SimpleTestCase):
# A valid URL with a bad server response:
response.status_code = requests.codes.internal_server_error
mock_post.return_value = response
with override_settings(
APPRISE_WEBHOOK_URL='http://localhost'):
with override_settings(APPRISE_WEBHOOK_URL="http://localhost"):
send_webhook({})
assert mock_post.call_count == 1
@@ -127,8 +121,6 @@ class WebhookTests(SimpleTestCase):
# A valid URL with a bad server response:
mock_post.return_value = None
mock_post.side_effect = requests.RequestException("error")
with override_settings(
APPRISE_WEBHOOK_URL='http://localhost'):
with override_settings(APPRISE_WEBHOOK_URL="http://localhost"):
send_webhook({})
assert mock_post.call_count == 1

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -26,7 +25,6 @@ from django.test import SimpleTestCase
class WelcomePageTests(SimpleTestCase):
def test_welcome_page_status_code(self):
response = self.client.get('/')
response = self.client.get("/")
assert response.status_code == 200

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -23,6 +22,7 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import re
from apprise.utils.parse import parse_url
@@ -57,12 +57,12 @@ class AppriseURLFilter:
"""
Split the list (tokens separated by whitespace or commas) and compile each token.
Tokens are classified as follows:
- URLbased tokens: if they start with “http://” or “https://” (explicit)
- URL-based tokens: if they start with “http://” or “https://” (explicit)
or if they contain a “/” (implicit; no scheme given).
- Hostbased tokens: those that do not contain a “/”.
- Host-based tokens: those that do not contain a “/”.
Returns a list of tuples (compiled_regex, is_url_based).
"""
tokens = re.split(r'[\s,]+', list_str.strip().lower())
tokens = re.split(r"[\s,]+", list_str.strip().lower())
rules = []
for token in tokens:
if not token:
@@ -88,7 +88,7 @@ class AppriseURLFilter:
def _compile_url_token(self, token: str):
"""
Compiles a URLbased token (explicit token that starts with a scheme) into a regex.
Compiles a URL-based token (explicit token that starts with a scheme) into a regex.
An implied trailing wildcard is added to the path:
- If no path is given (or just “/”) then “(/.*)?” is appended.
- If a nonempty path is given that does not end with “/” or “*”, then “($|/.*)” is appended.
@@ -99,18 +99,18 @@ class AppriseURLFilter:
# Determine the scheme.
scheme_regex = ""
if token.startswith("http://"):
scheme_regex = r'http'
scheme_regex = r"http"
# drop http://
token = token[7:]
elif token.startswith("https://"):
scheme_regex = r'https'
scheme_regex = r"https"
# drop https://
token = token[8:]
else: # https?
# Used for implicit tokens; our _compile_implicit_token ensures this.
scheme_regex = r'https?'
scheme_regex = r"https?"
# strip https?://
token = token[9:]
@@ -173,8 +173,8 @@ class AppriseURLFilter:
def _compile_host_token(self, token: str):
"""
Compiles a hostbased token (one with no “/”) into a regex.
Note: When matching hostbased tokens, we require that the URLs scheme is exactly “http”.
Compiles a host-based token (one with no "/") into a regex.
Note: When matching host-based tokens, we require that the URL's scheme is exactly "http".
"""
regex = "^" + self._wildcard_to_regex(token) + "$"
return re.compile(regex, re.IGNORECASE)
@@ -190,11 +190,11 @@ class AppriseURLFilter:
"""
regex = ""
for char in pattern:
if char == '*':
regex += r"[^/]+/?" if not is_host else r'.*'
if char == "*":
regex += r"[^/]+/?" if not is_host else r".*"
elif char == '?':
regex += r'[^/]' if not is_host else r"[A-Za-z0-9_-]"
elif char == "?":
regex += r"[^/]" if not is_host else r"[A-Za-z0-9_-]"
else:
regex += re.escape(char)
@@ -205,14 +205,15 @@ class AppriseURLFilter:
"""
Checks a given URL against the deny list first, then the allow list.
For URL-based rules (explicit or implicit), the full URL is tested.
For host-based rules, the URLs netloc (which includes the port) is tested.
For host-based rules, the URL's netloc (which includes the port) is tested.
"""
parsed = parse_url(url, strict_port=True, simple=True)
if not parsed:
return False
# includes port if present
netloc = '%s:%d' % (parsed['host'], parsed.get('port')) if parsed.get('port') else parsed['host']
port = parsed.get("port")
netloc = f"{parsed['host']}:{port}" if port is not None else parsed["host"]
# Check deny rules first.
for pattern, is_url_based in self.deny_rules:

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -23,40 +22,31 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.urls import re_path
from . import views
urlpatterns = [
re_path(r"^$", views.WelcomeView.as_view(), name="welcome"),
re_path(r"^status/?$", views.HealthCheckView.as_view(), name="health"),
re_path(r"^details/?$", views.DetailsView.as_view(), name="details"),
re_path(
r'^$',
views.WelcomeView.as_view(), name='welcome'),
r"^cfg/(?P<key>[\w_-]{1,128})/?$",
views.ConfigView.as_view(),
name="config",
),
re_path(r"^cfg/?$", views.ConfigListView.as_view(), name="config_list"),
re_path(r"^add/(?P<key>[\w_-]{1,128})/?$", views.AddView.as_view(), name="add"),
re_path(r"^del/(?P<key>[\w_-]{1,128})/?$", views.DelView.as_view(), name="del"),
re_path(r"^get/(?P<key>[\w_-]{1,128})/?$", views.GetView.as_view(), name="get"),
re_path(
r'^status/?$',
views.HealthCheckView.as_view(), name='health'),
r"^notify/(?P<key>[\w_-]{1,128})/?$",
views.NotifyView.as_view(),
name="notify",
),
re_path(r"^notify/?$", views.StatelessNotifyView.as_view(), name="s_notify"),
re_path(
r'^details/?$',
views.DetailsView.as_view(), name='details'),
re_path(
r'^cfg/(?P<key>[\w_-]{1,128})/?$',
views.ConfigView.as_view(), name='config'),
re_path(
r'^cfg/?$',
views.ConfigListView.as_view(), name='config_list'),
re_path(
r'^add/(?P<key>[\w_-]{1,128})/?$',
views.AddView.as_view(), name='add'),
re_path(
r'^del/(?P<key>[\w_-]{1,128})/?$',
views.DelView.as_view(), name='del'),
re_path(
r'^get/(?P<key>[\w_-]{1,128})/?$',
views.GetView.as_view(), name='get'),
re_path(
r'^notify/(?P<key>[\w_-]{1,128})/?$',
views.NotifyView.as_view(), name='notify'),
re_path(
r'^notify/?$',
views.StatelessNotifyView.as_view(), name='s_notify'),
re_path(
r'^json/urls/(?P<key>[\w_-]{1,128})/?$',
views.JsonUrlView.as_view(), name='json_urls'),
r"^json/urls/(?P<key>[\w_-]{1,128})/?$",
views.JsonUrlView.as_view(),
name="json_urls",
),
]

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,54 +21,60 @@
# 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 re
import binascii
import os
import tempfile
import shutil
import gzip
import apprise
import hashlib
import errno
import base64
import requests
from json import dumps
from django.conf import settings
import binascii
from contextlib import suppress
from datetime import datetime
from . urlfilter import AppriseURLFilter
import errno
import gzip
import hashlib
from json import dumps
# import the logging library
import logging
import os
import re
import shutil
import tempfile
from django.conf import settings
import requests
import apprise
from .urlfilter import AppriseURLFilter
# Get an instance of a logger
logger = logging.getLogger('django')
logger = logging.getLogger("django")
class AppriseStoreMode(object):
class AppriseStoreMode:
"""
Defines the store modes of configuration
"""
# This is the default option. Content is cached and written by
# it's key
HASH = 'hash'
HASH = "hash"
# Content is written straight to disk using it's key
# there is nothing further done
SIMPLE = 'simple'
SIMPLE = "simple"
# When set to disabled; stateful functionality is disabled
DISABLED = 'disabled'
DISABLED = "disabled"
class AttachmentPayload(object):
class AttachmentPayload:
"""
Defines the supported Attachment Payload Types
"""
# BASE64
BASE64 = 'base64'
BASE64 = "base64"
# URL request
URL = 'url'
URL = "url"
STORE_MODES = (
@@ -88,7 +93,7 @@ N_MGR = apprise.manager_plugins.NotificationManager()
ATTACH_URL_FILTER = AppriseURLFilter(settings.APPRISE_ATTACH_ALLOW_URLS, settings.APPRISE_ATTACH_DENY_URLS)
class Attachment(A_MGR['file']):
class Attachment(A_MGR["file"]):
"""
A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise
Attachments
@@ -106,8 +111,7 @@ class Attachment(A_MGR['file']):
except OSError:
# Permission error
raise ValueError('Could not create directory {}'.format(
settings.APPRISE_ATTACH_DIR))
raise ValueError("Could not create directory {}".format(settings.APPRISE_ATTACH_DIR)) from None
if not path:
try:
@@ -117,8 +121,8 @@ class Attachment(A_MGR['file']):
except FileNotFoundError:
raise ValueError(
'Could not prepare {} attachment in {}'.format(
filename, settings.APPRISE_ATTACH_DIR))
"Could not prepare {} attachment in {}".format(filename, settings.APPRISE_ATTACH_DIR)
) from None
self._path = path
@@ -144,14 +148,12 @@ class Attachment(A_MGR['file']):
De-Construtor is used to tidy up files during garbage collection
"""
if self.delete and self._path:
try:
# no problem if file is missing
with suppress(FileNotFoundError):
os.remove(self._path)
except FileNotFoundError:
# no problem
pass
class HTTPAttachment(A_MGR['http']):
class HTTPAttachment(A_MGR["http"]):
"""
A Light Weight Attachment Object for Auto-cleanup that wraps the Apprise
Web Attachments
@@ -169,8 +171,7 @@ class HTTPAttachment(A_MGR['http']):
except OSError:
# Permission error
raise ValueError('Could not create directory {}'.format(
settings.APPRISE_ATTACH_DIR))
raise ValueError("Could not create directory {}".format(settings.APPRISE_ATTACH_DIR)) from None
try:
d, self._path = tempfile.mkstemp(dir=settings.APPRISE_ATTACH_DIR)
@@ -179,8 +180,8 @@ class HTTPAttachment(A_MGR['http']):
except FileNotFoundError:
raise ValueError(
'Could not prepare {} attachment in {}'.format(
filename, settings.APPRISE_ATTACH_DIR))
"Could not prepare {} attachment in {}".format(filename, settings.APPRISE_ATTACH_DIR)
) from None
# Prepare our item
super().__init__(name=filename, **kwargs)
@@ -204,11 +205,9 @@ class HTTPAttachment(A_MGR['http']):
De-Construtor is used to tidy up files during garbage collection
"""
if self.delete and self._path:
try:
# no problem if file is missing
with suppress(FileNotFoundError):
os.remove(self._path)
except FileNotFoundError:
# no problem
pass
def touchdir(path, mode=0o770, **kwargs):
@@ -238,8 +237,11 @@ def touch(fname, mode=0o666, dir_fd=None, **kwargs):
flags = os.O_CREAT | os.O_APPEND
try:
with os.fdopen(os.open(fname, flags=flags, mode=mode, dir_fd=dir_fd)) as f:
os.utime(f.fileno() if os.utime in os.supports_fd else fname,
dir_fd=None if os.supports_fd else dir_fd, **kwargs)
os.utime(
f.fileno() if os.utime in os.supports_fd else fname,
dir_fd=None if os.supports_fd else dir_fd,
**kwargs,
)
except OSError:
return False
@@ -266,26 +268,25 @@ def parse_attachments(attachment_payload, files_request):
raise ValueError("Attachment support has been disabled")
# Attachment Count
count = sum([
0 if not isinstance(attachment_payload, (set, tuple, list))
else len(attachment_payload),
0 if not isinstance(files_request, dict) else len(files_request),
])
count = sum(
[
(0 if not isinstance(attachment_payload, set | tuple | list) else len(attachment_payload)),
0 if not isinstance(files_request, dict) else len(files_request),
]
)
if isinstance(attachment_payload, (dict, str, bytes)):
if isinstance(attachment_payload, dict | str | bytes):
# Convert and adjust counter
attachment_payload = (attachment_payload, )
attachment_payload = (attachment_payload,)
count += 1
if settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS:
raise ValueError(
"There is a maximum of %d attachments" %
settings.APPRISE_MAX_ATTACHMENTS)
raise ValueError(f"There is a maximum of {settings.APPRISE_MAX_ATTACHMENTS} attachments")
if isinstance(attachment_payload, (tuple, list, set)):
if isinstance(attachment_payload, tuple | list | set):
for no, entry in enumerate(attachment_payload, start=1):
if isinstance(entry, (str, bytes)):
filename = "attachment.%.3d" % no
if isinstance(entry, str | bytes):
filename = f"attachment.{no:03d}"
elif isinstance(entry, dict):
try:
@@ -293,24 +294,19 @@ def parse_attachments(attachment_payload, files_request):
# Max filename size is 250
if len(filename) > 250:
raise ValueError(
"The filename associated with attachment "
"%d is too long" % no)
raise ValueError(f"The filename associated with attachment {no} is too long")
elif not filename:
filename = "attachment.%.3d" % no
filename = f"attachment.{no:03d}"
except AttributeError:
# not a string that was provided
raise ValueError(
"An invalid filename was provided for attachment %d" %
no)
raise ValueError(f"An invalid filename was provided for attachment {no}") from None
else:
# you must pass in a base64 string, or a dict containing our
# required parameters
raise ValueError(
"An invalid filename was provided for attachment %d" % no)
raise ValueError(f"An invalid filename was provided for attachment {no}")
#
# Prepare our Attachment
@@ -324,82 +320,62 @@ def parse_attachments(attachment_payload, files_request):
count -= 1
continue
if not re.match(r'^https?://.+', entry[:10], re.I):
if not re.match(r"^https?://.+", entry[:10], re.I):
# We failed to retrieve the product
raise ValueError(
"Failed to load attachment "
"%d (not web request): %s" % (no, entry))
raise ValueError(f"Failed to load attachment {no} (not web request): {entry}")
if not ATTACH_URL_FILTER.is_allowed(entry):
# We are not allowed to use this entry
raise ValueError(
"Denied attachment "
"%d (blocked web request): %s" % (no, entry))
raise ValueError(f"Denied attachment {no} (blocked web request): {entry}")
attachment = HTTPAttachment(
filename, **A_MGR['http'].parse_url(entry))
attachment = HTTPAttachment(filename, **A_MGR["http"].parse_url(entry))
if not attachment:
# We failed to retrieve the attachment
raise ValueError(
"Failed to retrieve attachment %d: %s" % (no, entry))
raise ValueError(f"Failed to retrieve attachment {no}: {entry}")
else: # web, base64 or raw
else: # web, base64 or raw
attachment = Attachment(filename)
try:
with open(attachment.path, 'wb') as f:
with open(attachment.path, "wb") as f:
# Write our content to disk
if isinstance(entry, dict) and \
AttachmentPayload.BASE64 in entry:
if isinstance(entry, dict) and AttachmentPayload.BASE64 in entry:
# BASE64
f.write(
base64.b64decode(
entry[AttachmentPayload.BASE64]))
elif isinstance(entry, dict) and \
AttachmentPayload.URL in entry:
f.write(base64.b64decode(entry[AttachmentPayload.BASE64]))
elif isinstance(entry, dict) and AttachmentPayload.URL in entry:
if not ATTACH_URL_FILTER.is_allowed(entry[AttachmentPayload.URL]):
# We are not allowed to use this entry
raise ValueError(
"Denied attachment "
"%d (blocked web request): %s" % (
no, entry[AttachmentPayload.URL]))
f"Denied attachment {no} (blocked web request): {entry[AttachmentPayload.URL]}"
)
attachment = HTTPAttachment(
filename, **A_MGR['http']
.parse_url(entry[AttachmentPayload.URL]))
filename,
**A_MGR["http"].parse_url(entry[AttachmentPayload.URL]),
)
if not attachment:
# We failed to retrieve the attachment
raise ValueError(
"Failed to retrieve attachment "
"%d: %s" % (no, entry))
raise ValueError(f"Failed to retrieve attachment {no}: {entry}")
elif isinstance(entry, bytes):
# RAW
f.write(entry)
else:
raise ValueError(
"Invalid filetype was provided for "
"attachment %s" % filename)
raise ValueError(f"Invalid filetype was provided for attachment {filename}")
except binascii.Error:
# The file ws not base64 encoded
raise ValueError(
"Invalid filecontent was provided for attachment %s" %
filename)
raise ValueError(f"Invalid filecontent was provided for attachment {filename}") from None
except OSError:
raise ValueError(
"Could not write attachment %s to disk" % filename)
raise ValueError(f"Could not write attachment {filename} to disk") from None
#
# Some Validation
#
if settings.APPRISE_ATTACH_SIZE > 0 and \
attachment.size > settings.APPRISE_ATTACH_SIZE:
raise ValueError(
"attachment %s's filesize is to large" % filename)
if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE:
raise ValueError(f"attachment {filename}'s filesize is to large")
# Add our attachment
attachments.append(attachment)
@@ -408,9 +384,7 @@ def parse_attachments(attachment_payload, files_request):
# Now handle the request.FILES
#
if isinstance(files_request, dict):
for no, (key, meta) in enumerate(
files_request.items(), start=len(attachments) + 1):
for no, (_key, meta) in enumerate(files_request.items(), start=len(attachments) + 1):
try:
# Filetype is presumed to be of base class
# django.core.files.UploadedFile
@@ -418,37 +392,31 @@ def parse_attachments(attachment_payload, files_request):
# Max filename size is 250
if len(filename) > 250:
raise ValueError(
"The filename associated with attachment "
"%d is too long" % no)
raise ValueError(f"The filename associated with attachment {no} is too long")
elif not filename:
filename = "attachment.%.3d" % no
filename = f"attachment.{no:03d}"
except (AttributeError, TypeError):
raise ValueError(
"An invalid filename was provided for attachment %d" % no)
raise ValueError(f"An invalid filename was provided for attachment {no}") from None
#
# Prepare our Attachment
#
attachment = Attachment(filename)
try:
with open(attachment.path, 'wb') as f:
with open(attachment.path, "wb") as f:
# Write our content to disk
f.write(meta.read())
except OSError:
raise ValueError(
"Could not write attachment %s to disk" % filename)
raise ValueError(f"Could not write attachment {filename} to disk") from None
#
# Some Validation
#
if settings.APPRISE_ATTACH_SIZE > 0 and \
attachment.size > settings.APPRISE_ATTACH_SIZE:
raise ValueError(
"attachment %s's filesize is to large" % filename)
if settings.APPRISE_ATTACH_SIZE > 0 and attachment.size > settings.APPRISE_ATTACH_SIZE:
raise ValueError(f"attachment {filename}'s filesize is to large")
# Add our attachment
attachments.append(attachment)
@@ -456,20 +424,21 @@ def parse_attachments(attachment_payload, files_request):
return attachments
class SimpleFileExtension(object):
class SimpleFileExtension:
"""
Defines the simple file exension lookups
"""
# Simple Configuration file
TEXT = 'cfg'
TEXT = "cfg"
# YAML Configuration file
YAML = 'yml'
YAML = "yml"
SIMPLE_FILE_EXTENSION_MAPPING = {
apprise.ConfigFormat.TEXT: SimpleFileExtension.TEXT,
apprise.ConfigFormat.YAML: SimpleFileExtension.YAML,
apprise.ConfigFormat.TEXT.value: SimpleFileExtension.TEXT,
apprise.ConfigFormat.YAML.value: SimpleFileExtension.YAML,
SimpleFileExtension.TEXT: SimpleFileExtension.TEXT,
SimpleFileExtension.YAML: SimpleFileExtension.YAML,
}
@@ -477,7 +446,7 @@ SIMPLE_FILE_EXTENSION_MAPPING = {
SIMPLE_FILE_EXTENSIONS = (SimpleFileExtension.TEXT, SimpleFileExtension.YAML)
class AppriseConfigCache(object):
class AppriseConfigCache:
"""
Designed to make it easy to store/read contact back from disk in a cache
type structure that is fast.
@@ -492,9 +461,7 @@ class AppriseConfigCache(object):
self.mode = mode.strip().lower()
if self.mode not in STORE_MODES:
self.mode = AppriseStoreMode.DISABLED
logger.error(
'APPRISE_STATEFUL_MODE {} is not supported; '
'reverted to {}.'.format(mode, self.mode))
logger.error("APPRISE_STATEFUL_MODE {} is not supported; reverted to {}.".format(mode, self.mode))
def put(self, key, content, fmt):
"""
@@ -519,18 +486,18 @@ class AppriseConfigCache(object):
except OSError:
# Permission error
logger.error('Could not create directory {}'.format(path))
logger.error("Could not create directory {}".format(path))
return False
# Write our file to a temporary file
d, tmp_path = tempfile.mkstemp(suffix='.tmp', dir=path)
d, tmp_path = tempfile.mkstemp(suffix=".tmp", dir=path)
# Close the file handle provided by mkstemp()
# We're reopening it, and it can't be renamed while open on Windows
os.close(d)
if self.mode == AppriseStoreMode.HASH:
try:
with gzip.open(tmp_path, 'wb') as f:
with gzip.open(tmp_path, "wb") as f:
# Write our content to disk
f.write(content.encode())
@@ -543,7 +510,7 @@ class AppriseConfigCache(object):
# Update our file extenion based on our fmt
fmt = SIMPLE_FILE_EXTENSION_MAPPING[fmt]
try:
with open(tmp_path, 'wb') as f:
with open(tmp_path, "wb") as f:
# Write our content to disk
f.write(content.encode())
@@ -555,8 +522,7 @@ class AppriseConfigCache(object):
# If we reach here we successfully wrote the content. We now safely
# move our configuration into place. The following writes our content
# to disk
shutil.move(tmp_path, os.path.join(
path, '{}.{}'.format(filename, fmt)))
shutil.move(tmp_path, os.path.join(path, "{}.{}".format(filename, fmt)))
# perform tidy of any other lingering files of other type in case
# configuration changed from TEXT -> YAML or YAML -> TEXT
@@ -593,7 +559,7 @@ class AppriseConfigCache(object):
if self.mode == AppriseStoreMode.DISABLED:
# Do nothing
return (None, '')
return (None, "")
# There isn't a lot of error handling done here as it is presumed most
# of the checking has been done higher up.
@@ -606,36 +572,32 @@ class AppriseConfigCache(object):
# Test the only possible hashed files we expect to find
if self.mode == AppriseStoreMode.HASH:
text_file = os.path.join(
path, '{}.{}'.format(filename, apprise.ConfigFormat.TEXT))
yaml_file = os.path.join(
path, '{}.{}'.format(filename, apprise.ConfigFormat.YAML))
text_file = os.path.join(path, "{}.{}".format(filename, apprise.ConfigFormat.TEXT.value))
yaml_file = os.path.join(path, "{}.{}".format(filename, apprise.ConfigFormat.YAML.value))
else: # AppriseStoreMode.SIMPLE
text_file = os.path.join(
path, '{}.{}'.format(filename, SimpleFileExtension.TEXT))
yaml_file = os.path.join(
path, '{}.{}'.format(filename, SimpleFileExtension.YAML))
text_file = os.path.join(path, "{}.{}".format(filename, SimpleFileExtension.TEXT))
yaml_file = os.path.join(path, "{}.{}".format(filename, SimpleFileExtension.YAML))
if os.path.isfile(text_file):
fmt = apprise.ConfigFormat.TEXT
fmt = apprise.ConfigFormat.TEXT.value
path = text_file
elif os.path.isfile(yaml_file):
fmt = apprise.ConfigFormat.YAML
fmt = apprise.ConfigFormat.YAML.value
path = yaml_file
else:
# Not found; we set the fmt to something other than none as
# an indication for the upstream handling to know that we didn't
# fail on error
return (None, '')
return (None, "")
# Initialize our content
content = None
if self.mode == AppriseStoreMode.HASH:
try:
with gzip.open(path, 'rb') as f:
with gzip.open(path, "rb") as f:
# Write our content to disk
content = f.read().decode()
@@ -646,7 +608,7 @@ class AppriseConfigCache(object):
else: # AppriseStoreMode.SIMPLE
try:
with open(path, 'rb') as f:
with open(path, "rb") as f:
# Write our content to disk
content = f.read().decode()
@@ -683,10 +645,15 @@ class AppriseConfigCache(object):
# Eliminate any existing content if present
try:
# Handle failure
os.remove(os.path.join(path, '{}.{}'.format(
filename,
fmt if self.mode == AppriseStoreMode.HASH
else SIMPLE_FILE_EXTENSION_MAPPING[fmt])))
os.remove(
os.path.join(
path,
"{}.{}".format(
filename,
(fmt if self.mode == AppriseStoreMode.HASH else SIMPLE_FILE_EXTENSION_MAPPING[fmt]),
),
)
)
# If we reach here, an element was removed
response = True
@@ -708,7 +675,7 @@ class AppriseConfigCache(object):
path = os.path.join(self.root, encoded_key[0:2])
return (path, encoded_key[2:])
else: # AppriseStoreMode.SIMPLE
else: # AppriseStoreMode.SIMPLE
return (self.root, key)
def keys(self):
@@ -720,7 +687,7 @@ class AppriseConfigCache(object):
return keys
for filename in sorted(os.listdir(self.root)):
if filename.startswith('.'):
if filename.startswith("."):
continue
path = os.path.join(self.root, filename)
if os.path.isfile(path):
@@ -732,8 +699,10 @@ class AppriseConfigCache(object):
# Initialize our singleton
ConfigCache = AppriseConfigCache(
settings.APPRISE_CONFIG_DIR, salt=settings.SECRET_KEY,
mode=settings.APPRISE_STATEFUL_MODE)
settings.APPRISE_CONFIG_DIR,
salt=settings.SECRET_KEY,
mode=settings.APPRISE_STATEFUL_MODE,
)
def apply_global_filters():
@@ -741,22 +710,22 @@ def apply_global_filters():
# Apply Any Global Filters (if identified)
#
if settings.APPRISE_ALLOW_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_ALLOW_SERVICES)
if alphanum_re.match(x)]
alphanum_re = re.compile(r"^(?P<name>[a-z][a-z0-9]+)", re.IGNORECASE)
entries = [
alphanum_re.match(x).group("name").lower()
for x in re.split(r"[ ,]+", settings.APPRISE_ALLOW_SERVICES)
if alphanum_re.match(x)
]
N_MGR.enable_only(*entries)
elif settings.APPRISE_DENY_SERVICES:
alphanum_re = re.compile(
r'^(?P<name>[a-z][a-z0-9]+)', re.IGNORECASE)
entries = \
[alphanum_re.match(x).group('name').lower()
for x in re.split(r'[ ,]+', settings.APPRISE_DENY_SERVICES)
if alphanum_re.match(x)]
alphanum_re = re.compile(r"^(?P<name>[a-z][a-z0-9]+)", re.IGNORECASE)
entries = [
alphanum_re.match(x).group("name").lower()
for x in re.split(r"[ ,]+", settings.APPRISE_DENY_SERVICES)
if alphanum_re.match(x)
]
N_MGR.disable(*entries)
@@ -767,8 +736,8 @@ def gen_unique_config_id():
"""
# our key to use
h = hashlib.sha256()
h.update(datetime.now().strftime('%Y%m%d%H%M%S%f').encode('utf-8'))
h.update(settings.SECRET_KEY.encode('utf-8'))
h.update(datetime.now().strftime("%Y%m%d%H%M%S%f").encode("utf-8"))
h.update(settings.SECRET_KEY.encode("utf-8"))
return h.hexdigest()
@@ -779,28 +748,26 @@ def send_webhook(payload):
# Prepare HTTP Headers
headers = {
'User-Agent': 'Apprise-API',
'Content-Type': 'application/json',
"User-Agent": "Apprise-API",
"Content-Type": "application/json",
}
try:
if not apprise.utils.parse.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group('schema'):
if not apprise.utils.parse.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group("schema"):
raise AttributeError()
except (AttributeError, TypeError):
logger.warning(
'The Apprise Webhook Result URL is not a valid web based URI')
logger.warning("The Apprise Webhook Result URL is not a valid web based URI")
return
# Parse our URL
results = apprise.URLBase.parse_url(settings.APPRISE_WEBHOOK_URL)
if not results:
logger.warning('The Apprise Webhook Result URL is not parseable')
logger.warning("The Apprise Webhook Result URL is not parseable")
return
if results['schema'] not in ('http', 'https'):
logger.warning(
'The Apprise Webhook Result URL is not using the HTTP protocol')
if results["schema"] not in ("http", "https"):
logger.warning("The Apprise Webhook Result URL is not using the HTTP protocol")
return
# Load our URL
@@ -808,8 +775,7 @@ def send_webhook(payload):
# Our Query String Dictionary; we use this to track arguments
# specified that aren't otherwise part of this class
params = {k: v for k, v in results.get('qsd', {}).items()
if k not in base.template_args}
params = {k: v for k, v in results.get("qsd", {}).items() if k not in base.template_args}
try:
requests.post(
@@ -823,10 +789,8 @@ def send_webhook(payload):
)
except requests.RequestException as e:
logger.warning(
'A Connection error occurred sending the Apprise Webhook '
'results to %s.' % base.url(privacy=True))
logger.debug('Socket Exception: %s' % str(e))
logger.warning("A Connection error occurred sending the Apprise Webhook results to %s.", base.url(privacy=True))
logger.debug("Socket Exception: %s", str(e))
return
@@ -838,21 +802,21 @@ def healthcheck(lazy=True):
# Some status variables we can flip
response = {
'persistent_storage': False,
'can_write_config': False,
'can_write_attach': False,
'details': [],
"persistent_storage": False,
"can_write_config": False,
"can_write_attach": False,
"details": [],
}
if not (settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.DISABLED or settings.APPRISE_CONFIG_LOCK):
# Update our Configuration Check Block
path = os.path.join(ConfigCache.root, '.tmp_hc')
path = os.path.join(ConfigCache.root, ".tmp_hc")
if lazy:
try:
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
delta = (datetime.now() - modify_date).total_seconds()
if delta <= 30.00: # 30s
response['can_write_config'] = True
response["can_write_config"] = True
except FileNotFoundError:
# No worries... continue with below testing
@@ -861,34 +825,34 @@ def healthcheck(lazy=True):
except OSError:
# Permission Issue or something else likely
# We can take an early exit
response['details'].append('CONFIG_PERMISSION_ISSUE')
response["details"].append("CONFIG_PERMISSION_ISSUE")
if not (response['can_write_config'] or 'CONFIG_PERMISSION_ISSUE' in response['details']):
if not (response["can_write_config"] or "CONFIG_PERMISSION_ISSUE" in response["details"]):
try:
os.makedirs(ConfigCache.root, exist_ok=True)
if touch(path):
# Toggle our status
response['can_write_config'] = True
response["can_write_config"] = True
else:
# We can take an early exit as there is already a permission issue detected
response['details'].append('CONFIG_PERMISSION_ISSUE')
response["details"].append("CONFIG_PERMISSION_ISSUE")
except OSError:
# We can take an early exit as there is already a permission issue detected
response['details'].append('CONFIG_PERMISSION_ISSUE')
response["details"].append("CONFIG_PERMISSION_ISSUE")
if settings.APPRISE_ATTACH_SIZE > 0:
# Test our ability to access write attachments
# Update our Configuration Check Block
path = os.path.join(settings.APPRISE_ATTACH_DIR, '.tmp_hc')
path = os.path.join(settings.APPRISE_ATTACH_DIR, ".tmp_hc")
if lazy:
try:
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
delta = (datetime.now() - modify_date).total_seconds()
if delta <= 30.00: # 30s
response['can_write_attach'] = True
response["can_write_attach"] = True
except FileNotFoundError:
# No worries... continue with below testing
@@ -896,23 +860,23 @@ def healthcheck(lazy=True):
except OSError:
# We can take an early exit as there is already a permission issue detected
response['details'].append('ATTACH_PERMISSION_ISSUE')
response["details"].append("ATTACH_PERMISSION_ISSUE")
if not (response['can_write_attach'] or 'ATTACH_PERMISSION_ISSUE' in response['details']):
if not (response["can_write_attach"] or "ATTACH_PERMISSION_ISSUE" in response["details"]):
# No lazy mode set or content require a refresh
try:
os.makedirs(settings.APPRISE_ATTACH_DIR, exist_ok=True)
if touch(path):
# Toggle our status
response['can_write_attach'] = True
response["can_write_attach"] = True
else:
# We can take an early exit as there is already a permission issue detected
response['details'].append('ATTACH_PERMISSION_ISSUE')
response["details"].append("ATTACH_PERMISSION_ISSUE")
except OSError:
# We can take an early exit
response['details'].append('ATTACH_PERMISSION_ISSUE')
response["details"].append("ATTACH_PERMISSION_ISSUE")
if settings.APPRISE_STORAGE_DIR:
#
@@ -920,13 +884,13 @@ def healthcheck(lazy=True):
#
store = apprise.PersistentStore(
path=settings.APPRISE_STORAGE_DIR,
namespace='tmp_hc',
namespace="tmp_hc",
mode=settings.APPRISE_STORAGE_MODE,
)
if store.mode != settings.APPRISE_STORAGE_MODE:
# Persistent storage not as configured
response['details'].append('STORE_PERMISSION_ISSUE')
response["details"].append("STORE_PERMISSION_ISSUE")
elif store.mode != apprise.PersistentStoreMode.MEMORY:
# G
@@ -936,23 +900,23 @@ def healthcheck(lazy=True):
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
delta = (datetime.now() - modify_date).total_seconds()
if delta <= 30.00: # 30s
response['persistent_storage'] = True
response["persistent_storage"] = True
except OSError:
# No worries... continue with below testing
pass
if not (store.set('foo', 'bar') and store.flush()):
if not (store.set("foo", "bar") and store.flush()):
# No persistent store
response['details'].append('STORE_PERMISSION_ISSUE')
response["details"].append("STORE_PERMISSION_ISSUE")
else:
# Toggle our status
response['persistent_storage'] = True
response["persistent_storage"] = True
# Clear our test
store.clear('foo')
store.clear("foo")
if not response['details']:
response['details'].append('OK')
if not response["details"]:
response["details"].append("OK")
return response

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -30,7 +29,7 @@ def base_url(request):
Returns our defined BASE_URL object
"""
return {
'BASE_URL': settings.BASE_URL,
'CONFIG_DIR': settings.APPRISE_CONFIG_DIR,
'ATTACH_DIR': settings.APPRISE_ATTACH_DIR,
"BASE_URL": settings.BASE_URL,
"CONFIG_DIR": settings.APPRISE_CONFIG_DIR,
"ATTACH_DIR": settings.APPRISE_ATTACH_DIR,
}

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -23,9 +22,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
import re
from django.conf import settings
import datetime
import re
from django.conf import settings
class DetectConfigMiddleware:
@@ -35,7 +35,7 @@ class DetectConfigMiddleware:
"""
_is_cfg_path = re.compile(r'/cfg/(?P<key>[\w_-]{1,128})')
_is_cfg_path = re.compile(r"/cfg/(?P<key>[\w_-]{1,128})")
def __init__(self, get_response):
"""
@@ -51,14 +51,13 @@ class DetectConfigMiddleware:
result = self._is_cfg_path.match(request.path)
if not result:
# Our current config
config = \
request.COOKIES.get('key', settings.APPRISE_DEFAULT_CONFIG_ID)
config = request.COOKIES.get("key", settings.APPRISE_DEFAULT_CONFIG_ID)
# Extract our key (fall back to our default if not set)
config = request.GET.get("key", config).strip()
else:
config = result.group('key')
config = result.group("key")
if not config:
# Fallback to default config
@@ -72,11 +71,10 @@ class DetectConfigMiddleware:
# Set our cookie
max_age = 365 * 24 * 60 * 60 # 1 year
expires = datetime.datetime.utcnow() + \
datetime.timedelta(seconds=max_age)
expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=max_age)
# Set our cookie
response.set_cookie('key', config, expires=expires)
response.set_cookie("key", config, expires=expires)
# return our response
return response

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -23,10 +22,12 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
from django.conf import settings
from core.themes import SiteTheme, SITE_THEMES
import datetime
from django.conf import settings
from core.themes import SITE_THEMES, SiteTheme
class AutoThemeMiddleware:
"""
@@ -47,15 +48,18 @@ class AutoThemeMiddleware:
"""
# Our current theme
current_theme = \
request.COOKIES.get('t', request.COOKIES.get(
'theme', settings.APPRISE_DEFAULT_THEME))
current_theme = request.COOKIES.get("t", request.COOKIES.get("theme", settings.APPRISE_DEFAULT_THEME))
# Extract our theme (fall back to our default if not set)
theme = request.GET.get("theme", current_theme).strip().lower()
theme = next((entry for entry in SITE_THEMES
if entry.startswith(theme)), None) \
if theme else None
theme = (
next(
(entry for entry in SITE_THEMES if entry.startswith(theme)),
None,
)
if theme
else None
)
if theme not in SITE_THEMES:
# Fallback to default theme
@@ -65,20 +69,17 @@ class AutoThemeMiddleware:
request.theme = theme
# Set our next theme
request.next_theme = SiteTheme.LIGHT \
if theme == SiteTheme.DARK \
else SiteTheme.DARK
request.next_theme = SiteTheme.LIGHT if theme == SiteTheme.DARK else SiteTheme.DARK
# Get our response object
response = self.get_response(request)
# Set our cookie
max_age = 365 * 24 * 60 * 60 # 1 year
expires = datetime.datetime.utcnow() + \
datetime.timedelta(seconds=max_age)
expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=max_age)
# Set our cookie
response.set_cookie('theme', theme, expires=expires)
response.set_cookie("theme", theme, expires=expires)
# return our response
return response

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -23,18 +22,17 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import os
from core.themes import SiteTheme
# Disable Timezones
USE_TZ = False
# Base Directory (relative to settings)
BASE_DIR = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
'SECRET_KEY', '+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=')
SECRET_KEY = os.environ.get("SECRET_KEY", "+reua88v8rs4j!bcfdtinb-f0edxazf!$x_q1g7jtgckxd7gi=")
# SECURITY WARNING: don't run with debug turned on in production!
# If you want to run this app in DEBUG mode, run the following:
@@ -47,124 +45,115 @@ SECRET_KEY = os.environ.get(
# ./manage.py runserver
#
# Support 'yes', '1', 'true', 'enable', 'active', and +
DEBUG = os.environ.get("DEBUG", 'No')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
DEBUG = os.environ.get("DEBUG", "No")[0].lower() in (
"a",
"y",
"1",
"t",
"e",
"+",
)
# allow all hosts by default otherwise read from the
# ALLOWED_HOSTS environment variable
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(' ')
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(" ")
# Application definition
INSTALLED_APPS = [
'django.contrib.contenttypes',
'django.contrib.staticfiles',
"django.contrib.contenttypes",
"django.contrib.staticfiles",
# Apprise API
'api',
"api",
# Prometheus
'django_prometheus',
"django_prometheus",
]
MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'django.middleware.common.CommonMiddleware',
'core.middleware.theme.AutoThemeMiddleware',
'core.middleware.config.DetectConfigMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
"django_prometheus.middleware.PrometheusBeforeMiddleware",
"django.middleware.common.CommonMiddleware",
"core.middleware.theme.AutoThemeMiddleware",
"core.middleware.config.DetectConfigMiddleware",
"django_prometheus.middleware.PrometheusAfterMiddleware",
]
ROOT_URLCONF = 'core.urls'
ROOT_URLCONF = "core.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'core.context_processors.base_url',
'api.context_processors.default_config_id',
'api.context_processors.unique_config_id',
'api.context_processors.stateful_mode',
'api.context_processors.config_lock',
'api.context_processors.admin_enabled',
'api.context_processors.apprise_version',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"core.context_processors.base_url",
"api.context_processors.default_config_id",
"api.context_processors.unique_config_id",
"api.context_processors.stateful_mode",
"api.context_processors.config_lock",
"api.context_processors.admin_enabled",
"api.context_processors.apprise_version",
],
},
},
]
LOGGING = {
'version': 1,
'disable_existing_loggers': True,
'formatters': {
'standard': {
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "standard"},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": os.environ.get("LOG_LEVEL", "debug" if DEBUG else "info").upper(),
},
"apprise": {
"handlers": ["console"],
"level": os.environ.get("LOG_LEVEL", "debug" if DEBUG else "info").upper(),
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'standard'
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': os.environ.get(
'LOG_LEVEL', 'debug' if DEBUG else 'info').upper(),
},
'apprise': {
'handlers': ['console'],
'level': os.environ.get(
'LOG_LEVEL', 'debug' if DEBUG else 'info').upper(),
},
}
}
WSGI_APPLICATION = 'core.wsgi.application'
WSGI_APPLICATION = "core.wsgi.application"
# Define our base URL
# The default value is to be a single slash
BASE_URL = os.environ.get('BASE_URL', '')
BASE_URL = os.environ.get("BASE_URL", "")
# Define our default configuration ID to use
APPRISE_DEFAULT_CONFIG_ID = \
os.environ.get('APPRISE_DEFAULT_CONFIG_ID', 'apprise')
APPRISE_DEFAULT_CONFIG_ID = os.environ.get("APPRISE_DEFAULT_CONFIG_ID", "apprise")
# Define our Prometheus Namespace
PROMETHEUS_METRIC_NAMESPACE = "apprise"
# Static files relative path (CSS, JavaScript, Images)
STATIC_URL = BASE_URL + '/s/'
STATIC_URL = BASE_URL + "/s/"
# Default theme can be either 'light' or 'dark'
APPRISE_DEFAULT_THEME = \
os.environ.get('APPRISE_DEFAULT_THEME', SiteTheme.LIGHT)
APPRISE_DEFAULT_THEME = os.environ.get("APPRISE_DEFAULT_THEME", SiteTheme.LIGHT)
# Webhook that is posted to upon executed results
# Set it to something like https://myserver.com/path/
# Requets are done as a POST
APPRISE_WEBHOOK_URL = os.environ.get('APPRISE_WEBHOOK_URL', '')
APPRISE_WEBHOOK_URL = os.environ.get("APPRISE_WEBHOOK_URL", "")
# The location to store Apprise configuration files
APPRISE_CONFIG_DIR = os.environ.get(
'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config'))
APPRISE_CONFIG_DIR = os.environ.get("APPRISE_CONFIG_DIR", os.path.join(BASE_DIR, "var", "config"))
# The location to store Apprise Persistent Storage files
APPRISE_STORAGE_DIR = os.environ.get(
'APPRISE_STORAGE_DIR', os.path.join(APPRISE_CONFIG_DIR, 'store'))
APPRISE_STORAGE_DIR = os.environ.get("APPRISE_STORAGE_DIR", os.path.join(APPRISE_CONFIG_DIR, "store"))
# Default number of days to prune persistent storage
APPRISE_STORAGE_PRUNE_DAYS = \
int(os.environ.get('APPRISE_STORAGE_PRUNE_DAYS', 30))
APPRISE_STORAGE_PRUNE_DAYS = int(os.environ.get("APPRISE_STORAGE_PRUNE_DAYS", 30))
# The default URL ID Length
APPRISE_STORAGE_UID_LENGTH = \
int(os.environ.get('APPRISE_STORAGE_UID_LENGTH', 8))
APPRISE_STORAGE_UID_LENGTH = int(os.environ.get("APPRISE_STORAGE_UID_LENGTH", 8))
# The default storage mode; options are:
# - memory : Disables persistent storage (this is also automatically set
@@ -174,14 +163,13 @@ APPRISE_STORAGE_UID_LENGTH = \
# - flush : Writes to storage constantly (as much as possible). This
# produces more i/o but can allow multiple calls to the same
# notification to be in sync more
APPRISE_STORAGE_MODE = os.environ.get('APPRISE_STORAGE_MODE', 'auto').lower()
APPRISE_STORAGE_MODE = os.environ.get("APPRISE_STORAGE_MODE", "auto").lower()
# The location to place file attachments
APPRISE_ATTACH_DIR = os.environ.get(
'APPRISE_ATTACH_DIR', os.path.join(BASE_DIR, 'var', 'attach'))
APPRISE_ATTACH_DIR = os.environ.get("APPRISE_ATTACH_DIR", os.path.join(BASE_DIR, "var", "attach"))
# The maximum file attachment size allowed by the API (defined in MB)
APPRISE_ATTACH_SIZE = int(os.environ.get('APPRISE_ATTACH_SIZE', 200)) * 1048576
APPRISE_ATTACH_SIZE = int(os.environ.get("APPRISE_ATTACH_SIZE", 200)) * 1048576
# A provided list that identify all of the URLs/Hosts/IPs that Apprise can
# retrieve remote attachments from.
@@ -210,18 +198,15 @@ APPRISE_ATTACH_SIZE = int(os.environ.get('APPRISE_ATTACH_SIZE', 200)) * 1048576
# the URL based attachment is ignored and is not retrieved at all.
# - Set the list to * (a single astrix) to match all URLs and accepting all provided
# matches
APPRISE_ATTACH_DENY_URLS = \
os.environ.get('APPRISE_ATTACH_REJECT_URL', '127.0.* localhost*').lower()
APPRISE_ATTACH_DENY_URLS = os.environ.get("APPRISE_ATTACH_REJECT_URL", "127.0.* localhost*").lower()
# The Allow list which is processed after the Deny list above
APPRISE_ATTACH_ALLOW_URLS = \
os.environ.get('APPRISE_ATTACH_ALLOW_URL', '*').lower()
APPRISE_ATTACH_ALLOW_URLS = os.environ.get("APPRISE_ATTACH_ALLOW_URL", "*").lower()
# The maximum size in bytes that a request body may be before raising an error
# (defined in MB)
DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get(
'APPRISE_UPLOAD_MAX_MEMORY_SIZE', 3))) * 1048576
DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get("APPRISE_UPLOAD_MAX_MEMORY_SIZE", 3))) * 1048576
# When set Apprise API Locks itself down so that future (configuration)
# changes can not be made or accessed. It disables access to:
@@ -239,55 +224,64 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = abs(int(os.environ.get(
# The idea here is that someone has set up the configuration they way they want
# and do not want this information exposed any more then it needs to be.
# it's a lock down mode if you will.
APPRISE_CONFIG_LOCK = \
os.environ.get("APPRISE_CONFIG_LOCK", 'no')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
APPRISE_CONFIG_LOCK = os.environ.get("APPRISE_CONFIG_LOCK", "no")[0].lower() in ("a", "y", "1", "t", "e", "+")
# Stateless posts to /notify/ will resort to this set of URLs if none
# were otherwise posted with the URL request.
APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '')
APPRISE_STATELESS_URLS = os.environ.get("APPRISE_STATELESS_URLS", "")
# Allow stateless URLS to generate and/or work with persistent storage
# By default this is set to no
APPRISE_STATELESS_STORAGE = \
os.environ.get("APPRISE_STATELESS_STORAGE", 'no')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
APPRISE_STATELESS_STORAGE = os.environ.get("APPRISE_STATELESS_STORAGE", "no")[0].lower() in (
"a",
"y",
"1",
"t",
"e",
"+",
)
# Defines the stateful mode; possible values are:
# - hash (default): content is hashed and zipped
# - simple: content is just written straight to disk 'as-is'
# - disabled: disable all stateful functionality
APPRISE_STATEFUL_MODE = os.environ.get('APPRISE_STATEFUL_MODE', 'hash')
APPRISE_STATEFUL_MODE = os.environ.get("APPRISE_STATEFUL_MODE", "hash")
# Our Apprise Deny List
# - By default we disable all non-remote calling services
# - You do not need to identify every schema supported by the service you
# wish to disable (only one). For example, if you were to specify
# xml, that would include the xmls entry as well (or vs versa)
APPRISE_DENY_SERVICES = os.environ.get('APPRISE_DENY_SERVICES', ','.join((
'windows', 'dbus', 'gnome', 'macosx', 'syslog')))
APPRISE_DENY_SERVICES = os.environ.get(
"APPRISE_DENY_SERVICES",
",".join(("windows", "dbus", "gnome", "macosx", "syslog")),
)
# Our Apprise Exclusive Allow List
# - anything not identified here is denied/disabled)
# - this list trumps the APPRISE_DENY_SERVICES identified above
APPRISE_ALLOW_SERVICES = os.environ.get('APPRISE_ALLOW_SERVICES', '')
APPRISE_ALLOW_SERVICES = os.environ.get("APPRISE_ALLOW_SERVICES", "")
# Define the number of recursive calls your system will allow users to make
# The idea here is to prevent people from defining apprise:// URL's triggering
# a call to the same server again, and again and again. By default we allow
# 1 level of recursion
APPRISE_RECURSION_MAX = int(os.environ.get('APPRISE_RECURSION_MAX', 1))
APPRISE_RECURSION_MAX = int(os.environ.get("APPRISE_RECURSION_MAX", 1))
# Provided optional plugin paths to scan for custom schema definitions
APPRISE_PLUGIN_PATHS = os.environ.get(
'APPRISE_PLUGIN_PATHS', os.path.join(BASE_DIR, 'var', 'plugin')).split(',')
APPRISE_PLUGIN_PATHS = os.environ.get("APPRISE_PLUGIN_PATHS", os.path.join(BASE_DIR, "var", "plugin")).split(",")
# Define the number of attachments that can exist as part of a payload
# Setting this to zero disables the limit
APPRISE_MAX_ATTACHMENTS = int(os.environ.get('APPRISE_MAX_ATTACHMENTS', 6))
APPRISE_MAX_ATTACHMENTS = int(os.environ.get("APPRISE_MAX_ATTACHMENTS", 6))
# Allow Admin mode:
# - showing a list of configuration keys (when STATEFUL_MODE is set to simple)
APPRISE_ADMIN = \
os.environ.get("APPRISE_ADMIN", 'no')[0].lower() in (
'a', 'y', '1', 't', 'e', '+')
APPRISE_ADMIN = os.environ.get("APPRISE_ADMIN", "no")[0].lower() in (
"a",
"y",
"1",
"t",
"e",
"+",
)

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -26,6 +25,7 @@
# To create a valid debug settings.py we need to intentionally pollute our
# file with all of the content found in the master configuration.
import os
from .. import * # noqa F403
# Debug is always on when running in debug mode
@@ -35,9 +35,7 @@ DEBUG = True
ALLOWED_HOSTS = []
# Over-ride the default URLConf for debugging
ROOT_URLCONF = 'core.settings.debug.urls'
ROOT_URLCONF = "core.settings.debug.urls"
# Our static paths directory for serving
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'static'), # noqa F405
)
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa F405

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -24,6 +23,7 @@
# THE SOFTWARE.
from django.conf import settings
from django.conf.urls.static import static
from ...urls import * # noqa F403
# Extend our patterns

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -26,6 +25,7 @@
# To create a valid debug settings.py we need to intentionally pollute our
# file with all of the content found in the master configuration.
from tempfile import TemporaryDirectory
from .. import * # noqa F403
# Debug is always on when running in debug mode
@@ -38,4 +38,4 @@ ALLOWED_HOSTS = []
APPRISE_CONFIG_DIR = TemporaryDirectory().name
# Setup our runner
TEST_RUNNER = 'core.settings.pytest.runner.PytestTestRunner'
TEST_RUNNER = "core.settings.pytest.runner.PytestTestRunner"

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -27,7 +26,7 @@
# file with all of the content found in the master configuration.
class PytestTestRunner(object):
class PytestTestRunner:
"""Runs pytest to discover and run tests."""
def __init__(self, verbosity=1, failfast=False, keepdb=False, **kwargs):
@@ -44,15 +43,15 @@ class PytestTestRunner(object):
argv = []
if self.verbosity == 0:
argv.append('--quiet')
argv.append("--quiet")
if self.verbosity == 2:
argv.append('--verbose')
argv.append("--verbose")
if self.verbosity == 3:
argv.append('-vv')
argv.append("-vv")
if self.failfast:
argv.append('--exitfirst')
argv.append("--exitfirst")
if self.keepdb:
argv.append('--reuse-db')
argv.append("--reuse-db")
argv.extend(test_labels)
return pytest.main(argv)

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -28,8 +27,9 @@ class SiteTheme:
"""
Defines our site themes
"""
LIGHT = 'light'
DARK = 'dark'
LIGHT = "light"
DARK = "dark"
SITE_THEMES = (

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -22,12 +21,11 @@
# 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.
from django.urls import path
from django.conf.urls import include
from api import urls as api_urls
from django.conf.urls import include
from django.urls import path
urlpatterns = [
path('', include(api_urls)),
path('', include('django_prometheus.urls')),
path("", include(api_urls)),
path("", include("django_prometheus.urls")),
]

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -23,7 +22,8 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
application = get_wsgi_application()

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2023 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@@ -22,36 +21,35 @@
# 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 multiprocessing
import os
# This file is launched with the call:
# gunicorn --config <this file> core.wsgi:application
raw_env = [
'LANG={}'.format(os.environ.get('LANG', 'en_US.UTF-8')),
'DJANGO_SETTINGS_MODULE=core.settings',
"LANG={}".format(os.environ.get("LANG", "en_US.UTF-8")),
"DJANGO_SETTINGS_MODULE=core.settings",
]
# This is the path as prepared in the docker compose
pythonpath = '/opt/apprise/webapp'
pythonpath = "/opt/apprise/webapp"
# bind to port 8000
bind = [
'0.0.0.0:8000', # IPv4 Support
'[::]:8000', # IPv6 Support
"0.0.0.0:8000", # IPv4 Support
"[::]:8000", # IPv6 Support
]
# Workers are relative to the number of CPU's provided by hosting server
workers = int(os.environ.get(
'APPRISE_WORKER_COUNT', multiprocessing.cpu_count() * 2 + 1))
workers = int(os.environ.get("APPRISE_WORKER_COUNT", multiprocessing.cpu_count() * 2 + 1))
# Increase worker timeout value to give upstream services time to
# respond.
timeout = int(os.environ.get('APPRISE_WORKER_TIMEOUT', 300))
timeout = int(os.environ.get("APPRISE_WORKER_TIMEOUT", 300))
# Our worker type to use; over-ride the default `sync`
worker_class = 'gevent'
worker_class = "gevent"
# Get workers memory consumption under control by leveraging gunicorn
# worker recycling timeout
@@ -60,6 +58,6 @@ max_requests_jitter = 50
# Logging
# '-' means log to stdout.
errorlog = '-'
accesslog = '-'
loglevel = 'warn'
errorlog = "-"
accesslog = "-"
loglevel = "warn"

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -28,7 +27,7 @@ import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.debug")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -40,5 +39,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@@ -1,6 +1,6 @@
flake8
mock
pytest-django
pytest
pytest-cov
tox
ruff

View File

@@ -1,7 +1,7 @@
services:
apprise:
apprise-api:
build: .
container_name: apprise
container_name: apprise-api
environment:
- APPRISE_STATEFUL_MODE=simple
ports:

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
@@ -27,13 +26,12 @@ import os
import sys
# Update our path so it will see our apprise_api content
sys.path.insert(
0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'apprise_api'))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "apprise_api"))
def main():
# Unless otherwise specified, default to a debug mode
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings.debug')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.debug")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -45,5 +43,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

179
pyproject.toml Normal file
View File

@@ -0,0 +1,179 @@
#
# Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>
# 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.
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "apprise-api"
version = "1.2.1"
authors = [{ name = "Chris Caron", email = "lead2gold@gmail.com" }]
license = "MIT"
dependencies = [
"apprise == 1.9.4",
"django",
"gevent",
"gunicorn",
"requests",
"paho-mqtt < 2.0.0",
"gntp",
"cryptography",
"django-prometheus"
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-cov",
"pytest-django",
"ruff",
"tox",
"mock"
]
[tool.setuptools]
license-files = ["LICENSE"]
[tool.setuptools.packages.find]
where = ["."]
include = ["apprise_api*"]
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "core.settings.pytest"
addopts = "--ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing"
filterwarnings = ["once::Warning"]
testpaths = ["apprise_api"]
[tool.coverage.run]
data_file = ".coverage-reports/.coverage"
parallel = false
concurrency = ["multiprocessing"]
include = ["apprise_api"]
omit = [
"*apps.py",
"*/migrations/*",
"*/core/settings/*",
"*/*/tests/*",
"lib/*",
"lib64/*",
"*urls.py",
"*/core/wsgi.py",
"gunicorn.conf.py",
"*/manage.py"
]
disable_warnings = ["no-data-collected"]
[tool.coverage.report]
show_missing = true
skip_covered = true
skip_empty = true
fail_under = 75.0
[tool.ruff]
line-length = 120
target-version = "py312"
exclude = [
"tests/data",
"bin",
"build",
"dist",
".eggs",
".tox",
".local",
".venv",
"venv",
]
[tool.black]
# Added for backwards support and alternative cleanup
# when needed
line-length = 79 # Respecting BSD-style 79-char limit
target-version = ['py39']
extend-exclude = '''
/(
tests/data \
bin \
build \
dist \
.eggs \
.tox \
.local \
.venv \
venv
)
'''
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"W", # pycodestyle warnings
"Q", # Quote handling ' -> "
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"T20", # flake8-print (catches stray `print`)
"RUF", # Ruff-native rules
]
extend-select = [
"E501", # Spacing
"Q000", # Quoting
"I" # Automatically sort imports with isort logic
]
ignore = [
"D100", # missing docstring in public module
"D104", # missing docstring in public package
"B008", # do not call `dict()` with keyword args
"E722", # bare except (Apprise uses it reasonably)
"E741", # Ambiguous variable name (e.g., l, O, I)
"W605", # Invalid escape sequence
"B026", # Star-arg play a big part of Apprise; must be accepted for now
# The following needs to be supported at some point and is of great value
"RUF012", # typing.ClassVar implimentation
"UP032", # This is a massive undertaking and requires a massive rewrite
# of test cases; this will take a while to fix, so turning off
# for now
]
[tool.ruff.lint.pyupgrade]
keep-runtime-typing = true
[tool.ruff.lint.flake8-quotes]
inline-quotes = "double"
[tool.ruff.lint.isort]
known-first-party = ["apprise"]
force-sort-within-sections = true
combine-as-imports = true
order-by-type = true
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]
[tool.ruff.lint.flake8-builtins]
builtins-ignorelist = ["_"]

View File

@@ -1,20 +0,0 @@
[metadata]
# ensure LICENSE is included in wheel metadata
license_file = LICENSE
[flake8]
# We exclude packages we don't maintain
exclude = .eggs,.tox
ignore = E741,E722,W503,W504,W605
statistics = true
builtins = _
max-line-length = 160
[aliases]
test=pytest
[tool:pytest]
DJANGO_SETTINGS_MODULE = core.settings.pytest
addopts = --ignore=lib --ignore=lib64 --nomigrations --cov=apprise_api --cov-report=term-missing
filterwarnings =
once::Warning

70
tox.ini
View File

@@ -1,30 +1,54 @@
[tox]
envlist = py312,coverage-report
skipsdist = true
envlist =
clean
test
lint
format
qa
coverage
skip_missing_interpreters = true
[testenv]
# Prevent random setuptools/pip breakages like
# https://github.com/pypa/setuptools/issues/1042 from breaking our builds.
skip_install = false
usedevelop = true
changedir = {toxinidir}
allowlist_externals = *
ensurepip = true
setenv =
VIRTUALENV_NO_DOWNLOAD=1
deps=
-r{toxinidir}/requirements.txt
-r{toxinidir}/dev-requirements.txt
commands =
coverage run --parallel -m pytest {posargs} apprise_api
flake8 apprise_api --count --show-source --statistics
COVERAGE_RCFILE = {toxinidir}/pyproject.toml
[testenv:py312]
deps=
-r{toxinidir}/requirements.txt
-r{toxinidir}/dev-requirements.txt
commands =
coverage run --parallel -m pytest {posargs} apprise_api
flake8 apprise_api --count --show-source --statistics
[testenv:lint]
description = Run Ruff in check-only mode
deps = ruff
commands = ruff check apprise_api
[testenv:coverage-report]
deps = coverage
[testenv:format]
description = Auto-format code using Ruff
deps = ruff
skip_install = true
commands=
coverage combine apprise_api
coverage report apprise_api
commands = ruff check --fix apprise_api
[testenv:qa]
description = Run test suite and linting with coverage
deps =
.[dev]
commands =
coverage run --parallel -m pytest apprise_api
ruff check apprise_api
[testenv:test]
description = Run test suite only
deps =
.[dev]
commands =
pytest --tb=short -q apprise_api {posargs}
[testenv:coverage]
description = Generates coverage details
deps =
coverage
commands =
coverage combine apprise_api
coverage report apprise_api