From 8e516254fb5b7636e2440b82e5642bb412623239 Mon Sep 17 00:00:00 2001 From: Carina Antunes <carina.oliveira.antunes@cern.ch> Date: Mon, 15 Aug 2022 11:23:11 +0200 Subject: [PATCH] [#76] Safari push consumer: replace apns2 library with aioapns --- .isort.cfg | 2 +- logging.yaml | 4 + notifications_consumer/consumer.py | 2 +- .../megabus/client_individual_consumer.py | 4 +- notifications_consumer/processors/registry.py | 12 +- .../processors/safaripush/client.py | 79 +++++++++++++ .../processors/safaripush/processor.py | 18 ++- .../processors/safaripush/utils.py | 39 ------- poetry.lock | 107 +++++++----------- pyproject.toml | 2 +- .../unit/test_processors_safaripush_utils.py | 18 ++- 11 files changed, 162 insertions(+), 125 deletions(-) create mode 100644 notifications_consumer/processors/safaripush/client.py diff --git a/.isort.cfg b/.isort.cfg index e6663ff..5b68580 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -4,4 +4,4 @@ multi_line_output=3 include_trailing_comma=True lines_after_imports=2 not_skip=__init__.py -known_third_party = Crypto,apns2,bs4,cachetools,etcd3,jinja2,lxml,markdownify,mattermostdriver,megabus,pytest,pywebpush,requests,smail,sqlalchemy,stomp,yaml +known_third_party = Crypto,aioapns,bs4,cachetools,etcd3,jinja2,lxml,markdownify,mattermostdriver,megabus,pytest,pywebpush,requests,smail,sqlalchemy,stomp,yaml diff --git a/logging.yaml b/logging.yaml index 79d7459..115a4c0 100644 --- a/logging.yaml +++ b/logging.yaml @@ -36,6 +36,10 @@ loggers: level: WARNING handlers: [] propagate: True + aioapns: + level: WARNING + handlers: [] + propagate: True root: level: INFO diff --git a/notifications_consumer/consumer.py b/notifications_consumer/consumer.py index 57762a7..82b3cdb 100644 --- a/notifications_consumer/consumer.py +++ b/notifications_consumer/consumer.py @@ -14,7 +14,7 @@ class NotificationsConsumer(ClientIndividualListener): """Initialize the Notifications consumer.""" ClientIndividualListener.__init__(self) self.config = config - self.processor = ProcessorRegistry.processor(self.config.PROCESSOR, config) + self.processor = ProcessorRegistry.processor(self.config.PROCESSOR, config, **kwargs) self.kwargs = kwargs def on_message(self, message, headers): diff --git a/notifications_consumer/megabus/client_individual_consumer.py b/notifications_consumer/megabus/client_individual_consumer.py index a306fc1..446c7f8 100644 --- a/notifications_consumer/megabus/client_individual_consumer.py +++ b/notifications_consumer/megabus/client_individual_consumer.py @@ -1,4 +1,5 @@ """Client Individual Consumer definition.""" +import asyncio import logging import threading import time @@ -64,6 +65,7 @@ class ClientIndividualConsumer(Consumer): self._listeners = [] # type : List[ClientIndividualListener] self._hosts = [] # type : List[str] self._on_disconnect_evt = threading.Event() + self._asyncio_loop = asyncio.get_event_loop() # Auto connect must be called until _listener_kwargs are set if auto_connect: @@ -76,7 +78,7 @@ class ClientIndividualConsumer(Consumer): self, ): """Create a new instance of the Listener class.""" - return self._external_listener(**self._listener_kwargs) + return self._external_listener(**self._listener_kwargs, asyncio_loop=self._asyncio_loop) def __reconnect(self): self._disconnect() diff --git a/notifications_consumer/processors/registry.py b/notifications_consumer/processors/registry.py index 2f11ae3..602e901 100644 --- a/notifications_consumer/processors/registry.py +++ b/notifications_consumer/processors/registry.py @@ -24,15 +24,19 @@ class ProcessorRegistry: return processor_cls @classmethod - def processor(cls, processor_id, config): + def processor(cls, processor_id, config, **kwargs): """Return a new processor instance.""" processor_cls = cls.registry[processor_id] - return processor_cls(**build_kwargs(config)) + return processor_cls(**build_kwargs(config, **kwargs)) -def build_kwargs(config: Config) -> dict: +def build_kwargs(config: Config, **kwargs) -> dict: """Build processor kwargs.""" - kwargs = dict(config=config) + if not kwargs: + kwargs = dict() + + kwargs["config"] = config + if config.PUBLISHER_NAME: kwargs["publisher"] = Publisher(config.PUBLISHER_NAME) diff --git a/notifications_consumer/processors/safaripush/client.py b/notifications_consumer/processors/safaripush/client.py new file mode 100644 index 0000000..637c646 --- /dev/null +++ b/notifications_consumer/processors/safaripush/client.py @@ -0,0 +1,79 @@ +"""safaripush notifications utils.""" +import asyncio +import html +import logging +import re +import unicodedata + +from aioapns import APNs, NotificationRequest + +from notifications_consumer.config import Config +from notifications_consumer.processors.safaripush.utils import remove_http + + +class ThreadSafeSafariPushClient: + """Thread safe Apple Push Notification Service Wrapper. + + A sync client like apns2 would be better to avoid handling asyncio and threads together. + However at the time, there's no maintained apsn client except the implemented in this class: aioapns. + We should revisit apns2 eventually: https://github.com/Pr0Ger/PyAPNs2/issues/126#issuecomment-1008424954. + """ + + def __init__(self, asyncio_loop): + """Initialize APN Client.""" + self._monkey_patch_asyncio_event_loop(asyncio_loop) + + self.client = APNs( + client_cert=Config.APPLE_SAFARI_PUSH_CERT_KEY_FILE_PATH, + topic=Config.APPLE_SAFARI_PUSH_WEBSITEPUSHID, + use_sandbox=False, + ) + + @staticmethod + def _monkey_patch_asyncio_event_loop(loop): + """Monkey patch event loop for asyncio-threads compatibility in the current module. + + Threads created externally in stomp/python-megabus don't have access to the asyncio loop, which is only + available from the Main Thread. The current workaround is to receive the loop created in the main thread, + and monkeypatch asyncio.get_event_loop so it always sets the loop for the current thread before returning it. + """ + + def _patch(): + asyncio.set_event_loop(loop) + return loop + + asyncio.get_running_loop = _patch + asyncio.get_event_loop = _patch + + @staticmethod + def _create_request(summary: str, body: str, url: str, image: str, device_token: str): + # strip tags, html decode and replace unicode blobs + text_content = unicodedata.normalize("NFKD", html.unescape(re.sub("<.*?>", "", body))) + + # click URL needs to be cleaned from leading http(s):// due to definition set to https://%s in the + # pushPackage.zip + return NotificationRequest( + device_token=device_token, + message={ + "aps": { + "alert": { + "title": summary, + "body": text_content[:3000], + }, + "url-args": [remove_http(url)], + } + }, + ) + + async def _send_notification(self, request: NotificationRequest): + logging.debug("Running async ThreadSafeSafariPushClient._send_message in main thread event loop") + await self.client.send_notification(request) + + def send_message(self, summary: str, body: str, url: str, image: str, device_token: str): + """Create json blob to be sent via safaripush.""" + request = self._create_request(summary, body, url, image, device_token) + logging.debug("safaripush send_message to %s", device_token) + future = asyncio.run_coroutine_threadsafe(self._send_notification(request), asyncio.get_running_loop()) + # Wait for the result with a timeout + assert future.result(timeout=3) is None + logging.debug("Safari message sent") diff --git a/notifications_consumer/processors/safaripush/processor.py b/notifications_consumer/processors/safaripush/processor.py index 8123d60..b9e53db 100644 --- a/notifications_consumer/processors/safaripush/processor.py +++ b/notifications_consumer/processors/safaripush/processor.py @@ -6,13 +6,18 @@ from bs4 import BeautifulSoup from notifications_consumer.processors.processor import Processor from notifications_consumer.processors.registry import ProcessorRegistry -from notifications_consumer.processors.safaripush.utils import create_message, send_message +from notifications_consumer.processors.safaripush.client import ThreadSafeSafariPushClient @ProcessorRegistry.register class SafariPushProcessor(Processor): """Send safaripush notifications.""" + def __init__(self, asyncio_loop, **kwargs): + """Initialize client.""" + Processor.__init__(self, **kwargs) + self.client = ThreadSafeSafariPushClient(asyncio_loop) + __id = "safaripush" @classmethod @@ -34,24 +39,17 @@ class SafariPushProcessor(Processor): subject = f'[{kwargs["channel_name"]}] - {kwargs["summary"]}' message_body = BeautifulSoup(kwargs["message_body"], "lxml").get_text(" ") - spmessage = create_message( - subject, - message_body, - kwargs["link"], - kwargs["img_url"], - ) - if self.auditor.get_audit_notification(kwargs["notification_id"], device_id, email): logging.info( "%s is already sent to %s according ot etcd, skipping", kwargs["notification_id"], device_token ) return - ret = send_message(spmessage, device_token) + self.client.send_message(subject, message_body, kwargs["link"], kwargs["img_url"], device_token) + self.auditor.audit_notification( kwargs["notification_id"], {"event": "Sent", "device_token": device_token}, device_id, email ) - return ret def read_message(self, message: Dict): """Read the message.""" diff --git a/notifications_consumer/processors/safaripush/utils.py b/notifications_consumer/processors/safaripush/utils.py index 658d6ad..08011da 100644 --- a/notifications_consumer/processors/safaripush/utils.py +++ b/notifications_consumer/processors/safaripush/utils.py @@ -1,13 +1,4 @@ """safaripush notifications utils.""" -import html -import logging -import re -import unicodedata - -from apns2.client import APNsClient -from apns2.payload import Payload - -from notifications_consumer.config import Config # Strip start http:// or https:// from url @@ -20,33 +11,3 @@ def remove_http(inputstring): if inputstring.startswith("http://"): return inputstring[7:] return inputstring - - -def create_message(summary: str, body: str, url: str, image: str): - """Create json blob to be sent via safaripush.""" - # strip tags, html decode and replace unicode blobs - text_content = unicodedata.normalize("NFKD", html.unescape(re.sub("<.*?>", "", body))) - - # click URL needs to be cleaned from leading http(s):// due to definition set to https://%s in the pushPackage.zip - spmessage = Payload( - alert={ - "title": summary, - "body": text_content[:3000], - } - ) - spmessage.url_args = [remove_http(url)] - - return spmessage - - -def send_message(payload: Payload, device_token: str): - """Send the message Payload. - - :param payload: apns2 Payload object - :param device_token: Device token - """ - logging.debug("safaripush send_message to %s", device_token) - topic = Config.APPLE_SAFARI_PUSH_WEBSITEPUSHID - client = APNsClient(Config.APPLE_SAFARI_PUSH_CERT_KEY_FILE_PATH, use_sandbox=False, use_alternative_port=False) - client.send_notification(device_token, payload, topic) - logging.debug("Safari message sent") diff --git a/poetry.lock b/poetry.lock index 072f634..002abd7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,16 @@ +[[package]] +name = "aioapns" +version = "2.1" +description = "An efficient APNs Client Library for Python/asyncio" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +h2 = ">=4.0.0" +pyjwt = ">=2.0.0" +pyOpenSSL = ">=17.5.0" + [[package]] name = "aiohttp" version = "3.8.1" @@ -32,22 +45,6 @@ python-versions = ">=3.6" [package.dependencies] frozenlist = ">=1.1.0" -[[package]] -name = "apns2" -version = "0.7.2" -description = "A python library for interacting with the Apple Push Notification Service via HTTP/2 protocol" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -cryptography = ">=1.7.2" -hyper = ">=0.7" -PyJWT = ">=1.4.0,<2.0.0" - -[package.extras] -tests = ["freezegun", "pytest"] - [[package]] name = "appnope" version = "0.1.3" @@ -298,23 +295,23 @@ python-versions = ">=3.6" [[package]] name = "h2" -version = "2.6.2" +version = "4.1.0" description = "HTTP/2 State-Machine based protocol implementation" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6.1" [package.dependencies] -hpack = ">=2.2,<4" -hyperframe = ">=3.1,<4.0.0 || >4.0.0,<6" +hpack = ">=4.0,<5" +hyperframe = ">=6.0,<7" [[package]] name = "hpack" -version = "3.0.0" +version = "4.0.0" description = "Pure-Python HPACK header compression" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6.1" [[package]] name = "http-ece" @@ -327,28 +324,13 @@ python-versions = "*" [package.dependencies] cryptography = ">=2.5" -[[package]] -name = "hyper" -version = "0.7.0" -description = "HTTP/2 Client for Python" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -h2 = ">=2.4,<3.0" -hyperframe = ">=3.2,<4.0" - -[package.extras] -fast = ["pycohttpparser"] - [[package]] name = "hyperframe" -version = "3.2.0" +version = "6.0.1" description = "HTTP/2 framing layer for Python" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6.1" [[package]] name = "identify" @@ -597,7 +579,7 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] -testing = ["docopt", "pytest (>=3.0.7)"] +testing = ["pytest (>=3.0.7)", "docopt"] [[package]] name = "pexpect" @@ -642,8 +624,8 @@ python-versions = ">=3.6" importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] [[package]] name = "pre-commit" @@ -765,16 +747,17 @@ python-versions = ">=3.6" [[package]] name = "pyjwt" -version = "1.7.1" +version = "2.4.0" description = "JSON Web Token implementation in Python" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.extras] -crypto = ["cryptography (>=1.4)"] -flake8 = ["flake8", "flake8-import-order", "pep8-naming"] -test = ["pytest (>=4.0.1,<5.0.0)", "pytest-cov (>=2.6.0,<3.0.0)", "pytest-runner (>=4.2,<5.0.0)"] +crypto = ["cryptography (>=3.3.1)"] +dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] [[package]] name = "pyopenssl" @@ -1103,9 +1086,13 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "3e248119a43c3556cb8a5dbd4f3db0329a617d9a1291aff7d7ff5d1da2e22e15" +content-hash = "299e7eb6edfbb16faeca3b0140e7ee6dc76dd2b910d60b026f354f5de4654665" [metadata.files] +aioapns = [ + {file = "aioapns-2.1-py3-none-any.whl", hash = "sha256:095a205ec08481a640ea85be1a85e4c1b26a43058a489ee7e48dcc13ab2d1d12"}, + {file = "aioapns-2.1.tar.gz", hash = "sha256:2ce526910bc2514a84b8105abe80508526ceafc0097c89f86bbbc501f8666c99"}, +] aiohttp = [ {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"}, {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"}, @@ -1184,10 +1171,6 @@ aiosignal = [ {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, ] -apns2 = [ - {file = "apns2-0.7.2-py2.py3-none-any.whl", hash = "sha256:f64a50181d0206a02943c835814a34fc1b1e12914931b74269a0f0fb4f39fd45"}, - {file = "apns2-0.7.2.tar.gz", hash = "sha256:4f2dae8c608961d1768f734acb1d0809a60ac71a0cdcca60f46529b73f20fb34"}, -] appnope = [ {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, @@ -1421,23 +1404,19 @@ frozenlist = [ {file = "frozenlist-1.2.0.tar.gz", hash = "sha256:68201be60ac56aff972dc18085800b6ee07973c49103a8aba669dee3d71079de"}, ] h2 = [ - {file = "h2-2.6.2-py2.py3-none-any.whl", hash = "sha256:93cbd1013a2218539af05cdf9fc37b786655b93bbc94f5296b7dabd1c5cadf41"}, - {file = "h2-2.6.2.tar.gz", hash = "sha256:af35878673c83a44afbc12b13ac91a489da2819b5dc1e11768f3c2406f740fe9"}, + {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, + {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, ] hpack = [ - {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"}, - {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"}, + {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, + {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, ] http-ece = [ {file = "http_ece-1.1.0.tar.gz", hash = "sha256:932ebc2fa7c216954c320a188ae9c1f04d01e67bec9cdce1bfbc912813b0b4f8"}, ] -hyper = [ - {file = "hyper-0.7.0-py2.py3-none-any.whl", hash = "sha256:069514f54231fb7b5df2fb910a114663a83306d5296f588fffcb0a9be19407fc"}, - {file = "hyper-0.7.0.tar.gz", hash = "sha256:12c82eacd122a659673484c1ea0d34576430afbe5aa6b8f63fe37fcb06a2458c"}, -] hyperframe = [ - {file = "hyperframe-3.2.0-py2.py3-none-any.whl", hash = "sha256:4dcab11967482d400853b396d042038e4c492a15a5d2f57259e2b5f89a32f755"}, - {file = "hyperframe-3.2.0.tar.gz", hash = "sha256:05f0e063e117c16fcdd13c12c93a4424a2c40668abfac3bb419a10f57698204e"}, + {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, + {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, ] identify = [ {file = "identify-2.4.4-py2.py3-none-any.whl", hash = "sha256:aa68609c7454dbcaae60a01ff6b8df1de9b39fe6e50b1f6107ec81dcda624aa6"}, @@ -1864,8 +1843,8 @@ pygments = [ {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, ] pyjwt = [ - {file = "PyJWT-1.7.1-py2.py3-none-any.whl", hash = "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e"}, - {file = "PyJWT-1.7.1.tar.gz", hash = "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"}, + {file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"}, + {file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"}, ] pyopenssl = [ {file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"}, diff --git a/pyproject.toml b/pyproject.toml index f0823d5..a6c823f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ PyYAML = "^5.3.1" SQLAlchemy = "1.3.20" psycopg2-binary = "^2.8.6" pywebpush = "^1.11.0" -apns2 = "^0.7.2" pytest = "^6.2.3" unittest = "^0.0" python-smail = "^0.9.0" @@ -27,6 +26,7 @@ lxml = "^4.8.0" markdownify = "^0.11.2" watchpoints = "^0.2.5" pycryptodome = "^3.15.0" +aioapns = "^2.1" [tool.poetry.dev-dependencies] pre-commit = "~2.9.2" diff --git a/tests/unit/test_processors_safaripush_utils.py b/tests/unit/test_processors_safaripush_utils.py index 047c6d1..061d149 100644 --- a/tests/unit/test_processors_safaripush_utils.py +++ b/tests/unit/test_processors_safaripush_utils.py @@ -1,7 +1,7 @@ """Unit Tests for Utils in webpush.""" import unittest -from notifications_consumer.processors.safaripush.utils import create_message +from notifications_consumer.processors.safaripush.client import ThreadSafeSafariPushClient class TestUtils(unittest.TestCase): @@ -9,10 +9,20 @@ class TestUtils(unittest.TestCase): def test_validate_safari_message_body_decoded_correctly(self): """Tests correct bpdy decode htmlstrip+hemldecode.""" + expected_message = { + "aps": { + "alert": { + "title": "Test", + "body": "This is a test we've got", + }, + "url-args": ["cern.ch/news?today"], + } + } body = "<p>This is a test we've got</p>" - expected_body = "This is a test we've got" - sp_message = create_message("Test", body, None, None) - self.assertTrue(sp_message.alert["body"] == expected_body) + url = "https://cern.ch/news?today" + request = ThreadSafeSafariPushClient._create_request("Test", body, url, None, "token") + self.assertTrue(request.message == expected_message) + self.assertTrue(request.device_token == "token") if __name__ == "__main__": -- GitLab