pywebpush 2.1.0__1-py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,726 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import os
9
+ import time
10
+ import logging
11
+ 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
18
+
19
+ import aiohttp
20
+ import http_ece
21
+ import requests
22
+ import six
23
+ from cryptography.hazmat.backends import default_backend
24
+ from cryptography.hazmat.primitives.asymmetric import ec
25
+ from cryptography.hazmat.primitives import serialization
26
+ from functools import partial
27
+ from py_vapid import Vapid, Vapid01
28
+ from requests import Response
29
+
30
+
31
+ class WebPushException(Exception):
32
+ """Web Push failure.
33
+
34
+ This may contain the requests.Response
35
+
36
+ """
37
+
38
+ def __init__(self, message, response=None):
39
+ self.message = message
40
+ self.response = response
41
+
42
+ def __str__(self):
43
+ extra = ""
44
+ if self.response is not None:
45
+ try:
46
+ extra = ", Response {}".format(
47
+ self.response.text,
48
+ )
49
+ except AttributeError:
50
+ extra = ", Response {}".format(self.response)
51
+ return "WebPushException: {}{}".format(self.message, extra)
52
+
53
+
54
+ class NoData(Exception):
55
+ """Message contained No Data, no encoding required."""
56
+
57
+
58
+ class CaseInsensitiveDict(dict):
59
+ """A dictionary that has case-insensitive keys"""
60
+
61
+ def __init__(self, data={}, **kwargs):
62
+ for key in data:
63
+ dict.__setitem__(self, key.lower(), data[key])
64
+ self.update(kwargs)
65
+
66
+ def __contains__(self, key):
67
+ return dict.__contains__(self, key.lower())
68
+
69
+ def __setitem__(self, key, value):
70
+ dict.__setitem__(self, key.lower(), value)
71
+
72
+ def __getitem__(self, key):
73
+ return dict.__getitem__(self, key.lower())
74
+
75
+ def __delitem__(self, key):
76
+ dict.__delitem__(self, key.lower())
77
+
78
+ def get(self, key, default=None):
79
+ try:
80
+ return self.__getitem__(key)
81
+ except KeyError:
82
+ return default
83
+
84
+ def update(self, data):
85
+ for key in data:
86
+ self.__setitem__(key, data[key])
87
+
88
+
89
+ class WebPusher:
90
+ """WebPusher encrypts a data block using HTTP Encrypted Content Encoding
91
+ for WebPush.
92
+
93
+ See https://tools.ietf.org/html/draft-ietf-webpush-protocol-04
94
+ for the current specification, and
95
+ https://developer.mozilla.org/en-US/docs/Web/API/Push_API for an
96
+ overview of Web Push.
97
+
98
+ Example of use:
99
+
100
+ The javascript promise handler for PushManager.subscribe()
101
+ receives a subscription_info object. subscription_info.getJSON()
102
+ will return a JSON representation.
103
+ (e.g.
104
+ .. code-block:: javascript
105
+ subscription_info.getJSON() ==
106
+ {"endpoint": "https://push.server.com/...",
107
+ "keys":{"auth": "...", "p256dh": "..."}
108
+ }
109
+ )
110
+
111
+ This subscription_info block can be stored.
112
+
113
+ To send a subscription update:
114
+
115
+ .. code-block:: python
116
+ # Optional
117
+ # headers = py_vapid.sign({"aud": "https://push.server.com/",
118
+ "sub": "mailto:your_admin@your.site.com"})
119
+ data = "Mary had a little lamb, with a nice mint jelly"
120
+ WebPusher(subscription_info).send(data, headers)
121
+
122
+ """
123
+
124
+ subscription_info = {}
125
+ valid_encodings = [
126
+ # "aesgcm128", # this is draft-0, but DO NOT USE.
127
+ "aesgcm", # draft-httpbis-encryption-encoding-01
128
+ "aes128gcm", # RFC8188 Standard encoding
129
+ ]
130
+ verbose = False
131
+
132
+ # Note: the type declarations are not valid under python 3.8,
133
+ def __init__(
134
+ self,
135
+ subscription_info: Dict[
136
+ str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
137
+ ],
138
+ requests_session: Union[None, requests.Session] = None,
139
+ aiohttp_session: Union[None, aiohttp.client.ClientSession] = None,
140
+ verbose: bool = False,
141
+ ):
142
+ """Initialize using the info provided by the client PushSubscription
143
+ object (See
144
+ https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
145
+
146
+ :param subscription_info: a dict containing the subscription_info from
147
+ the client.
148
+ :type subscription_info: dict
149
+
150
+ :param requests_session: a requests.Session object to optimize requests
151
+ to the same client.
152
+ :type requests_session: requests.Session
153
+
154
+ :param verbose: provide verbose feedback
155
+ :type verbose: bool
156
+
157
+ """
158
+
159
+ self.verbose = verbose
160
+ if requests_session is None:
161
+ self.requests_method = requests
162
+ else:
163
+ self.requests_method = requests_session
164
+
165
+ self.aiohttp_session = aiohttp_session
166
+
167
+ if "endpoint" not in subscription_info:
168
+ raise WebPushException("subscription_info missing endpoint URL")
169
+ self.subscription_info = deepcopy(subscription_info)
170
+ self.auth_key = self.receiver_key = None
171
+ if "keys" in subscription_info:
172
+ keys: Dict[str, Union[str, bytes]] = cast(
173
+ Dict[str, Union[str, bytes]], self.subscription_info["keys"]
174
+ )
175
+ for k in ["p256dh", "auth"]:
176
+ if keys.get(k) is None:
177
+ raise WebPushException("Missing keys value: {}".format(k))
178
+ if isinstance(keys[k], six.text_type):
179
+ keys[k] = bytes(cast(str, keys[k]).encode("utf8"))
180
+ receiver_raw = base64.urlsafe_b64decode(
181
+ self._repad(cast(bytes, keys["p256dh"]))
182
+ )
183
+ if len(receiver_raw) != 65 and receiver_raw[0] != "\x04":
184
+ raise WebPushException("Invalid p256dh key specified")
185
+ self.receiver_key = receiver_raw
186
+ self.auth_key = base64.urlsafe_b64decode(
187
+ self._repad(cast(bytes, keys["auth"]))
188
+ )
189
+
190
+ def verb(self, msg: str, *args, **kwargs):
191
+ if self.verbose:
192
+ logging.info(msg.format(*args, **kwargs))
193
+
194
+ def _repad(self, data: bytes):
195
+ """Add base64 padding to the end of a string, if required"""
196
+ return data + b"===="[: len(data) % 4]
197
+
198
+ def encode(
199
+ self, data: bytes, content_encoding: str = "aes128gcm"
200
+ ) -> CaseInsensitiveDict:
201
+ """Encrypt the data.
202
+
203
+ :param data: A serialized block of byte data (String, JSON, bit array,
204
+ etc.) Make sure that whatever you send, your client knows how
205
+ to understand it.
206
+ :type data: str
207
+ :param content_encoding: The content_encoding type to use to encrypt
208
+ the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is
209
+ "aesgcm", however this format is now deprecated.
210
+ :type content_encoding: enum("aesgcm", "aes128gcm")
211
+
212
+ """
213
+ reply = CaseInsensitiveDict()
214
+ # Salt is a random 16 byte array.
215
+ if not data:
216
+ self.verb("No data found...")
217
+ raise NoData()
218
+ if not self.auth_key or not self.receiver_key:
219
+ raise WebPushException("No keys specified in subscription info")
220
+ self.verb("Encoding data...")
221
+ salt = None
222
+ if content_encoding not in self.valid_encodings:
223
+ raise WebPushException(
224
+ "Invalid content encoding specified. "
225
+ "Select from " + json.dumps(self.valid_encodings)
226
+ )
227
+ if content_encoding == "aesgcm":
228
+ self.verb("Generating salt for aesgcm...")
229
+ salt = os.urandom(16)
230
+ logging.debug("Salt: {}".format(salt))
231
+ # The server key is an ephemeral ECDH key used only for this
232
+ # transaction
233
+ server_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
234
+ crypto_key = server_key.public_key().public_bytes(
235
+ encoding=serialization.Encoding.X962,
236
+ format=serialization.PublicFormat.UncompressedPoint,
237
+ )
238
+
239
+ if isinstance(data, six.text_type):
240
+ data = bytes(data.encode("utf8"))
241
+ if content_encoding == "aes128gcm":
242
+ self.verb("Encrypting to aes128gcm...")
243
+ encrypted = http_ece.encrypt(
244
+ data,
245
+ salt=salt,
246
+ private_key=server_key,
247
+ dh=self.receiver_key,
248
+ auth_secret=self.auth_key,
249
+ version=content_encoding,
250
+ )
251
+ reply["body"] = encrypted
252
+ else:
253
+ self.verb("Encrypting to aesgcm...")
254
+ crypto_key = base64.urlsafe_b64encode(crypto_key).strip(b"=")
255
+ encrypted = http_ece.encrypt(
256
+ data,
257
+ salt=salt,
258
+ private_key=server_key,
259
+ keyid=crypto_key.decode(),
260
+ dh=self.receiver_key,
261
+ auth_secret=self.auth_key,
262
+ version=content_encoding,
263
+ )
264
+ reply["crypto_key"] = crypto_key
265
+ reply["body"] = encrypted
266
+ if salt:
267
+ reply["salt"] = base64.urlsafe_b64encode(salt).strip(b"=")
268
+ return reply
269
+
270
+ def as_curl(self, endpoint: str, encoded_data: bytes, headers: Dict[str, str]):
271
+ """Return the send as a curl command.
272
+
273
+ Useful for debugging. This will write out the encoded data to a local
274
+ file named `encrypted.data`
275
+
276
+ :param endpoint: Push service endpoint URL
277
+ :type endpoint: basestring
278
+ :param encoded_data: byte array of encoded data
279
+ :type encoded_data: bytearray
280
+ :param headers: Additional headers for the send
281
+ :type headers: dict
282
+ :returns string
283
+
284
+ """
285
+ header_list = [
286
+ '-H "{}: {}" \\ \n'.format(key.lower(), val) for key, val in headers.items()
287
+ ]
288
+ data = ""
289
+ if encoded_data:
290
+ with open("encrypted.data", "wb") as f:
291
+ f.write(encoded_data)
292
+ data = "--data-binary @encrypted.data"
293
+ if "content-length" not in headers:
294
+ self.verb("Generating content-length header...")
295
+ header_list.append(
296
+ '-H "content-length: {}" \\ \n'.format(len(encoded_data))
297
+ )
298
+ return """curl -vX POST {url} \\\n{headers}{data}""".format(
299
+ url=endpoint, headers="".join(header_list), data=data
300
+ )
301
+
302
+ def _prepare_send_data(
303
+ self,
304
+ data: Union[None, bytes] = None,
305
+ headers: Union[None, Dict[str, str]] = None,
306
+ ttl: int = 0,
307
+ gcm_key: Union[None, str] = None,
308
+ reg_id: Union[None, str] = None,
309
+ content_encoding: str = "aes128gcm",
310
+ curl: bool = False,
311
+ ) -> dict:
312
+ """Encode and send the data to the Push Service.
313
+
314
+ :param data: A serialized block of data (see encode() ).
315
+ :type data: str
316
+ :param headers: A dictionary containing any additional HTTP headers.
317
+ :type headers: dict
318
+ :param ttl: The Time To Live in seconds for this message if the
319
+ recipient is not online. (Defaults to "0", which discards the
320
+ message immediately if the recipient is unavailable.)
321
+ :type ttl: int
322
+ :param gcm_key: API key obtained from the Google Developer Console.
323
+ Needed if endpoint is https://android.googleapis.com/gcm/send
324
+ :type gcm_key: string
325
+ :param reg_id: registration id of the recipient. If not provided,
326
+ it will be extracted from the endpoint.
327
+ :type reg_id: str
328
+ :param content_encoding: ECE content encoding (defaults to "aes128gcm")
329
+ :type content_encoding: str
330
+ :param curl: Display output as `curl` command instead of sending
331
+ :type curl: bool
332
+ """
333
+ # Encode the data.
334
+ if headers is None:
335
+ headers = dict()
336
+ encoded = CaseInsensitiveDict()
337
+ headers = CaseInsensitiveDict(headers)
338
+ if data:
339
+ encoded = self.encode(data, content_encoding)
340
+ if "crypto_key" in encoded:
341
+ # Append the p256dh to the end of any existing crypto-key
342
+ crypto_key = headers.get("crypto-key", "")
343
+ if crypto_key:
344
+ # due to some confusion by a push service provider, we
345
+ # should use ';' instead of ',' to append the headers.
346
+ # see
347
+ # https://github.com/webpush-wg/webpush-encryption/issues/6
348
+ crypto_key += ";"
349
+ crypto_key += "dh=" + encoded["crypto_key"].decode("utf8")
350
+ headers.update({"crypto-key": crypto_key})
351
+ if "salt" in encoded:
352
+ headers.update({"encryption": "salt=" + encoded["salt"].decode("utf8")})
353
+ headers.update(
354
+ {
355
+ "content-encoding": content_encoding,
356
+ }
357
+ )
358
+ if gcm_key:
359
+ # guess if it is a legacy GCM project key or actual FCM key
360
+ # gcm keys are all about 40 chars (use 100 for confidence),
361
+ # fcm keys are 153-175 chars
362
+ if len(gcm_key) < 100:
363
+ self.verb("Guessing this is legacy GCM...")
364
+ endpoint = "https://android.googleapis.com/gcm/send"
365
+ else:
366
+ self.verb("Guessing this is FCM...")
367
+ endpoint = "https://fcm.googleapis.com/fcm/send"
368
+ reg_ids = []
369
+ if not reg_id:
370
+ reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[
371
+ -1
372
+ ]
373
+ self.verb("Fetching out registration id: {}", reg_id)
374
+ reg_ids.append(reg_id)
375
+ gcm_data = dict()
376
+ gcm_data["registration_ids"] = reg_ids
377
+ if data:
378
+ buffer = encoded.get("body")
379
+ if buffer:
380
+ gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8")
381
+ gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl)
382
+ encoded_data = json.dumps(gcm_data)
383
+ headers.update(
384
+ {
385
+ "Authorization": "key=" + gcm_key,
386
+ "Content-Type": "application/json",
387
+ }
388
+ )
389
+ else:
390
+ encoded_data = encoded.get("body")
391
+ endpoint = self.subscription_info["endpoint"]
392
+
393
+ if "ttl" not in headers or ttl:
394
+ self.verb("Generating TTL of 0...")
395
+ headers["ttl"] = str(ttl or 0)
396
+ # Additionally useful headers:
397
+ # Authorization / Crypto-Key (VAPID headers)
398
+
399
+ self.verb(
400
+ "\nSending request to" "\n\thost: {}\n\theaders: {}\n\tdata: {}",
401
+ endpoint,
402
+ headers,
403
+ encoded_data,
404
+ )
405
+
406
+ return {"endpoint": endpoint, "data": encoded_data, "headers": headers}
407
+
408
+ def send(self, *args, **kwargs) -> Union[Response, str]:
409
+ """Encode and send the data to the Push Service"""
410
+ timeout = kwargs.pop("timeout", 10000)
411
+ curl = kwargs.pop("curl", False)
412
+
413
+ params = self._prepare_send_data(*args, **kwargs)
414
+ endpoint = params.pop("endpoint")
415
+
416
+ if curl:
417
+ encoded_data = params["data"]
418
+ headers = params["headers"]
419
+ return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
420
+
421
+ resp = self.requests_method.post(
422
+ endpoint,
423
+ timeout=timeout,
424
+ **params,
425
+ )
426
+ self.verb(
427
+ "\nResponse:\n\tcode: {}\n\tbody: {}\n",
428
+ resp.status_code,
429
+ resp.text or "Empty",
430
+ )
431
+ return resp
432
+
433
+ async def send_async(self, *args, **kwargs) -> Union[aiohttp.ClientResponse, str]:
434
+ timeout = kwargs.pop("timeout", 10000)
435
+ curl = kwargs.pop("curl", False)
436
+
437
+ params = self._prepare_send_data(*args, **kwargs)
438
+ endpoint = params.pop("endpoint")
439
+
440
+ if curl:
441
+ encoded_data = params["data"]
442
+ headers = params["headers"]
443
+ return self.as_curl(endpoint, encoded_data=encoded_data, headers=headers)
444
+ if self.aiohttp_session:
445
+ resp = await self.aiohttp_session.post(endpoint, timeout=timeout, **params)
446
+ resp_text = await resp.text()
447
+ else:
448
+ async with aiohttp.ClientSession() as session:
449
+ resp = await session.post(endpoint, timeout=timeout, **params)
450
+ resp_text = await resp.text()
451
+ self.verb(
452
+ "\nResponse:\n\tcode: {}\n\tbody: {}\n",
453
+ resp.status,
454
+ resp_text or "Empty",
455
+ )
456
+ return resp
457
+
458
+
459
+ def webpush(
460
+ subscription_info: Dict[
461
+ str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
462
+ ],
463
+ data: Union[None, str] = None,
464
+ vapid_private_key: Union[None, Vapid, str] = None,
465
+ vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
466
+ content_encoding: str = "aes128gcm",
467
+ curl: bool = False,
468
+ timeout: Union[None, float] = None,
469
+ ttl: int = 0,
470
+ verbose: bool = False,
471
+ headers: Union[None, Dict[str, Union[str, int, float]]] = None,
472
+ requests_session: Union[None, requests.Session] = None,
473
+ ) -> Union[str, requests.Response]:
474
+ """
475
+ One call solution to endcode and send `data` to the endpoint
476
+ contained in `subscription_info` using optional VAPID auth headers.
477
+
478
+ in example:
479
+
480
+ .. code-block:: python
481
+
482
+ from pywebpush import python
483
+
484
+ webpush(
485
+ subscription_info={
486
+ "endpoint": "https://push.example.com/v1/abcd",
487
+ "keys": {"p256dh": "0123abcd...",
488
+ "auth": "001122..."}
489
+ },
490
+ data="Mary had a little lamb, with a nice mint jelly",
491
+ vapid_private_key="path/to/key.pem",
492
+ vapid_claims={"sub": "YourNameHere@example.com"}
493
+ )
494
+
495
+ No additional method call is required. Any non-success will throw a
496
+ `WebPushException`.
497
+
498
+ :param subscription_info: Provided by the client call
499
+ :type subscription_info: dict
500
+ :param data: Serialized data to send
501
+ :type data: str
502
+ :param vapid_private_key: Vapid instance or path to vapid private key PEM \
503
+ or encoded str
504
+ :type vapid_private_key: Union[Vapid, str]
505
+ :param vapid_claims: Dictionary of claims ('sub' required)
506
+ :type vapid_claims: dict
507
+ :param content_encoding: Optional content type string
508
+ :type content_encoding: str
509
+ :param curl: Return as "curl" string instead of sending
510
+ :type curl: bool
511
+ :param timeout: POST requests timeout
512
+ :type timeout: float
513
+ :param ttl: Time To Live
514
+ :type ttl: int
515
+ :param verbose: Provide verbose feedback
516
+ :type verbose: bool
517
+ :return requests.Response or string
518
+ :param headers: Dictionary of extra HTTP headers to include
519
+ :type headers: dict
520
+
521
+ """
522
+ if headers is None:
523
+ headers = dict()
524
+ else:
525
+ # Ensure we don't leak VAPID headers by mutating the passed in dict.
526
+ headers = headers.copy()
527
+
528
+ vapid_headers = None
529
+ if vapid_claims:
530
+ if verbose:
531
+ logging.info("Generating VAPID headers...")
532
+ if not vapid_claims.get("aud"):
533
+ url = urlparse(cast(str, subscription_info.get("endpoint")))
534
+ aud = "{}://{}".format(url.scheme, url.netloc)
535
+ vapid_claims["aud"] = aud
536
+ # Remember, passed structures are mutable in python.
537
+ # It's possible that a previously set `exp` field is no longer valid.
538
+ if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
539
+ time.time()
540
+ ):
541
+ # encryption lives for 12 hours
542
+ vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
543
+ if verbose:
544
+ logging.info("Setting VAPID expry to {}...".format(vapid_claims["exp"]))
545
+ if not vapid_private_key:
546
+ raise WebPushException("VAPID dict missing 'private_key'")
547
+ if isinstance(vapid_private_key, Vapid01):
548
+ if verbose:
549
+ logging.info("Looks like we already have a valid VAPID key")
550
+ vv = vapid_private_key
551
+ elif os.path.isfile(vapid_private_key):
552
+ # Presume that key from file is handled correctly by
553
+ # py_vapid.
554
+ if verbose:
555
+ logging.info("Reading VAPID key from file {}".format(vapid_private_key))
556
+ vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
557
+ else:
558
+ if verbose:
559
+ logging.info("Reading VAPID key from arguments")
560
+ vv = Vapid.from_string(private_key=vapid_private_key)
561
+ if verbose:
562
+ logging.info("\t claims: {}".format(vapid_claims))
563
+ vapid_headers = vv.sign(vapid_claims)
564
+ if verbose:
565
+ logging.info("\t headers: {}".format(vapid_headers))
566
+ headers.update(vapid_headers)
567
+
568
+ response = WebPusher(
569
+ subscription_info, requests_session=requests_session, verbose=verbose
570
+ ).send(
571
+ data,
572
+ headers,
573
+ ttl=ttl,
574
+ content_encoding=content_encoding,
575
+ curl=curl,
576
+ timeout=timeout,
577
+ )
578
+ if not curl and cast(Response, response).status_code > 202:
579
+ response = cast(Response, response)
580
+ raise WebPushException(
581
+ "Push failed: {} {}\nResponse body:{}".format(
582
+ response.status_code, response.reason, response.text
583
+ ),
584
+ response=response,
585
+ )
586
+ return response
587
+
588
+
589
+ async def webpush_async(
590
+ subscription_info: Dict[
591
+ str, Union[Union[str, bytes], Dict[str, Union[str, bytes]]]
592
+ ],
593
+ data: Union[None, str] = None,
594
+ vapid_private_key: Union[None, Vapid, str] = None,
595
+ vapid_claims: Union[None, Dict[str, Union[str, int]]] = None,
596
+ content_encoding: str = "aes128gcm",
597
+ curl: bool = False,
598
+ timeout: Union[None, float] = None,
599
+ ttl: int = 0,
600
+ verbose: bool = False,
601
+ headers: Union[None, Dict[str, Union[str, int, float]]] = None,
602
+ aiohttp_session: Union[None, aiohttp.ClientSession] = None,
603
+ ) -> Union[str, aiohttp.ClientResponse]:
604
+ """
605
+ Async version of webpush function. One call solution to encode and send
606
+ `data` to the endpoint contained in `subscription_info` using optional
607
+ VAPID auth headers.
608
+
609
+ Example:
610
+
611
+ .. code-block:: python
612
+
613
+ from pywebpush import webpush_async
614
+ import asyncio
615
+
616
+ async def send_notification():
617
+ response = await webpush_async(
618
+ subscription_info={
619
+ "endpoint": "https://push.example.com/v1/abcd",
620
+ "keys": {"p256dh": "0123abcd...",
621
+ "auth": "001122..."}
622
+ },
623
+ data="Mary had a little lamb, with a nice mint jelly",
624
+ vapid_private_key="path/to/key.pem",
625
+ vapid_claims={"sub": "YourNameHere@example.com"}
626
+ )
627
+
628
+ asyncio.run(send_notification())
629
+
630
+ No additional method call is required. Any non-success will throw a
631
+ `WebPushException`.
632
+
633
+ :param subscription_info: Provided by the client call
634
+ :type subscription_info: dict
635
+ :param data: Serialized data to send
636
+ :type data: str
637
+ :param vapid_private_key: Vapid instance or path to vapid private key PEM \
638
+ or encoded str
639
+ :type vapid_private_key: Union[Vapid, str]
640
+ :param vapid_claims: Dictionary of claims ('sub' required)
641
+ :type vapid_claims: dict
642
+ :param content_encoding: Optional content type string
643
+ :type content_encoding: str
644
+ :param curl: Return as "curl" string instead of sending
645
+ :type curl: bool
646
+ :param timeout: POST requests timeout
647
+ :type timeout: float
648
+ :param ttl: Time To Live
649
+ :type ttl: int
650
+ :param verbose: Provide verbose feedback
651
+ :type verbose: bool
652
+ :param headers: Dictionary of extra HTTP headers to include
653
+ :type headers: dict
654
+ :param aiohttp_session: Optional aiohttp ClientSession for connection reuse
655
+ :type aiohttp_session: aiohttp.ClientSession
656
+ :return aiohttp.ClientResponse or string
657
+
658
+ """
659
+ if headers is None:
660
+ headers = dict()
661
+ else:
662
+ # Ensure we don't leak VAPID headers by mutating the passed in dict.
663
+ headers = headers.copy()
664
+
665
+ vapid_headers = None
666
+ if vapid_claims:
667
+ if verbose:
668
+ logging.info("Generating VAPID headers...")
669
+ if not vapid_claims.get("aud"):
670
+ url = urlparse(cast(str, subscription_info.get("endpoint")))
671
+ aud = "{}://{}".format(url.scheme, url.netloc)
672
+ vapid_claims["aud"] = aud
673
+ # Remember, passed structures are mutable in python.
674
+ # It's possible that a previously set `exp` field is no longer valid.
675
+ if not vapid_claims.get("exp") or int(vapid_claims.get("exp") or 0) < int(
676
+ time.time()
677
+ ):
678
+ # encryption lives for 12 hours
679
+ vapid_claims["exp"] = int(time.time()) + (12 * 60 * 60)
680
+ if verbose:
681
+ logging.info(
682
+ "Setting VAPID expiry to {}...".format(vapid_claims["exp"])
683
+ )
684
+ if not vapid_private_key:
685
+ raise WebPushException("VAPID dict missing 'private_key'")
686
+ if isinstance(vapid_private_key, Vapid01):
687
+ if verbose:
688
+ logging.info("Looks like we already have a valid VAPID key")
689
+ vv = vapid_private_key
690
+ elif os.path.isfile(vapid_private_key):
691
+ # Presume that key from file is handled correctly by
692
+ # py_vapid.
693
+ if verbose:
694
+ logging.info("Reading VAPID key from file {}".format(vapid_private_key))
695
+ vv = Vapid.from_file(private_key_file=vapid_private_key) # pragma no cover
696
+ else:
697
+ if verbose:
698
+ logging.info("Reading VAPID key from arguments")
699
+ vv = Vapid.from_string(private_key=vapid_private_key)
700
+ if verbose:
701
+ logging.info("\t claims: {}".format(vapid_claims))
702
+ vapid_headers = vv.sign(vapid_claims)
703
+ if verbose:
704
+ logging.info("\t headers: {}".format(vapid_headers))
705
+ headers.update(vapid_headers)
706
+
707
+ response = await WebPusher(
708
+ subscription_info, aiohttp_session=aiohttp_session, verbose=verbose
709
+ ).send_async(
710
+ data,
711
+ headers,
712
+ ttl=ttl,
713
+ content_encoding=content_encoding,
714
+ curl=curl,
715
+ timeout=timeout,
716
+ )
717
+ if not curl and cast(aiohttp.ClientResponse, response).status > 202:
718
+ response = cast(aiohttp.ClientResponse, response)
719
+ response_text = await response.text()
720
+ raise WebPushException(
721
+ "Push failed: {} {}\nResponse body:{}".format(
722
+ response.status, response.reason, response_text
723
+ ),
724
+ response=response,
725
+ )
726
+ return response