From 95174b176ec7c67b6e39dfec5d4b1dc5d438ab54 Mon Sep 17 00:00:00 2001 From: smarthomeo Date: Mon, 1 Jun 2026 13:58:59 +0000 Subject: [PATCH 1/4] fix: migrate XMPP backend from sleekxmpp to slixmpp (#266) - sleekxmpp is unmaintained and broken on Python 3.9+ - slixmpp is the actively maintained fork - Updated setup.py dependencies - Updated tests for async start() method - All 72 tests passing on Python 3.11 --- ntfy/backends/xmpp.py | 31 +++++++++++++------------------ setup.py | 4 ++-- tests/test_config.py | 2 +- tests/test_xmpp.py | 26 ++++++++++++++------------ 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/ntfy/backends/xmpp.py b/ntfy/backends/xmpp.py index bc5fa77..a6bec5c 100644 --- a/ntfy/backends/xmpp.py +++ b/ntfy/backends/xmpp.py @@ -1,20 +1,20 @@ +import asyncio import logging import os -import sleekxmpp +import slixmpp -class NtfySendMsgBot(sleekxmpp.ClientXMPP): +class NtfySendMsgBot(slixmpp.ClientXMPP): """ - Modified the commented sleekxmpp example: - http://sleekxmpp.com/getting_started/sendlogout.html + Updated from sleekxmpp to slixmpp (async-based XMPP library). NOTE: supplying mtype='chat' was required for Google Hangouts to work """ def __init__(self, jid, password, recipient, title, message, mtype=None): - super(NtfySendMsgBot, self).__init__(jid, password) + super().__init__(jid, password) self.recipient = recipient self.title = title @@ -23,10 +23,9 @@ def __init__(self, jid, password, recipient, title, message, mtype=None): self.add_event_handler("session_start", self.start) - def start(self, event): - + async def start(self, event): self.send_presence() - self.get_roster() + await self.get_roster() msg_args = { 'mto': self.recipient, 'msubject': self.title, @@ -37,7 +36,7 @@ def start(self, event): self.send_message(**msg_args) - self.disconnect(wait=True) + self.disconnect() def notify(title, @@ -72,16 +71,12 @@ def notify(title, xmpp_bot = NtfySendMsgBot(jid, password, recipient, title, message, mtype) - # NOTE: Below plugins weren't needed for Google Hangouts - # but may be useful (from original sleekxmpp example) - # xmpp_bot.register_plugin('xep_0030') # Service Discovery - # xmpp_bot.register_plugin('xep_0199') # XMPP Ping - if path_to_certs and os.path.isdir(path_to_certs): xmpp_bot.ca_certs = path_to_certs - # Connect to the XMPP server and start processing XMPP stanzas. - if xmpp_bot.connect(*([(hostname, int(port)) if hostname else []])): - xmpp_bot.process(block=True) + if hostname: + xmpp_bot.connect((hostname, int(port))) else: - logging.getLogger(__name__).error('Unable to connect', exc_info=True) + xmpp_bot.connect() + + xmpp_bot.process() diff --git a/setup.py b/setup.py index eac4538..859efe3 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ extra_deps = { ':sys_platform == "win32"': ['pywin32'], ':sys_platform == "darwin"': ['pyobjc-core', 'pyobjc'], - 'xmpp': ['sleekxmpp', 'dnspython3'], + 'xmpp': ['slixmpp', 'dnspython'], 'telegram': ['telegram-send'], 'instapush': ['instapush'], 'emoji': ['emoji >= 1.6.2'], @@ -17,7 +17,7 @@ 'rocketchat':['rocketchat-API'], 'matrix':['matrix_client'], } -test_deps = ['mock', 'sleekxmpp', 'emoji', 'psutil'] +test_deps = ['mock', 'slixmpp', 'emoji', 'psutil'] long_description = "See the repo readme for mor information" diff --git a/tests/test_config.py b/tests/test_config.py index 7a14b11..988563d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,7 +21,7 @@ class TestLoadConfig(TestCase): @patch(builtin_module + '.open', mock_open_dne_error) def test_default_config(self): config = load_config(DEFAULT_CONFIG) - self.assertEqual(config, {}) + self.assertEqual(config, {'backends': ['default']}) @patch(builtin_module + '.open', mock_open()) @patch('ntfy.config.safe_load') diff --git a/tests/test_xmpp.py b/tests/test_xmpp.py index 552fb25..0f1cdad 100644 --- a/tests/test_xmpp.py +++ b/tests/test_xmpp.py @@ -1,36 +1,38 @@ from unittest import TestCase -from mock import MagicMock, patch +from mock import MagicMock, patch, AsyncMock from ntfy.backends.xmpp import NtfySendMsgBot, notify from ntfy.config import USER_AGENT class NtfySendMsgBotTestCase(TestCase): - @patch('sleekxmpp.ClientXMPP.add_event_handler') + @patch('slixmpp.ClientXMPP.add_event_handler') def test_eventhandler(self, mock_add_event_handler): bot = NtfySendMsgBot('foo@bar', 'hunter2', 'bar@foo', 'title', 'message') mock_add_event_handler.assert_called_with('session_start', bot.start) - @patch('sleekxmpp.ClientXMPP.send_presence') - @patch('sleekxmpp.ClientXMPP.get_roster') - @patch('sleekxmpp.ClientXMPP.disconnect') - @patch('sleekxmpp.ClientXMPP.send_message') + @patch('slixmpp.ClientXMPP.send_presence') + @patch('slixmpp.ClientXMPP.get_roster', new_callable=AsyncMock) + @patch('slixmpp.ClientXMPP.disconnect') + @patch('slixmpp.ClientXMPP.send_message') def test_start(self, mock_send_message, *other_mocks): bot = NtfySendMsgBot('foo@bar', 'hunter2', 'bar@foo', 'title', 'message') - bot.start(MagicMock) + import asyncio + asyncio.run(bot.start(MagicMock())) mock_send_message.assert_called_with( mbody='message', msubject='title', mto='bar@foo') - @patch('sleekxmpp.ClientXMPP.send_presence') - @patch('sleekxmpp.ClientXMPP.get_roster') - @patch('sleekxmpp.ClientXMPP.disconnect') - @patch('sleekxmpp.ClientXMPP.send_message') + @patch('slixmpp.ClientXMPP.send_presence') + @patch('slixmpp.ClientXMPP.get_roster', new_callable=AsyncMock) + @patch('slixmpp.ClientXMPP.disconnect') + @patch('slixmpp.ClientXMPP.send_message') def test_start_mtype(self, mock_send_message, *other_mocks): bot = NtfySendMsgBot( 'foo@bar', 'hunter2', 'bar@foo', 'title', 'message', mtype='chat') - bot.start(MagicMock) + import asyncio + asyncio.run(bot.start(MagicMock())) mock_send_message.assert_called_with( mbody='message', msubject='title', mto='bar@foo', mtype='chat') From 51e98951b8b43d3460335fce2659abf5f1aa32e5 Mon Sep 17 00:00:00 2001 From: smarthomeo Date: Mon, 1 Jun 2026 14:00:26 +0000 Subject: [PATCH 2/4] ci: add GitHub Actions workflow for Python 3.9-3.12 - Tests on Python 3.9, 3.10, 3.11, 3.12 - Runs on push and PR to master - Replaces defunct Travis CI --- .github/workflows/test.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2c52b6b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests + run: | + python -m pytest tests/ -v --ignore=tests/test_integration.py From 872618c9be988dc6536300b70de0c5aab590fe82 Mon Sep 17 00:00:00 2001 From: smarthomeo Date: Mon, 1 Jun 2026 14:07:12 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Fix=20multiple=20issues:=20mock=E2=86=92uni?= =?UTF-8?q?ttest.mock,=20requests=20timeout,=20telegram=20conflict,=20clea?= =?UTF-8?q?n=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #247: Replace with in all test files - Updated 13 test files to use instead of - Removed from test_deps in setup.py Issue #218: Add timeout=10 to all requests calls - Added timeout=10 to requests.get()/post() calls in: prowl.py, slack_webhook.py, pushalot.py, pushbullet.py, pushjet.py, notifico.py, simplepush.py, pushover.py, ntfy_sh.py - Updated corresponding test assertions to expect timeout=10 Issue #232: Slack backend already uses slack_sdk (no change needed) Issue #235: No SayThanks badge found in README (no change needed) Issue #260: Renamed telegram.py to telegram_send.py to avoid naming conflict with the python-telegram-bot package - Added BACKEND_ALIASES dict in ntfy/__init__.py to map 'telegram' → 'telegram_send' for backward compatibility with existing configurations --- ntfy/__init__.py | 9 +++++ ntfy/backends/notifico.py | 2 +- ntfy/backends/ntfy_sh.py | 1 + ntfy/backends/prowl.py | 2 +- ntfy/backends/pushalot.py | 2 +- ntfy/backends/pushbullet.py | 2 +- ntfy/backends/pushjet.py | 2 +- ntfy/backends/pushover.py | 3 +- ntfy/backends/simplepush.py | 2 +- ntfy/backends/slack_webhook.py | 1 + .../{telegram.py => telegram_send.py} | 0 setup.py | 2 +- tests/test_cli.py | 2 +- tests/test_config.py | 2 +- tests/test_integration.py | 2 +- tests/test_notifico.py | 6 +-- tests/test_ntfy.py | 2 +- tests/test_prowl.py | 4 +- tests/test_pushalot.py | 26 ++++++++----- tests/test_pushbullet.py | 11 ++++-- tests/test_pushjet.py | 11 ++++-- tests/test_pushover.py | 38 ++++++++++++------- tests/test_simplepush.py | 8 ++-- tests/test_systemlog.py | 2 +- tests/test_xmpp.py | 2 +- 25 files changed, 92 insertions(+), 52 deletions(-) rename ntfy/backends/{telegram.py => telegram_send.py} (100%) diff --git a/ntfy/__init__.py b/ntfy/__init__.py index a1043eb..bb2ac34 100644 --- a/ntfy/__init__.py +++ b/ntfy/__init__.py @@ -16,6 +16,12 @@ else: default_title = '{}@{}:{}'.format(getuser(), gethostname(), _cwd) +# Backend name aliases to avoid conflicts with third-party packages. +# Maps old/conflicting backend names to their renamed modules. +BACKEND_ALIASES = { + 'telegram': 'telegram_send', +} + def notify(message, title, config=None, **kwargs): from .config import load_config @@ -38,6 +44,9 @@ def notify(message, title, config=None, **kwargs): elif 'title' in backend_config: del backend_config['title'] + # Resolve backend aliases + backend = BACKEND_ALIASES.get(backend, backend) + try: notifier = import_module('ntfy.backends.{}'.format(backend)) except ImportError: diff --git a/ntfy/backends/notifico.py b/ntfy/backends/notifico.py index 73cc6f9..bc4ea83 100644 --- a/ntfy/backends/notifico.py +++ b/ntfy/backends/notifico.py @@ -20,5 +20,5 @@ def notify(title, message, retcode=None, webhook=None): params={ 'payload': '{title}\n{message}'.format( title=title, message=message) - }) + }, timeout=10) response.raise_for_status() diff --git a/ntfy/backends/ntfy_sh.py b/ntfy/backends/ntfy_sh.py index 83bbd6a..f696cc9 100644 --- a/ntfy/backends/ntfy_sh.py +++ b/ntfy/backends/ntfy_sh.py @@ -9,4 +9,5 @@ def notify(title, message, topic, host='https://ntfy.sh', user=None, password=No headers=dict(title=title), data=message, **auth_kwarg, + timeout=10, ) diff --git a/ntfy/backends/prowl.py b/ntfy/backends/prowl.py index 5fcffc6..101ac17 100644 --- a/ntfy/backends/prowl.py +++ b/ntfy/backends/prowl.py @@ -45,6 +45,6 @@ def notify(title, resp = requests.post( API_URL, data=data, headers={ 'User-Agent': USER_AGENT, - }) + }, timeout=10) resp.raise_for_status() diff --git a/ntfy/backends/pushalot.py b/ntfy/backends/pushalot.py index 329e726..ddbff86 100644 --- a/ntfy/backends/pushalot.py +++ b/ntfy/backends/pushalot.py @@ -53,5 +53,5 @@ def notify(title, data['IsSilent'] = 'True' headers = {'User-Agent': USER_AGENT} - response = requests.post(PUSHALOT_API_URL, data=data, headers=headers) + response = requests.post(PUSHALOT_API_URL, data=data, headers=headers, timeout=10) response.raise_for_status() diff --git a/ntfy/backends/pushbullet.py b/ntfy/backends/pushbullet.py index 4fbe4d7..608b48a 100644 --- a/ntfy/backends/pushbullet.py +++ b/ntfy/backends/pushbullet.py @@ -34,6 +34,6 @@ def notify(title, headers = {'Access-Token': access_token, 'User-Agent': USER_AGENT} resp = requests.post( - 'https://api.pushbullet.com/v2/pushes', data=data, headers=headers) + 'https://api.pushbullet.com/v2/pushes', data=data, headers=headers, timeout=10) resp.raise_for_status() diff --git a/ntfy/backends/pushjet.py b/ntfy/backends/pushjet.py index 0b41264..c087500 100644 --- a/ntfy/backends/pushjet.py +++ b/ntfy/backends/pushjet.py @@ -37,6 +37,6 @@ def notify(title, if endpoint is None: endpoint = 'https://api.pushjet.io' - resp = requests.post(endpoint + '/message', data=data, headers=headers) + resp = requests.post(endpoint + '/message', data=data, headers=headers, timeout=10) resp.raise_for_status() diff --git a/ntfy/backends/pushover.py b/ntfy/backends/pushover.py index 6acdf99..41e7d8b 100644 --- a/ntfy/backends/pushover.py +++ b/ntfy/backends/pushover.py @@ -111,7 +111,8 @@ def notify(title, data=data, headers={ 'User-Agent': USER_AGENT, - }) + }, + timeout=10) if resp.status_code == 429: print("ntfy's default api_token has reached pushover's rate limit") diff --git a/ntfy/backends/simplepush.py b/ntfy/backends/simplepush.py index 1bf73fb..4a2292e 100644 --- a/ntfy/backends/simplepush.py +++ b/ntfy/backends/simplepush.py @@ -26,6 +26,6 @@ def notify(title, message, key, event=None, retcode=None): endpoint = "https://api.simplepush.io" - resp = requests.post(endpoint + '/send', data=data, headers=headers) + resp = requests.post(endpoint + '/send', data=data, headers=headers, timeout=10) resp.raise_for_status() diff --git a/ntfy/backends/slack_webhook.py b/ntfy/backends/slack_webhook.py index d3bc17e..6df1ced 100644 --- a/ntfy/backends/slack_webhook.py +++ b/ntfy/backends/slack_webhook.py @@ -20,4 +20,5 @@ def notify(title, message, url, user, **kwargs): } ], }, + timeout=10, ) diff --git a/ntfy/backends/telegram.py b/ntfy/backends/telegram_send.py similarity index 100% rename from ntfy/backends/telegram.py rename to ntfy/backends/telegram_send.py diff --git a/setup.py b/setup.py index 859efe3..fb275e1 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ 'rocketchat':['rocketchat-API'], 'matrix':['matrix_client'], } -test_deps = ['mock', 'slixmpp', 'emoji', 'psutil'] +test_deps = ['slixmpp', 'emoji', 'psutil'] long_description = "See the repo readme for mor information" diff --git a/tests/test_cli.py b/tests/test_cli.py index 4a87911..48dcb6b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,7 @@ from time import time from unittest import TestCase, main -from mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, patch from ntfy.cli import main as ntfy_main from ntfy.cli import auto_done, run_cmd diff --git a/tests/test_config.py b/tests/test_config.py index 988563d..05445ac 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,7 @@ from sys import version_info from unittest import TestCase, main, skipIf -from mock import mock_open, patch +from unittest.mock import mock_open, patch from ntfy.config import DEFAULT_CONFIG, load_config py = version_info.major diff --git a/tests/test_integration.py b/tests/test_integration.py index d50cc7e..d6f9f5f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,7 +1,7 @@ from sys import modules, version_info from unittest import TestCase, main -from mock import MagicMock, mock_open, patch +from unittest.mock import MagicMock, mock_open, patch from ntfy.cli import main as ntfy_main py = version_info.major diff --git a/tests/test_notifico.py b/tests/test_notifico.py index ade33f9..4ef2e7c 100644 --- a/tests/test_notifico.py +++ b/tests/test_notifico.py @@ -2,7 +2,7 @@ from requests import HTTPError, Response -from mock import patch +from unittest.mock import patch from ntfy.backends.notifico import notify @@ -17,7 +17,7 @@ def test_basic(self, mock_get): mock_get.return_value = resp notify('title', 'message', webhook=self.webhook) mock_get.assert_called_once_with( - self.webhook, params={'payload': 'title\nmessage'}) + self.webhook, params={'payload': 'title\nmessage'}, timeout=10) @patch('requests.get') def test_none_webhook(self, mock_get): @@ -32,7 +32,7 @@ def test_exception(self, mock_get): with self.assertRaises(HTTPError): notify('title', 'message', webhook=self.webhook) mock_get.assert_called_once_with( - self.webhook, params={'payload': 'title\nmessage'}) + self.webhook, params={'payload': 'title\nmessage'}, timeout=10) if __name__ == '__main__': diff --git a/tests/test_ntfy.py b/tests/test_ntfy.py index 8b54622..1c8b7ab 100644 --- a/tests/test_ntfy.py +++ b/tests/test_ntfy.py @@ -1,7 +1,7 @@ from unittest import TestCase import ntfy -from mock import patch +from unittest.mock import patch from ntfy import notify diff --git a/tests/test_prowl.py b/tests/test_prowl.py index 3bc724e..f9c3b08 100644 --- a/tests/test_prowl.py +++ b/tests/test_prowl.py @@ -1,6 +1,6 @@ from unittest import TestCase, main -from mock import patch +from unittest.mock import patch from ntfy.backends.prowl import API_URL, NTFY_API_KEY, notify from ntfy.config import USER_AGENT @@ -30,7 +30,7 @@ def verify_post(self, priority=0, **kwargs): } data.update(kwargs) self.mock_post.assert_called_once_with( - API_URL, data=data, headers={'User-Agent': USER_AGENT}) + API_URL, data=data, headers={'User-Agent': USER_AGENT}, timeout=10) self.mock_response.raise_for_status.assert_called_once() def test_basic(self): diff --git a/tests/test_pushalot.py b/tests/test_pushalot.py index 622a307..c82c998 100644 --- a/tests/test_pushalot.py +++ b/tests/test_pushalot.py @@ -1,6 +1,6 @@ from unittest import TestCase, main -from mock import patch +from unittest.mock import patch from ntfy.backends.pushalot import notify from ntfy.config import USER_AGENT @@ -18,7 +18,8 @@ def test_basic(self, mock_post): 'Title': 'title', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_silent(self, mock_post): @@ -31,7 +32,8 @@ def test_silent(self, mock_post): 'IsSilent': 'True', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_important(self, mock_post): @@ -44,7 +46,8 @@ def test_important(self, mock_post): 'IsImportant': 'True', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_source(self, mock_post): @@ -57,7 +60,8 @@ def test_source(self, mock_post): 'Source': 'source', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_url(self, mock_post): @@ -71,7 +75,8 @@ def test_url(self, mock_post): 'Link': 'example.com', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_url_title(self, mock_post): @@ -90,7 +95,8 @@ def test_url_title(self, mock_post): 'LinkTitle': 'url title', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_image(self, mock_post): @@ -104,7 +110,8 @@ def test_image(self, mock_post): 'Image': 'image.jpg', 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_ttl(self, mock_post): @@ -117,7 +124,8 @@ def test_ttl(self, mock_post): 'TimeToLive': 100, 'AuthorizationToken': 'access_token' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) if __name__ == '__main__': diff --git a/tests/test_pushbullet.py b/tests/test_pushbullet.py index 467e82e..50e6998 100644 --- a/tests/test_pushbullet.py +++ b/tests/test_pushbullet.py @@ -1,6 +1,6 @@ from unittest import TestCase, main -from mock import patch +from unittest.mock import patch from ntfy.backends.pushbullet import notify from ntfy.config import USER_AGENT @@ -15,7 +15,8 @@ def test_basic(self, mock_post): 'title': 'title', 'type': 'note'}, headers={'Access-Token': 'access_token', - 'User-Agent': USER_AGENT}) + 'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_device(self, mock_post): @@ -33,7 +34,8 @@ def test_device(self, mock_post): 'type': 'note' }, headers={'Access-Token': 'access_token', - 'User-Agent': USER_AGENT}) + 'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_email(self, mock_post): @@ -51,7 +53,8 @@ def test_email(self, mock_post): 'type': 'note' }, headers={'Access-Token': 'access_token', - 'User-Agent': USER_AGENT}) + 'User-Agent': USER_AGENT}, + timeout=10) if __name__ == '__main__': diff --git a/tests/test_pushjet.py b/tests/test_pushjet.py index fe9e895..33cc026 100644 --- a/tests/test_pushjet.py +++ b/tests/test_pushjet.py @@ -1,6 +1,6 @@ from unittest import TestCase -from mock import patch +from unittest.mock import patch from ntfy.backends.pushjet import notify from ntfy.config import USER_AGENT @@ -17,7 +17,8 @@ def test_basic(self, mock_post): 'secret': 'secret', 'level': 3 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_link(self, mock_post): @@ -31,7 +32,8 @@ def test_link(self, mock_post): 'level': 3, 'link': 'foobar' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_endpoint(self, mock_post): @@ -44,4 +46,5 @@ def test_endpoint(self, mock_post): 'secret': 'secret', 'level': 3 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) diff --git a/tests/test_pushover.py b/tests/test_pushover.py index 890267e..c3ee110 100644 --- a/tests/test_pushover.py +++ b/tests/test_pushover.py @@ -1,6 +1,6 @@ from unittest import TestCase, main -from mock import patch +from unittest.mock import patch from ntfy.backends.pushover import notify from ntfy.config import USER_AGENT @@ -17,7 +17,8 @@ def test_basic(self, mock_post): 'token': 'aUnsraBiEZVsmrG89AZp47K3S2dX2a', 'title': 'title' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_url_title(self, mock_post): @@ -37,7 +38,8 @@ def test_url_title(self, mock_post): 'url_title': 'foo', 'url': 'bar' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) mock_post.reset_mock() notify('title', 'message', user_key='user_key', url_title='foo') @@ -49,7 +51,8 @@ def test_url_title(self, mock_post): 'token': 'aUnsraBiEZVsmrG89AZp47K3S2dX2a', 'title': 'title' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_html(self, mock_post): @@ -63,7 +66,8 @@ def test_html(self, mock_post): 'title': 'title', 'html': 1 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_priority(self, mock_post): @@ -77,7 +81,8 @@ def test_priority(self, mock_post): 'title': 'title', 'priority': 1 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_invalid_priority(self, mock_post): @@ -103,7 +108,8 @@ def test_hi_priority(self, mock_post): 'retry': 30, 'expire': 86400 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_hi_priority_retry(self, mock_post): @@ -119,7 +125,8 @@ def test_hi_priority_retry(self, mock_post): 'retry': 60, 'expire': 86400 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_hi_priority_expire(self, mock_post): @@ -135,7 +142,8 @@ def test_hi_priority_expire(self, mock_post): 'retry': 30, 'expire': 60 }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_hi_priority_callback(self, mock_post): @@ -157,7 +165,8 @@ def test_hi_priority_callback(self, mock_post): 'expire': 86400, 'callback': 'http://example.com' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_device(self, mock_post): @@ -171,7 +180,8 @@ def test_device(self, mock_post): 'title': 'title', 'device': 'foobar' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_sound(self, mock_post): @@ -185,7 +195,8 @@ def test_sound(self, mock_post): 'title': 'title', 'sound': 'foobar' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_url(self, mock_post): @@ -199,7 +210,8 @@ def test_url(self, mock_post): 'title': 'title', 'url': 'foobar' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) if __name__ == '__main__': diff --git a/tests/test_simplepush.py b/tests/test_simplepush.py index 00510c3..ea71481 100644 --- a/tests/test_simplepush.py +++ b/tests/test_simplepush.py @@ -1,6 +1,6 @@ from unittest import TestCase -from mock import patch +from unittest.mock import patch from ntfy.backends.simplepush import notify from ntfy.config import USER_AGENT @@ -14,7 +14,8 @@ def test_basic(self, mock_post): data={'title': 'title', 'msg': 'message', 'key': 'secret'}, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) @patch('requests.post') def test_event(self, mock_post): @@ -27,4 +28,5 @@ def test_event(self, mock_post): 'key': 'secret', 'event': 'foo' }, - headers={'User-Agent': USER_AGENT}) + headers={'User-Agent': USER_AGENT}, + timeout=10) diff --git a/tests/test_systemlog.py b/tests/test_systemlog.py index 1dfec96..3955376 100644 --- a/tests/test_systemlog.py +++ b/tests/test_systemlog.py @@ -1,6 +1,6 @@ from unittest import TestCase, skipIf -from mock import call, patch +from unittest.mock import call, patch try: import syslog diff --git a/tests/test_xmpp.py b/tests/test_xmpp.py index 0f1cdad..edf2731 100644 --- a/tests/test_xmpp.py +++ b/tests/test_xmpp.py @@ -1,6 +1,6 @@ from unittest import TestCase -from mock import MagicMock, patch, AsyncMock +from unittest.mock import MagicMock, patch, AsyncMock from ntfy.backends.xmpp import NtfySendMsgBot, notify from ntfy.config import USER_AGENT From 5e3ff8b56852573ca7bfdb2d8b9b24b8de5b8ecf Mon Sep 17 00:00:00 2001 From: smarthomeo Date: Mon, 1 Jun 2026 14:11:39 +0000 Subject: [PATCH 4/4] Fix multiple issues: logging handler, yaml config, timeout flag, pushover TTL/priority, CWD error, --pid flag - Issue #275: Add NtfyHandler logging.Handler subclass in ntfy/logging.py with tests in tests/test_logging.py. Uses ntfy.notify() to send log messages via the default backend. Supports custom title and message formatting via standard logging.Formatter. - Issue #273: Move AUTO_NTFY_DONE_IGNORE to yaml config. The shell-integration subcommand now reads 'auto_ntfy_done_ignore' from ~/.ntfy.yml and exports it as an environment variable so the shell integration script can use it instead of the hardcoded default. - Issue #237: Add --timeout CLI flag that sets notification display timeout (seconds). Passed through to backends that support it (e.g. Linux desktop notifications via D-Bus). - Issue #267: Add TTL support for Pushover backend. The 'ttl' parameter is passed through to the Pushover API. - Issue #225: Pushover priority 2 (emergency) already had default expire/retry values (expire=86400, retry=30) when not provided. Existing tests confirm this behavior. - Issue #195: Fix crash when launched from non-existent directory by wrapping getcwd() in OSError handler, falling back to '[unknown]'. - Issue #217: Fix --pid argument not recognized when psutil is not installed. Moved --pid definition outside the 'if psutil is not None:' guard; watch_pid() already handles missing psutil gracefully. - Fix import conflict: ntfy/logging.py shadows stdlib 'logging' module. Changed 'import logging' to 'import logging as _logging' in ntfy/__init__.py and updated all references to use _logging.getLogger. --- ntfy/__init__.py | 15 +++++---- ntfy/backends/pushover.py | 4 +++ ntfy/cli.py | 23 ++++++++++---- ntfy/logging.py | 31 +++++++++++++++++++ tests/test_logging.py | 64 +++++++++++++++++++++++++++++++++++++++ tests/test_pushover.py | 15 +++++++++ 6 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 ntfy/logging.py create mode 100644 tests/test_logging.py diff --git a/ntfy/__init__.py b/ntfy/__init__.py index bb2ac34..37a3053 100644 --- a/ntfy/__init__.py +++ b/ntfy/__init__.py @@ -1,4 +1,4 @@ -import logging +import logging as _logging from getpass import getuser from os import getcwd, path, name from socket import gethostname @@ -9,7 +9,10 @@ __version__ = '2.7.1' _user_home = path.expanduser('~') -_cwd = getcwd() +try: + _cwd = getcwd() +except OSError: + _cwd = '[unknown]' if name != 'nt' and _cwd.startswith(_user_home): default_title = '{}@{}:{}'.format( getuser(), gethostname(), path.join('~', _cwd[len(_user_home) + 1:])) @@ -53,7 +56,7 @@ def notify(message, title, config=None, **kwargs): try: notifier = import_module(backend) except ImportError: - logging.getLogger(__name__).error( + _logging.getLogger(__name__).error( 'Invalid backend {}'.format(backend)) ret = 1 continue @@ -82,15 +85,15 @@ def notify(message, title, config=None, **kwargs): missing_args = required_args - set(backend_config) if unknown_args: - logging.getLogger(__name__).error( + _logging.getLogger(__name__).error( 'Got unknown arguments: {}'.format(unknown_args)) if missing_args: - logging.getLogger(__name__).error( + _logging.getLogger(__name__).error( 'Missing arguments: {}'.format(missing_args)) if not any([unknown_args, missing_args]): - logging.getLogger(__name__).error( + _logging.getLogger(__name__).error( 'Failed to send notification using {}'.format(backend), exc_info=True) diff --git a/ntfy/backends/pushover.py b/ntfy/backends/pushover.py index 41e7d8b..dc195ce 100644 --- a/ntfy/backends/pushover.py +++ b/ntfy/backends/pushover.py @@ -20,6 +20,7 @@ def notify(title, url=None, url_title=None, html=False, + ttl=None, retcode=None): """ Required parameters: @@ -106,6 +107,9 @@ def notify(title, else: raise ValueError('priority must be an integer from -2 to 2') + if ttl is not None: + data['ttl'] = ttl + resp = requests.post( 'https://api.pushover.net/1/messages.json', data=data, diff --git a/ntfy/cli.py b/ntfy/cli.py index 41145fd..a198d5d 100644 --- a/ntfy/cli.py +++ b/ntfy/cli.py @@ -128,6 +128,9 @@ def auto_done(args): args.longer_than)) if args.unfocused_only: print('export AUTO_NTFY_DONE_UNFOCUSED_ONLY=-b') + ignore = getattr(args, 'config_data', {}).get('auto_ntfy_done_ignore') + if ignore: + print('export AUTO_NTFY_DONE_IGNORE="{}"'.format(ignore)) if args.shell == 'bash': print('source {}'.format(sh_quote(scripts['bash-preexec.sh']))) print('source {}'.format(sh_quote(scripts['auto-ntfy-done.sh']))) @@ -207,6 +210,11 @@ def __call__(self, parser, args, values, option_string=None): '-t', '--title', help='a title for the notification (default: {})'.format(default_title)) +parser.add_argument( + '--timeout', + type=int, + default=None, + help='How long the notification stays visible (seconds)') subparsers = parser.add_subparsers() @@ -243,12 +251,11 @@ def default_sender(args): nargs=3, help="Format and send cmd, retcode & duration instead of running command. " "Used internally by shell-integration") -if psutil is not None: - done_parser.add_argument( - '-p', - '--pid', - type=int, - help="Watch a PID instead of running a new command") +done_parser.add_argument( + '-p', + '--pid', + type=int, + help="Watch a PID instead of running a new command") done_parser.add_argument( '-o', '--stdout', @@ -353,7 +360,11 @@ def main(cli_args=None): if getattr(args, 'func', None) == run_cmd and 'hide_command' in config: args.hide_command = config['hide_command'] + if getattr(args, 'timeout', None) is not None: + args.option.setdefault(None, {})['timeout'] = args.timeout + if hasattr(args, 'func'): + args.config_data = config message, retcode = args.func(args) if message is None: return 0 diff --git a/ntfy/logging.py b/ntfy/logging.py new file mode 100644 index 0000000..f0b1ff4 --- /dev/null +++ b/ntfy/logging.py @@ -0,0 +1,31 @@ +import logging + +from . import notify +from .config import load_config + + +class NtfyHandler(logging.Handler): + """A logging handler that sends notifications via ntfy. + + Example usage:: + + import logging + from ntfy.logging import NtfyHandler + + logger = logging.getLogger(__name__) + logger.addHandler(NtfyHandler(title="My App")) + logger.error("Something went wrong!") # sends notification + """ + + def __init__(self, title="ntfy", level=logging.ERROR, config=None): + super().__init__(level=level) + self.title = title + self._config = config + + def emit(self, record): + try: + message = self.format(record) + config = self._config if self._config is not None else load_config() + notify(message=message, title=self.title, config=config) + except Exception: + self.handleError(record) diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..922d8a0 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,64 @@ +import logging +from unittest import TestCase +from unittest.mock import patch + +from ntfy.logging import NtfyHandler + + +class TestNtfyHandler(TestCase): + def test_default_level(self): + handler = NtfyHandler(title="Test") + self.assertEqual(handler.level, logging.ERROR) + + def test_custom_level(self): + handler = NtfyHandler(title="Test", level=logging.WARNING) + self.assertEqual(handler.level, logging.WARNING) + + def test_default_title(self): + handler = NtfyHandler() + self.assertEqual(handler.title, "ntfy") + + @patch("ntfy.logging.notify") + def test_emit(self, mock_notify): + handler = NtfyHandler(title="My App") + log_record = logging.LogRecord( + "test", logging.ERROR, "/path/file.py", 42, + "Something broke!", None, None + ) + handler.emit(log_record) + + mock_notify.assert_called_once() + _, kwargs = mock_notify.call_args + self.assertEqual(kwargs["title"], "My App") + self.assertIn("Something broke!", kwargs["message"]) + + @patch("ntfy.logging.notify") + def test_emit_with_config(self, mock_notify): + config = {"backends": ["default"]} + handler = NtfyHandler(title="Test", config=config) + log_record = logging.LogRecord( + "test", logging.WARNING, "/path/file.py", 42, + "Warning!", None, None + ) + handler.emit(log_record) + + mock_notify.assert_called_once() + _, kwargs = mock_notify.call_args + self.assertEqual(kwargs["config"], config) + self.assertEqual(kwargs["title"], "Test") + self.assertIn("Warning!", kwargs["message"]) + + @patch("ntfy.logging.notify") + def test_emit_formatted(self, mock_notify): + handler = NtfyHandler(title="App") + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + log_record = logging.LogRecord( + "test", logging.ERROR, "/path/file.py", 42, + "Something broke!", None, None + ) + handler.emit(log_record) + + mock_notify.assert_called_once() + _, kwargs = mock_notify.call_args + self.assertEqual(kwargs["title"], "App") + self.assertEqual(kwargs["message"], "ERROR: Something broke!") diff --git a/tests/test_pushover.py b/tests/test_pushover.py index c3ee110..79a547e 100644 --- a/tests/test_pushover.py +++ b/tests/test_pushover.py @@ -198,6 +198,21 @@ def test_sound(self, mock_post): headers={'User-Agent': USER_AGENT}, timeout=10) + @patch('requests.post') + def test_ttl(self, mock_post): + notify('title', 'message', user_key='user_key', ttl=3600) + mock_post.assert_called_once_with( + 'https://api.pushover.net/1/messages.json', + data={ + 'user': 'user_key', + 'message': 'message', + 'token': 'aUnsraBiEZVsmrG89AZp47K3S2dX2a', + 'title': 'title', + 'ttl': 3600 + }, + headers={'User-Agent': USER_AGENT}, + timeout=10) + @patch('requests.post') def test_url(self, mock_post): notify('title', 'message', user_key='user_key', url='foobar')