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.
Files changed (24) hide show
  1. {pywebpush-2.3.0 → pywebpush-2.5.0}/CHANGELOG.md +5 -0
  2. {pywebpush-2.3.0 → pywebpush-2.5.0}/MANIFEST.in +2 -0
  3. {pywebpush-2.3.0 → pywebpush-2.5.0}/PKG-INFO +10 -3
  4. {pywebpush-2.3.0 → pywebpush-2.5.0}/README.md +5 -0
  5. {pywebpush-2.3.0 → pywebpush-2.5.0}/pyproject.toml +38 -4
  6. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/__init__.py +80 -101
  7. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/__main__.py +5 -7
  8. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/tests/test_webpush.py +32 -21
  9. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/PKG-INFO +10 -3
  10. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/SOURCES.txt +0 -3
  11. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/requires.txt +3 -1
  12. {pywebpush-2.3.0 → pywebpush-2.5.0}/requirements.txt +1 -0
  13. pywebpush-2.3.0/local_test.txt +0 -3
  14. pywebpush-2.3.0/pywebpush/foo.py +0 -51
  15. pywebpush-2.3.0/test-requirements.txt +0 -4
  16. {pywebpush-2.3.0 → pywebpush-2.5.0}/CODE_OF_CONDUCT.md +0 -0
  17. {pywebpush-2.3.0 → pywebpush-2.5.0}/LICENSE +0 -0
  18. {pywebpush-2.3.0 → pywebpush-2.5.0}/README.rst +0 -0
  19. {pywebpush-2.3.0 → pywebpush-2.5.0}/entry_points.txt +0 -0
  20. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush/tests/__init__.py +0 -0
  21. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/dependency_links.txt +0 -0
  22. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/entry_points.txt +0 -0
  23. {pywebpush-2.3.0 → pywebpush-2.5.0}/pywebpush.egg-info/top_level.txt +0 -0
  24. {pywebpush-2.3.0 → pywebpush-2.5.0}/setup.cfg +0 -0
@@ -1,5 +1,10 @@
1
1
  # I am terrible at keeping this up-to-date.
2
2
 
3
+ ## 2.5.0
4
+
5
+ - Add common `status_code` and `retry_after` accessors to `WebPushException`
6
+ for synchronous and asynchronous responses.
7
+
3
8
  ## 2.3.0 (2026-02-09)
4
9
 
5
10
  - Cleanup from @Rotzbua
@@ -3,3 +3,5 @@ include *.txt
3
3
  include setup.*
4
4
  include LICENSE
5
5
  recursive-include pywebpush *.py
6
+ global-exclude */__pycache__/*
7
+ global-exclude *.pyc
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pywebpush
3
- Version: 2.3.0
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>=2.6.1
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.3.0"
12
+ version = "2.5.0"
13
+ # PYTHON_VER
13
14
  requires-python = ">= 3.10"
14
- license = { text = "MPL-2.0" }
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
- dynamic = ["dependencies"]
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 typing import cast, Union, Dict
13
-
14
- try:
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 functools import partial
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 the requests.Response
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
- def update(self, data) -> None:
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: Dict[
135
- str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
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.requests_method = requests
173
+ self.mod_or_session = requests
161
174
  else:
162
- self.requests_method = requests_session
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: Dict[str, Union[str, bytes]] = cast(
172
- Dict[str, Union[str, bytes]], self.subscription_info["keys"]
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(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]) -> str:
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: Union[None, bytes] = None,
304
- headers: Union[None, Dict[str, str]] = None,
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) -> Union[Response, str]:
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.requests_method.post(
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) -> Union[aiohttp.ClientResponse, str]:
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: Dict[
418
- str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
419
- ],
420
- data: Union[None, str] = None,
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: Union[None, float] = None,
427
+ timeout: None | float = None,
426
428
  ttl: int = 0,
427
429
  verbose: bool = False,
428
- headers: Union[None, Dict[str, Union[str, int, float]]] = None,
429
- requests_session: Union[None, requests.Session] = None,
430
- ) -> Union[str, requests.Response]:
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 endcode and send `data` to the endpoint
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("Setting VAPID expry to {}...".format(vapid_claims["exp"]))
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: Dict[
548
- str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
549
- ],
550
- data: Union[None, str] = None,
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: Union[None, float] = None,
546
+ timeout: None | float = None,
556
547
  ttl: int = 0,
557
548
  verbose: bool = False,
558
- headers: Union[None, Dict[str, Union[str, int, float]]] = None,
559
- aiohttp_session: Union[None, aiohttp.ClientSession] = None,
560
- ) -> Union[str, aiohttp.ClientResponse]:
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 math
4
+ import os
6
5
 
7
6
  from requests import JSONDecodeError
8
7
 
9
- from pywebpush import webpush, WebPushException
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
- from typing import cast, Union, Dict
7
- from unittest.mock import patch, Mock, AsyncMock
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
- WebPusher,
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
- "endpoint": "https://example.com/",
58
- "keys": {
59
- "p256dh": (
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
- "auth": "k8JV6sjdbhAi1n3_LDBLvA",
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: Dict[str, Union[str, int]] = dict(
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: Dict[str, Union[str, int]] = dict(
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.0
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>=2.6.1
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
@@ -1,10 +1,12 @@
1
1
  aiohttp
2
- cryptography>=2.6.1
2
+ cryptography>=47.0.0
3
3
  http-ece>=1.1.0
4
4
  requests>=2.21.0
5
5
  py-vapid>=1.7.0
6
6
 
7
7
  [dev]
8
+ isort
9
+ bandit
8
10
  black
9
11
  mock
10
12
  pytest
@@ -1,3 +1,4 @@
1
+ # NOTE: Requirements are now in pyproject.toml
1
2
  aiohttp
2
3
  cryptography>=2.6.1
3
4
  http-ece>=1.1.0
@@ -1,3 +0,0 @@
1
- Amidst the mists and coldest frosts I thrust my fists against the
2
- posts and still demand to see the ghosts.
3
-
@@ -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")
@@ -1,4 +0,0 @@
1
- -r requirements.txt
2
- black
3
- mock
4
- pytest
File without changes
File without changes
File without changes
File without changes
File without changes