pywebpush 2.3.0__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pywebpush-2.3.0 → pywebpush-2.5.0}/CHANGELOG.md +5 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/MANIFEST.in +2 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/PKG-INFO +10 -3
- {pywebpush-2.3.0 → pywebpush-2.5.0}/README.md +5 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pyproject.toml +38 -4
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/__init__.py +80 -101
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/__main__.py +5 -7
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/tests/test_webpush.py +32 -21
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/PKG-INFO +10 -3
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/SOURCES.txt +0 -3
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/requires.txt +3 -1
- {pywebpush-2.3.0 → pywebpush-2.5.0}/requirements.txt +1 -0
- pywebpush-2.3.0/local_test.txt +0 -3
- pywebpush-2.3.0/pywebpush/foo.py +0 -51
- pywebpush-2.3.0/test-requirements.txt +0 -4
- {pywebpush-2.3.0 → pywebpush-2.5.0}/CODE_OF_CONDUCT.md +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/LICENSE +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/README.rst +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/entry_points.txt +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/tests/__init__.py +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/dependency_links.txt +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/entry_points.txt +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/top_level.txt +0 -0
- {pywebpush-2.3.0 → pywebpush-2.5.0}/setup.cfg +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywebpush
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: WebPush publication library
|
|
5
5
|
Author-email: JR Conlin <src+webpusher@jrconlin.com>
|
|
6
|
-
License: MPL-2.0
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/web-push-libs/pywebpush
|
|
8
8
|
Keywords: webpush,vapid,notification
|
|
9
9
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
@@ -14,11 +14,13 @@ Requires-Python: >=3.10
|
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
License-File: LICENSE
|
|
16
16
|
Requires-Dist: aiohttp
|
|
17
|
-
Requires-Dist: cryptography>=
|
|
17
|
+
Requires-Dist: cryptography>=47.0.0
|
|
18
18
|
Requires-Dist: http-ece>=1.1.0
|
|
19
19
|
Requires-Dist: requests>=2.21.0
|
|
20
20
|
Requires-Dist: py-vapid>=1.7.0
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: isort; extra == "dev"
|
|
23
|
+
Requires-Dist: bandit; extra == "dev"
|
|
22
24
|
Requires-Dist: black; extra == "dev"
|
|
23
25
|
Requires-Dist: mock; extra == "dev"
|
|
24
26
|
Requires-Dist: pytest; extra == "dev"
|
|
@@ -130,6 +132,11 @@ try:
|
|
|
130
132
|
)
|
|
131
133
|
except WebPushException as ex:
|
|
132
134
|
print("I'm sorry, Dave, but I can't do that: {}", repr(ex))
|
|
135
|
+
# status_code works with both webpush() and webpush_async().
|
|
136
|
+
if ex.status_code in (429, 503):
|
|
137
|
+
print("Push service requested a retry after:", ex.retry_after)
|
|
138
|
+
# retry_after is either a delay in seconds or an HTTP date:
|
|
139
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
|
|
133
140
|
# Mozilla returns additional information in the body of the response.
|
|
134
141
|
if ex.response is not None and ex.response.json():
|
|
135
142
|
extra = ex.response.json()
|
|
@@ -104,6 +104,11 @@ try:
|
|
|
104
104
|
)
|
|
105
105
|
except WebPushException as ex:
|
|
106
106
|
print("I'm sorry, Dave, but I can't do that: {}", repr(ex))
|
|
107
|
+
# status_code works with both webpush() and webpush_async().
|
|
108
|
+
if ex.status_code in (429, 503):
|
|
109
|
+
print("Push service requested a retry after:", ex.retry_after)
|
|
110
|
+
# retry_after is either a delay in seconds or an HTTP date:
|
|
111
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
|
|
107
112
|
# Mozilla returns additional information in the body of the response.
|
|
108
113
|
if ex.response is not None and ex.response.json():
|
|
109
114
|
extra = ex.response.json()
|
|
@@ -9,9 +9,10 @@ build-backend = "setuptools.build_meta"
|
|
|
9
9
|
|
|
10
10
|
[project]
|
|
11
11
|
name = "pywebpush"
|
|
12
|
-
version = "2.
|
|
12
|
+
version = "2.5.0"
|
|
13
|
+
# PYTHON_VER
|
|
13
14
|
requires-python = ">= 3.10"
|
|
14
|
-
license =
|
|
15
|
+
license = "MPL-2.0"
|
|
15
16
|
authors = [{ name = "JR Conlin", email = "src+webpusher@jrconlin.com" }]
|
|
16
17
|
description = "WebPush publication library"
|
|
17
18
|
readme = "README.md"
|
|
@@ -22,13 +23,20 @@ classifiers = [
|
|
|
22
23
|
"Programming Language :: Python",
|
|
23
24
|
"Programming Language :: Python :: 3",
|
|
24
25
|
]
|
|
25
|
-
|
|
26
|
+
dependencies = [
|
|
27
|
+
"aiohttp",
|
|
28
|
+
"cryptography>=47.0.0",
|
|
29
|
+
"http-ece>=1.1.0",
|
|
30
|
+
"requests>=2.21.0",
|
|
31
|
+
"py-vapid>=1.7.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
26
34
|
|
|
27
35
|
[project.urls]
|
|
28
36
|
Homepage = "https://github.com/web-push-libs/pywebpush"
|
|
29
37
|
|
|
30
38
|
[project.optional-dependencies]
|
|
31
|
-
dev = ["black", "mock", "pytest"]
|
|
39
|
+
dev = ["isort", "bandit", "black", "mock", "pytest"]
|
|
32
40
|
|
|
33
41
|
# create the `pywebpush` helper using `python -m pip install --editable .`
|
|
34
42
|
[project.scripts]
|
|
@@ -39,3 +47,29 @@ dependencies = { file = "requirements.txt" }
|
|
|
39
47
|
|
|
40
48
|
[tool.setuptools.packages.find]
|
|
41
49
|
include = ["pywebpush*"]
|
|
50
|
+
|
|
51
|
+
[tool.isort]
|
|
52
|
+
profile = "black"
|
|
53
|
+
skip_gitignore = true
|
|
54
|
+
|
|
55
|
+
[tool.bandit]
|
|
56
|
+
# skips asserts
|
|
57
|
+
# B101: https://bandit.readthedocs.io/en/latest/plugins/b101_assert_used.html#
|
|
58
|
+
# skip false detect of hardcoded sql
|
|
59
|
+
# B608:https://bandit.readthedocs.io/en/latest/plugins/B608_hardcoded_sql_expressions.html#
|
|
60
|
+
skips = ["B101", "B608"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
disable_error_code = "attr-defined"
|
|
64
|
+
disallow_untyped_calls = false
|
|
65
|
+
follow_imports = "normal"
|
|
66
|
+
ignore_missing_imports = true
|
|
67
|
+
pretty = true
|
|
68
|
+
show_error_codes = true
|
|
69
|
+
strict_optional = true
|
|
70
|
+
warn_no_return = true
|
|
71
|
+
warn_redundant_casts = true
|
|
72
|
+
warn_return_any = true
|
|
73
|
+
warn_unused_ignores = true
|
|
74
|
+
warn_unreachable = true
|
|
75
|
+
check_untyped_defs = true
|
|
@@ -2,27 +2,22 @@
|
|
|
2
2
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
4
4
|
|
|
5
|
-
import asyncio
|
|
6
5
|
import base64
|
|
7
6
|
import json
|
|
7
|
+
import logging
|
|
8
8
|
import os
|
|
9
9
|
import time
|
|
10
|
-
import logging
|
|
11
10
|
from copy import deepcopy
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from urlparse import urlparse
|
|
16
|
-
except ImportError: # pragma nocover
|
|
17
|
-
from urllib.parse import urlparse
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Mapping, cast
|
|
13
|
+
from urllib.parse import urlparse
|
|
18
14
|
|
|
19
15
|
import aiohttp
|
|
20
16
|
import http_ece
|
|
21
17
|
import requests
|
|
22
18
|
from cryptography.hazmat.backends import default_backend
|
|
23
|
-
from cryptography.hazmat.primitives.asymmetric import ec
|
|
24
19
|
from cryptography.hazmat.primitives import serialization
|
|
25
|
-
from
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
26
21
|
from py_vapid import Vapid, Vapid01
|
|
27
22
|
from requests import Response
|
|
28
23
|
|
|
@@ -30,7 +25,11 @@ from requests import Response
|
|
|
30
25
|
class WebPushException(Exception):
|
|
31
26
|
"""Web Push failure.
|
|
32
27
|
|
|
33
|
-
This may contain
|
|
28
|
+
This may contain a requests.Response or aiohttp.ClientResponse.
|
|
29
|
+
|
|
30
|
+
``status_code`` and ``retry_after`` provide a common interface for
|
|
31
|
+
inspecting either response type without discarding the original
|
|
32
|
+
``response`` object.
|
|
34
33
|
|
|
35
34
|
"""
|
|
36
35
|
|
|
@@ -49,6 +48,26 @@ class WebPushException(Exception):
|
|
|
49
48
|
extra = f", Response {self.response}"
|
|
50
49
|
return f"WebPushException: {self.message}{extra}"
|
|
51
50
|
|
|
51
|
+
@property
|
|
52
|
+
def status_code(self) -> int | None:
|
|
53
|
+
"""Return the HTTP status for synchronous or asynchronous responses."""
|
|
54
|
+
if self.response is None:
|
|
55
|
+
return None
|
|
56
|
+
return getattr(
|
|
57
|
+
self.response,
|
|
58
|
+
"status_code",
|
|
59
|
+
getattr(self.response, "status", None),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def retry_after(self) -> str | None:
|
|
64
|
+
"""Return the provider's Retry-After header, when present.
|
|
65
|
+
|
|
66
|
+
The value can be either a delay in seconds or an HTTP date. See
|
|
67
|
+
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
|
|
68
|
+
"""
|
|
69
|
+
return getattr(self.response, "headers", {}).get("Retry-After", None)
|
|
70
|
+
|
|
52
71
|
|
|
53
72
|
class NoData(Exception):
|
|
54
73
|
"""Message contained No Data, no encoding required."""
|
|
@@ -80,7 +99,9 @@ class CaseInsensitiveDict(dict):
|
|
|
80
99
|
except KeyError:
|
|
81
100
|
return default
|
|
82
101
|
|
|
83
|
-
|
|
102
|
+
# Skip mypy check on the following because the declaration is too
|
|
103
|
+
# abstract
|
|
104
|
+
def update(self, data: dict) -> None: # type: ignore
|
|
84
105
|
for key in data:
|
|
85
106
|
self.__setitem__(key, data[key])
|
|
86
107
|
|
|
@@ -120,22 +141,20 @@ class WebPusher:
|
|
|
120
141
|
|
|
121
142
|
"""
|
|
122
143
|
|
|
123
|
-
subscription_info = {}
|
|
124
|
-
valid_encodings = [
|
|
144
|
+
subscription_info: Mapping = {}
|
|
145
|
+
valid_encodings: list[str] = [
|
|
125
146
|
# "aesgcm128", # this is draft-0, but DO NOT USE.
|
|
126
147
|
"aesgcm", # draft-httpbis-encryption-encoding-01
|
|
127
148
|
"aes128gcm", # RFC8188 Standard encoding
|
|
128
149
|
]
|
|
129
|
-
verbose = False
|
|
150
|
+
verbose: bool = False
|
|
151
|
+
mod_or_session: ModuleType | requests.Session
|
|
130
152
|
|
|
131
|
-
# Note: the type declarations are not valid under python 3.8,
|
|
132
153
|
def __init__(
|
|
133
154
|
self,
|
|
134
|
-
subscription_info:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
requests_session: Union[None, requests.Session] = None,
|
|
138
|
-
aiohttp_session: Union[None, aiohttp.client.ClientSession] = None,
|
|
155
|
+
subscription_info: Mapping,
|
|
156
|
+
requests_session: None | requests.Session = None,
|
|
157
|
+
aiohttp_session: None | aiohttp.client.ClientSession = None,
|
|
139
158
|
verbose: bool = False,
|
|
140
159
|
) -> None:
|
|
141
160
|
"""Initialize using the info provided by the client PushSubscription
|
|
@@ -144,22 +163,16 @@ class WebPusher:
|
|
|
144
163
|
|
|
145
164
|
:param subscription_info: a dict containing the subscription_info from
|
|
146
165
|
the client.
|
|
147
|
-
:type subscription_info: dict
|
|
148
|
-
|
|
149
166
|
:param requests_session: a requests.Session object to optimize requests
|
|
150
167
|
to the same client.
|
|
151
|
-
:type requests_session: requests.Session
|
|
152
|
-
|
|
153
168
|
:param verbose: provide verbose feedback
|
|
154
|
-
:type verbose: bool
|
|
155
|
-
|
|
156
169
|
"""
|
|
157
170
|
|
|
158
171
|
self.verbose = verbose
|
|
159
172
|
if requests_session is None:
|
|
160
|
-
self.
|
|
173
|
+
self.mod_or_session = requests
|
|
161
174
|
else:
|
|
162
|
-
self.
|
|
175
|
+
self.mod_or_session = requests_session
|
|
163
176
|
|
|
164
177
|
self.aiohttp_session = aiohttp_session
|
|
165
178
|
|
|
@@ -168,8 +181,8 @@ class WebPusher:
|
|
|
168
181
|
self.subscription_info = deepcopy(subscription_info)
|
|
169
182
|
self.auth_key = self.receiver_key = None
|
|
170
183
|
if "keys" in subscription_info:
|
|
171
|
-
keys:
|
|
172
|
-
|
|
184
|
+
keys: dict[str, str | bytes] = cast(
|
|
185
|
+
dict[str, str | bytes], self.subscription_info["keys"]
|
|
173
186
|
)
|
|
174
187
|
for k in ["p256dh", "auth"]:
|
|
175
188
|
if keys.get(k) is None:
|
|
@@ -202,7 +215,6 @@ class WebPusher:
|
|
|
202
215
|
:param data: A serialized block of byte data (String, JSON, bit array,
|
|
203
216
|
etc.) Make sure that whatever you send, your client knows how
|
|
204
217
|
to understand it.
|
|
205
|
-
:type data: str
|
|
206
218
|
:param content_encoding: The content_encoding type to use to encrypt
|
|
207
219
|
the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
|
|
208
220
|
"aesgcm", however this format is now deprecated.
|
|
@@ -217,7 +229,7 @@ class WebPusher:
|
|
|
217
229
|
if not self.auth_key or not self.receiver_key:
|
|
218
230
|
raise WebPushException("No keys specified in subscription info")
|
|
219
231
|
self.verb("Encoding data...")
|
|
220
|
-
salt = None
|
|
232
|
+
salt: bytes | None = None
|
|
221
233
|
if content_encoding not in self.valid_encodings:
|
|
222
234
|
raise WebPushException(
|
|
223
235
|
"Invalid content encoding specified. "
|
|
@@ -226,7 +238,7 @@ class WebPusher:
|
|
|
226
238
|
if content_encoding == "aesgcm":
|
|
227
239
|
self.verb("Generating salt for aesgcm...")
|
|
228
240
|
salt = os.urandom(16)
|
|
229
|
-
logging.debug(f"Salt: {salt}")
|
|
241
|
+
logging.debug(f"Salt: {salt!r}")
|
|
230
242
|
# The server key is an ephemeral ECDH key used only for this
|
|
231
243
|
# transaction
|
|
232
244
|
server_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
@@ -235,8 +247,6 @@ class WebPusher:
|
|
|
235
247
|
format=serialization.PublicFormat.UncompressedPoint,
|
|
236
248
|
)
|
|
237
249
|
|
|
238
|
-
if isinstance(data, str):
|
|
239
|
-
data = bytes(data.encode("utf8"))
|
|
240
250
|
if content_encoding == "aes128gcm":
|
|
241
251
|
self.verb("Encrypting to aes128gcm...")
|
|
242
252
|
encrypted = http_ece.encrypt(
|
|
@@ -266,20 +276,17 @@ class WebPusher:
|
|
|
266
276
|
reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=")
|
|
267
277
|
return reply
|
|
268
278
|
|
|
269
|
-
def as_curl(
|
|
279
|
+
def as_curl(
|
|
280
|
+
self, endpoint: str, encoded_data: bytes, headers: dict[str, str]
|
|
281
|
+
) -> str:
|
|
270
282
|
"""Return the send as a curl command.
|
|
271
283
|
|
|
272
284
|
Useful for debugging. This will write out the encoded data to a local
|
|
273
285
|
file named `encrypted.data`
|
|
274
286
|
|
|
275
287
|
:param endpoint: Push service endpoint URL
|
|
276
|
-
:type endpoint: basestring
|
|
277
288
|
:param encoded_data: byte array of encoded data
|
|
278
|
-
:type encoded_data: bytearray
|
|
279
289
|
:param headers: Additional headers for the send
|
|
280
|
-
:type headers: dict
|
|
281
|
-
:returns string
|
|
282
|
-
|
|
283
290
|
"""
|
|
284
291
|
header_list = [
|
|
285
292
|
f'-H "{key.lower()}: {val}" \\ \n' for key, val in headers.items()
|
|
@@ -291,17 +298,15 @@ class WebPusher:
|
|
|
291
298
|
data = "--data-binary @encrypted.data"
|
|
292
299
|
if "content-length" not in headers:
|
|
293
300
|
self.verb("Generating content-length header...")
|
|
294
|
-
header_list.append(
|
|
295
|
-
f'-H "content-length: {len(encoded_data)}" \\ \n'
|
|
296
|
-
)
|
|
301
|
+
header_list.append(f'-H "content-length: {len(encoded_data)}" \\ \n')
|
|
297
302
|
return """curl -vX POST {url} \\\n{headers}{data}""".format(
|
|
298
303
|
url=endpoint, headers="".join(header_list), data=data
|
|
299
304
|
)
|
|
300
305
|
|
|
301
306
|
def _prepare_send_data(
|
|
302
307
|
self,
|
|
303
|
-
data:
|
|
304
|
-
headers:
|
|
308
|
+
data: None | bytes = None,
|
|
309
|
+
headers: None | dict[str, str] = None,
|
|
305
310
|
ttl: int = 0,
|
|
306
311
|
content_encoding: str = "aes128gcm",
|
|
307
312
|
) -> dict:
|
|
@@ -310,19 +315,18 @@ class WebPusher:
|
|
|
310
315
|
:param data: A serialized block of data (see encode() ).
|
|
311
316
|
:type data: str
|
|
312
317
|
:param headers: A dictionary containing any additional HTTP headers.
|
|
313
|
-
:type headers: dict
|
|
314
318
|
:param ttl: The Time To Live in seconds for this message if the
|
|
315
319
|
recipient is not online. (Defaults to "0", which discards the
|
|
316
320
|
message immediately if the recipient is unavailable.)
|
|
317
|
-
:type ttl: int
|
|
318
321
|
:param content_encoding: ECE content encoding (defaults to "aes128gcm")
|
|
319
|
-
:type content_encoding: str
|
|
320
322
|
"""
|
|
321
323
|
# Encode the data.
|
|
322
324
|
if headers is None:
|
|
323
325
|
headers = dict()
|
|
324
326
|
encoded = CaseInsensitiveDict()
|
|
325
327
|
headers = CaseInsensitiveDict(headers)
|
|
328
|
+
if isinstance(data, str):
|
|
329
|
+
data = data.encode()
|
|
326
330
|
if data:
|
|
327
331
|
encoded = self.encode(data, content_encoding)
|
|
328
332
|
if "crypto_key" in encoded:
|
|
@@ -361,7 +365,7 @@ class WebPusher:
|
|
|
361
365
|
|
|
362
366
|
return {"endpoint": endpoint, "data": encoded_data, "headers": headers}
|
|
363
367
|
|
|
364
|
-
def send(self, *args, **kwargs) ->
|
|
368
|
+
def send(self, *args, **kwargs) -> Response | str:
|
|
365
369
|
"""Encode and send the data to the Push Service"""
|
|
366
370
|
timeout = kwargs.pop("timeout", 10000)
|
|
367
371
|
curl = kwargs.pop("curl", False)
|
|
@@ -374,7 +378,7 @@ class WebPusher:
|
|
|
374
378
|
headers = params["headers"]
|
|
375
379
|
return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
|
|
376
380
|
|
|
377
|
-
resp = self.
|
|
381
|
+
resp = self.mod_or_session.post(
|
|
378
382
|
endpoint,
|
|
379
383
|
timeout=timeout,
|
|
380
384
|
**params,
|
|
@@ -383,11 +387,11 @@ class WebPusher:
|
|
|
383
387
|
"\nResponse:\n\tcode: {}\n\tbody: {}\n\theaders: {}",
|
|
384
388
|
resp.status_code,
|
|
385
389
|
resp.text or "Empty",
|
|
386
|
-
resp.headers or "None"
|
|
390
|
+
resp.headers or "None",
|
|
387
391
|
)
|
|
388
392
|
return resp
|
|
389
393
|
|
|
390
|
-
async def send_async(self, *args, **kwargs) ->
|
|
394
|
+
async def send_async(self, *args, **kwargs) -> aiohttp.ClientResponse | str:
|
|
391
395
|
timeout = kwargs.pop("timeout", 10000)
|
|
392
396
|
curl = kwargs.pop("curl", False)
|
|
393
397
|
|
|
@@ -414,22 +418,20 @@ class WebPusher:
|
|
|
414
418
|
|
|
415
419
|
|
|
416
420
|
def webpush(
|
|
417
|
-
subscription_info:
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
vapid_private_key: Union[None, Vapid, str] = None,
|
|
422
|
-
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
|
421
|
+
subscription_info: Mapping,
|
|
422
|
+
data: None | str = None,
|
|
423
|
+
vapid_private_key: None | Vapid | str = None,
|
|
424
|
+
vapid_claims: None | dict[str, str | int] = None,
|
|
423
425
|
content_encoding: str = "aes128gcm",
|
|
424
426
|
curl: bool = False,
|
|
425
|
-
timeout:
|
|
427
|
+
timeout: None | float = None,
|
|
426
428
|
ttl: int = 0,
|
|
427
429
|
verbose: bool = False,
|
|
428
|
-
headers:
|
|
429
|
-
requests_session:
|
|
430
|
-
) ->
|
|
430
|
+
headers: None | dict[str, str | int | float] = None,
|
|
431
|
+
requests_session: None | requests.Session = None,
|
|
432
|
+
) -> str | requests.Response:
|
|
431
433
|
"""
|
|
432
|
-
One call solution to
|
|
434
|
+
One call solution to encode and send `data` to the endpoint
|
|
433
435
|
contained in `subscription_info` using optional VAPID auth headers.
|
|
434
436
|
|
|
435
437
|
in example:
|
|
@@ -453,28 +455,17 @@ def webpush(
|
|
|
453
455
|
`WebPushException`.
|
|
454
456
|
|
|
455
457
|
:param subscription_info: Provided by the client call
|
|
456
|
-
:type subscription_info: dict
|
|
457
458
|
:param data: Serialized data to send
|
|
458
|
-
:type data: str
|
|
459
459
|
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
|
460
460
|
or encoded str
|
|
461
461
|
:type vapid_private_key: Union[Vapid, str]
|
|
462
462
|
:param vapid_claims: Dictionary of claims ('sub' required)
|
|
463
|
-
:type vapid_claims: dict
|
|
464
463
|
:param content_encoding: Optional content type string
|
|
465
|
-
:type content_encoding: str
|
|
466
464
|
:param curl: Return as "curl" string instead of sending
|
|
467
|
-
:type curl: bool
|
|
468
465
|
:param timeout: POST requests timeout
|
|
469
|
-
:type timeout: float
|
|
470
466
|
:param ttl: Time To Live
|
|
471
|
-
:type ttl: int
|
|
472
467
|
:param verbose: Provide verbose feedback
|
|
473
|
-
:type verbose: bool
|
|
474
|
-
:return requests.Response or string
|
|
475
468
|
:param headers: Dictionary of extra HTTP headers to include
|
|
476
|
-
:type headers: dict
|
|
477
|
-
|
|
478
469
|
"""
|
|
479
470
|
if headers is None:
|
|
480
471
|
headers = dict()
|
|
@@ -498,7 +489,9 @@ def webpush(
|
|
|
498
489
|
# encryption lives for 12 hours
|
|
499
490
|
vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
|
|
500
491
|
if verbose:
|
|
501
|
-
logging.info(
|
|
492
|
+
logging.info(
|
|
493
|
+
"Setting VAPID expiry to {}...".format(vapid_claims["exp"])
|
|
494
|
+
)
|
|
502
495
|
if not vapid_private_key:
|
|
503
496
|
raise WebPushException("VAPID dict missing 'private_key'")
|
|
504
497
|
if isinstance(vapid_private_key, Vapid01):
|
|
@@ -544,23 +537,21 @@ def webpush(
|
|
|
544
537
|
|
|
545
538
|
|
|
546
539
|
async def webpush_async(
|
|
547
|
-
subscription_info:
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
vapid_private_key: Union[None, Vapid, str] = None,
|
|
552
|
-
vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
|
|
540
|
+
subscription_info: dict[str, str | bytes | dict[str, str | bytes]],
|
|
541
|
+
data: None | str = None,
|
|
542
|
+
vapid_private_key: None | Vapid | str = None,
|
|
543
|
+
vapid_claims: None | dict[str, str | int] = None,
|
|
553
544
|
content_encoding: str = "aes128gcm",
|
|
554
545
|
curl: bool = False,
|
|
555
|
-
timeout:
|
|
546
|
+
timeout: None | float = None,
|
|
556
547
|
ttl: int = 0,
|
|
557
548
|
verbose: bool = False,
|
|
558
|
-
headers:
|
|
559
|
-
aiohttp_session:
|
|
560
|
-
) ->
|
|
549
|
+
headers: None | dict[str, str | int | float] = None,
|
|
550
|
+
aiohttp_session: None | aiohttp.ClientSession = None,
|
|
551
|
+
) -> str | aiohttp.ClientResponse:
|
|
561
552
|
"""
|
|
562
|
-
Async version of webpush function. One call solution to encode and send
|
|
563
|
-
`data` to the endpoint contained in `subscription_info` using optional
|
|
553
|
+
Async version of webpush function. One call solution to encode and send
|
|
554
|
+
`data` to the endpoint contained in `subscription_info` using optional
|
|
564
555
|
VAPID auth headers.
|
|
565
556
|
|
|
566
557
|
Example:
|
|
@@ -588,30 +579,18 @@ async def webpush_async(
|
|
|
588
579
|
`WebPushException`.
|
|
589
580
|
|
|
590
581
|
:param subscription_info: Provided by the client call
|
|
591
|
-
:type subscription_info: dict
|
|
592
582
|
:param data: Serialized data to send
|
|
593
|
-
:type data: str
|
|
594
583
|
:param vapid_private_key: Vapid instance or path to vapid private key PEM \
|
|
595
584
|
or encoded str
|
|
596
585
|
:type vapid_private_key: Union[Vapid, str]
|
|
597
586
|
:param vapid_claims: Dictionary of claims ('sub' required)
|
|
598
|
-
:type vapid_claims: dict
|
|
599
587
|
:param content_encoding: Optional content type string
|
|
600
|
-
:type content_encoding: str
|
|
601
588
|
:param curl: Return as "curl" string instead of sending
|
|
602
|
-
:type curl: bool
|
|
603
589
|
:param timeout: POST requests timeout
|
|
604
|
-
:type timeout: float
|
|
605
590
|
:param ttl: Time To Live
|
|
606
|
-
:type ttl: int
|
|
607
591
|
:param verbose: Provide verbose feedback
|
|
608
|
-
:type verbose: bool
|
|
609
592
|
:param headers: Dictionary of extra HTTP headers to include
|
|
610
|
-
:type headers: dict
|
|
611
593
|
:param aiohttp_session: Optional aiohttp ClientSession for connection reuse
|
|
612
|
-
:type aiohttp_session: aiohttp.ClientSession
|
|
613
|
-
:return aiohttp.ClientResponse or string
|
|
614
|
-
|
|
615
594
|
"""
|
|
616
595
|
if headers is None:
|
|
617
596
|
headers = dict()
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import argparse
|
|
2
|
-
import os
|
|
3
2
|
import json
|
|
4
3
|
import logging
|
|
5
|
-
import
|
|
4
|
+
import os
|
|
6
5
|
|
|
7
6
|
from requests import JSONDecodeError
|
|
8
7
|
|
|
9
|
-
from pywebpush import
|
|
8
|
+
from pywebpush import WebPushException, webpush
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
def get_config():
|
|
@@ -20,7 +19,8 @@ def get_config():
|
|
|
20
19
|
"--wns",
|
|
21
20
|
help="Include WNS cache header based on TTL",
|
|
22
21
|
default=False,
|
|
23
|
-
action="store_true"
|
|
22
|
+
action="store_true",
|
|
23
|
+
)
|
|
24
24
|
parser.add_argument(
|
|
25
25
|
"--curl",
|
|
26
26
|
help="Don't send, display as curl command",
|
|
@@ -75,9 +75,7 @@ def get_config():
|
|
|
75
75
|
try:
|
|
76
76
|
args.claims = json.loads(r.read())
|
|
77
77
|
except JSONDecodeError as e:
|
|
78
|
-
raise WebPushException(
|
|
79
|
-
f"Could not read the VAPID claims file {e}"
|
|
80
|
-
)
|
|
78
|
+
raise WebPushException(f"Could not read the VAPID claims file {e}")
|
|
81
79
|
except Exception as ex:
|
|
82
80
|
logging.error(f"Couldn't read input {ex}.")
|
|
83
81
|
raise ex
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import base64
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
-
import unittest
|
|
5
4
|
import time
|
|
6
|
-
|
|
7
|
-
from
|
|
5
|
+
import unittest
|
|
6
|
+
from typing import cast
|
|
7
|
+
from unittest.mock import AsyncMock, Mock, patch
|
|
8
8
|
|
|
9
9
|
import http_ece
|
|
10
10
|
import py_vapid
|
|
11
11
|
import requests
|
|
12
|
-
from cryptography.hazmat.primitives.asymmetric import ec
|
|
13
|
-
from cryptography.hazmat.primitives import serialization
|
|
14
12
|
from cryptography.hazmat.backends import default_backend
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
15
15
|
|
|
16
16
|
from pywebpush import (
|
|
17
|
-
|
|
17
|
+
CaseInsensitiveDict,
|
|
18
18
|
NoData,
|
|
19
|
+
WebPusher,
|
|
19
20
|
WebPushException,
|
|
20
|
-
CaseInsensitiveDict,
|
|
21
21
|
webpush,
|
|
22
22
|
webpush_async,
|
|
23
23
|
)
|
|
@@ -53,16 +53,16 @@ class WebpushTestUtils(unittest.TestCase):
|
|
|
53
53
|
|
|
54
54
|
def test_init(self):
|
|
55
55
|
# use static values so we know what to look for in the reply
|
|
56
|
-
subscription_info =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
subscription_info = dict(
|
|
57
|
+
endpoint="https://example.com/",
|
|
58
|
+
keys=dict(
|
|
59
|
+
p256dh=(
|
|
60
60
|
"BOrnIslXrUow2VAzKCUAE4sIbK00daEZCswOcf8m3T"
|
|
61
61
|
"F8V82B-OpOg5JbmYLg44kRcvQC1E2gMJshsUYA-_zMPR8"
|
|
62
62
|
),
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
auth="k8JV6sjdbhAi1n3_LDBLvA",
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
66
|
rk_decode = (
|
|
67
67
|
b'\x04\xea\xe7"\xc9W\xadJ0\xd9P3(%\x00\x13\x8b'
|
|
68
68
|
b"\x08l\xad4u\xa1\x19\n\xcc\x0eq\xff&\xdd1"
|
|
@@ -192,7 +192,7 @@ class WebpushTestUtils(unittest.TestCase):
|
|
|
192
192
|
subscription_info = self._gen_subscription_info()
|
|
193
193
|
data = "Mary had a little lamb"
|
|
194
194
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
|
195
|
-
claims:
|
|
195
|
+
claims: dict[str, str | int] = dict(
|
|
196
196
|
sub="mailto:ops@example.com", aud="https://example.com"
|
|
197
197
|
)
|
|
198
198
|
webpush(
|
|
@@ -211,7 +211,7 @@ class WebpushTestUtils(unittest.TestCase):
|
|
|
211
211
|
subscription_info = self._gen_subscription_info()
|
|
212
212
|
data = "Mary had a little lamb"
|
|
213
213
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
|
214
|
-
claims = dict(
|
|
214
|
+
claims: dict[str, str | int] = dict(
|
|
215
215
|
sub="mailto:ops@example.com",
|
|
216
216
|
aud="https://example.com",
|
|
217
217
|
exp=int(time.time() - 48600),
|
|
@@ -456,7 +456,7 @@ class WebPusherAsyncTestCase(WebpushTestUtils, unittest.IsolatedAsyncioTestCase)
|
|
|
456
456
|
subscription_info = self._gen_subscription_info()
|
|
457
457
|
data = "Mary had a little lamb"
|
|
458
458
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
|
459
|
-
claims:
|
|
459
|
+
claims: dict[str, str | int] = dict(
|
|
460
460
|
sub="mailto:ops@example.com", aud="https://example.com"
|
|
461
461
|
)
|
|
462
462
|
await webpush_async(
|
|
@@ -477,7 +477,7 @@ class WebPusherAsyncTestCase(WebpushTestUtils, unittest.IsolatedAsyncioTestCase)
|
|
|
477
477
|
subscription_info = self._gen_subscription_info()
|
|
478
478
|
data = "Mary had a little lamb"
|
|
479
479
|
vapid_key = py_vapid.Vapid.from_string(self.vapid_key)
|
|
480
|
-
claims = dict(
|
|
480
|
+
claims: dict[str, str | int] = dict(
|
|
481
481
|
sub="mailto:ops@example.com",
|
|
482
482
|
aud="https://example.com",
|
|
483
483
|
exp=int(time.time() - 48600),
|
|
@@ -563,6 +563,8 @@ class WebpushExceptionTestCase(unittest.TestCase):
|
|
|
563
563
|
|
|
564
564
|
exp = WebPushException("foo")
|
|
565
565
|
assert f"{exp}" == "WebPushException: foo"
|
|
566
|
+
assert exp.status_code is None
|
|
567
|
+
assert exp.retry_after is None
|
|
566
568
|
# Really should try to load the response to verify, but this mock
|
|
567
569
|
# covers what we need.
|
|
568
570
|
response = Mock(spec=Response)
|
|
@@ -577,11 +579,20 @@ class WebpushExceptionTestCase(unittest.TestCase):
|
|
|
577
579
|
response.json.return_value = json.loads(response.text)
|
|
578
580
|
response.status_code = 401
|
|
579
581
|
response.reason = "Unauthorized"
|
|
582
|
+
response.headers = {"Retry-After": "120"}
|
|
580
583
|
exp = WebPushException("foo", response)
|
|
581
|
-
assert f"{exp}" == "WebPushException: foo, Response {}".format(
|
|
582
|
-
response.text
|
|
583
|
-
)
|
|
584
|
+
assert f"{exp}" == "WebPushException: foo, Response {}".format(response.text)
|
|
584
585
|
assert f"{exp.response}", "<Response [401]>"
|
|
585
586
|
assert cast(requests.Response, exp.response).json().get("errno") == 109
|
|
587
|
+
assert exp.status_code == 401
|
|
588
|
+
assert exp.retry_after == "120"
|
|
589
|
+
|
|
590
|
+
async_response = Mock(spec=["status", "headers", "text"])
|
|
591
|
+
async_response.status = 503
|
|
592
|
+
async_response.headers = {"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"}
|
|
593
|
+
exp = WebPushException("async failure", async_response)
|
|
594
|
+
assert exp.status_code == 503
|
|
595
|
+
assert exp.retry_after == "Wed, 21 Oct 2015 07:28:00 GMT"
|
|
596
|
+
|
|
586
597
|
exp = WebPushException("foo", [1, 2, 3])
|
|
587
598
|
assert f"{exp}" == "WebPushException: foo, Response [1, 2, 3]"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pywebpush
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: WebPush publication library
|
|
5
5
|
Author-email: JR Conlin <src+webpusher@jrconlin.com>
|
|
6
|
-
License: MPL-2.0
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/web-push-libs/pywebpush
|
|
8
8
|
Keywords: webpush,vapid,notification
|
|
9
9
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
@@ -14,11 +14,13 @@ Requires-Python: >=3.10
|
|
|
14
14
|
Description-Content-Type: text/markdown
|
|
15
15
|
License-File: LICENSE
|
|
16
16
|
Requires-Dist: aiohttp
|
|
17
|
-
Requires-Dist: cryptography>=
|
|
17
|
+
Requires-Dist: cryptography>=47.0.0
|
|
18
18
|
Requires-Dist: http-ece>=1.1.0
|
|
19
19
|
Requires-Dist: requests>=2.21.0
|
|
20
20
|
Requires-Dist: py-vapid>=1.7.0
|
|
21
21
|
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: isort; extra == "dev"
|
|
23
|
+
Requires-Dist: bandit; extra == "dev"
|
|
22
24
|
Requires-Dist: black; extra == "dev"
|
|
23
25
|
Requires-Dist: mock; extra == "dev"
|
|
24
26
|
Requires-Dist: pytest; extra == "dev"
|
|
@@ -130,6 +132,11 @@ try:
|
|
|
130
132
|
)
|
|
131
133
|
except WebPushException as ex:
|
|
132
134
|
print("I'm sorry, Dave, but I can't do that: {}", repr(ex))
|
|
135
|
+
# status_code works with both webpush() and webpush_async().
|
|
136
|
+
if ex.status_code in (429, 503):
|
|
137
|
+
print("Push service requested a retry after:", ex.retry_after)
|
|
138
|
+
# retry_after is either a delay in seconds or an HTTP date:
|
|
139
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Retry-After
|
|
133
140
|
# Mozilla returns additional information in the body of the response.
|
|
134
141
|
if ex.response is not None and ex.response.json():
|
|
135
142
|
extra = ex.response.json()
|
|
@@ -5,14 +5,11 @@ MANIFEST.in
|
|
|
5
5
|
README.md
|
|
6
6
|
README.rst
|
|
7
7
|
entry_points.txt
|
|
8
|
-
local_test.txt
|
|
9
8
|
pyproject.toml
|
|
10
9
|
requirements.txt
|
|
11
10
|
setup.cfg
|
|
12
|
-
test-requirements.txt
|
|
13
11
|
pywebpush/__init__.py
|
|
14
12
|
pywebpush/__main__.py
|
|
15
|
-
pywebpush/foo.py
|
|
16
13
|
pywebpush.egg-info/PKG-INFO
|
|
17
14
|
pywebpush.egg-info/SOURCES.txt
|
|
18
15
|
pywebpush.egg-info/dependency_links.txt
|
pywebpush-2.3.0/local_test.txt
DELETED
pywebpush-2.3.0/pywebpush/foo.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
from pywebpush import webpush
|
|
2
|
-
import json
|
|
3
|
-
import logging
|
|
4
|
-
import datetime
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def send_push_notification(subscription, payload):
|
|
8
|
-
|
|
9
|
-
try:
|
|
10
|
-
|
|
11
|
-
# subscriptionData = json.loads(subscription)
|
|
12
|
-
|
|
13
|
-
# logger.error(subscriptionData)
|
|
14
|
-
|
|
15
|
-
webpush(
|
|
16
|
-
subscription_info={
|
|
17
|
-
"endpoint": subscription["endpoint"],
|
|
18
|
-
"keys": subscription["keys"],
|
|
19
|
-
},
|
|
20
|
-
data=json.dumps(payload),
|
|
21
|
-
vapid_claims={
|
|
22
|
-
"aud": "https://eshopper.africa",
|
|
23
|
-
"exp": int((datetime.datetime.now().timestamp())) + 86400,
|
|
24
|
-
"sub": "mailto:events@eshopper.africa",
|
|
25
|
-
},
|
|
26
|
-
vapid_private_key="UCUKEHn7Jd33QZx5lJFKBY4plOxGsJ6xJSOzE14jQlo",
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
# subscription_info = { 'endpoint': subscription['endpoint'], 'keys': subscription['keys'] },
|
|
30
|
-
|
|
31
|
-
# data = json.loads(payload),
|
|
32
|
-
|
|
33
|
-
# headers = {}
|
|
34
|
-
|
|
35
|
-
# ttl = 0
|
|
36
|
-
|
|
37
|
-
# gcm_key = ''
|
|
38
|
-
|
|
39
|
-
# content_encoding="aes128gcm"
|
|
40
|
-
|
|
41
|
-
# reg_id=""
|
|
42
|
-
|
|
43
|
-
# WebPusher = webpush(subscription_info)
|
|
44
|
-
|
|
45
|
-
# WebPusher(subscription_info).send(data, headers, ttl, gcm_key, reg_id, content_encoding, timeout=None)
|
|
46
|
-
|
|
47
|
-
except Exception as inst:
|
|
48
|
-
print(f" webpush Notification Error : {inst}")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
send_push_notification({"endpoint": "https://example.com", "keys": {}}, "laaaa")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|