pycupra 0.0.15__py3-none-any.whl → 0.1.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.
- pycupra/__init__.py +1 -0
- pycupra/__version__.py +1 -1
- pycupra/connection.py +66 -9
- pycupra/const.py +11 -0
- pycupra/dashboard.py +7 -0
- pycupra/firebase.py +73 -0
- pycupra/firebase_messaging/__init__.py +20 -0
- pycupra/firebase_messaging/const.py +32 -0
- pycupra/firebase_messaging/fcmpushclient.py +796 -0
- pycupra/firebase_messaging/fcmregister.py +519 -0
- pycupra/firebase_messaging/py.typed +0 -0
- pycupra/vehicle.py +257 -13
- {pycupra-0.0.15.dist-info → pycupra-0.1.1.dist-info}/METADATA +12 -2
- pycupra-0.1.1.dist-info/RECORD +19 -0
- pycupra-0.0.15.dist-info/RECORD +0 -13
- {pycupra-0.0.15.dist-info → pycupra-0.1.1.dist-info}/WHEEL +0 -0
- {pycupra-0.0.15.dist-info → pycupra-0.1.1.dist-info}/licenses/LICENSE +0 -0
- {pycupra-0.0.15.dist-info → pycupra-0.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,519 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import json
|
5
|
+
import logging
|
6
|
+
import os
|
7
|
+
import secrets
|
8
|
+
import time
|
9
|
+
import uuid
|
10
|
+
from base64 import b64encode, urlsafe_b64encode
|
11
|
+
from dataclasses import dataclass
|
12
|
+
from typing import Any, Callable
|
13
|
+
|
14
|
+
from aiohttp import ClientSession, ClientTimeout
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
17
|
+
from google.protobuf.json_format import MessageToDict, MessageToJson
|
18
|
+
|
19
|
+
from .const import (
|
20
|
+
AUTH_VERSION,
|
21
|
+
FCM_INSTALLATION,
|
22
|
+
FCM_REGISTRATION,
|
23
|
+
FCM_SEND_URL,
|
24
|
+
GCM_CHECKIN_URL,
|
25
|
+
GCM_REGISTER_URL,
|
26
|
+
GCM_SERVER_KEY_B64,
|
27
|
+
SDK_VERSION,
|
28
|
+
)
|
29
|
+
from .proto.android_checkin_pb2 import (
|
30
|
+
DEVICE_CHROME_BROWSER,
|
31
|
+
AndroidCheckinProto,
|
32
|
+
ChromeBuildProto,
|
33
|
+
)
|
34
|
+
from .proto.checkin_pb2 import (
|
35
|
+
AndroidCheckinRequest,
|
36
|
+
AndroidCheckinResponse,
|
37
|
+
)
|
38
|
+
|
39
|
+
_logger = logging.getLogger(__name__)
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class FcmRegisterConfig:
|
44
|
+
project_id: str
|
45
|
+
app_id: str
|
46
|
+
api_key: str
|
47
|
+
messaging_sender_id: str
|
48
|
+
bundle_id: str = "receiver.push.com"
|
49
|
+
chrome_id: str = "org.chromium.linux"
|
50
|
+
chrome_version: str = "94.0.4606.51"
|
51
|
+
vapid_key: str | None = GCM_SERVER_KEY_B64
|
52
|
+
persistend_ids: list[str] | None = None
|
53
|
+
heartbeat_interval_ms: int = 5 * 60 * 1000 # 5 mins
|
54
|
+
|
55
|
+
def __postinit__(self) -> None:
|
56
|
+
if self.persistend_ids is None:
|
57
|
+
self.persistend_ids = []
|
58
|
+
|
59
|
+
|
60
|
+
class FcmRegister:
|
61
|
+
CLIENT_TIMEOUT = ClientTimeout(total=3)
|
62
|
+
|
63
|
+
def __init__(
|
64
|
+
self,
|
65
|
+
config: FcmRegisterConfig,
|
66
|
+
credentials: dict | None = None,
|
67
|
+
credentials_updated_callback: Callable[[dict[str, Any]], None] | None = None,
|
68
|
+
*,
|
69
|
+
http_client_session: ClientSession | None = None,
|
70
|
+
log_debug_verbose: bool = False,
|
71
|
+
):
|
72
|
+
self.config = config
|
73
|
+
self.credentials = credentials
|
74
|
+
self.credentials_updated_callback = credentials_updated_callback
|
75
|
+
|
76
|
+
self._log_debug_verbose = log_debug_verbose
|
77
|
+
|
78
|
+
self._http_client_session = http_client_session
|
79
|
+
self._local_session: ClientSession | None = None
|
80
|
+
|
81
|
+
def _get_checkin_payload(
|
82
|
+
self, android_id: int | None = None, security_token: int | None = None
|
83
|
+
) -> AndroidCheckinRequest:
|
84
|
+
chrome = ChromeBuildProto()
|
85
|
+
chrome.platform = ChromeBuildProto.Platform.PLATFORM_LINUX # 3
|
86
|
+
chrome.chrome_version = self.config.chrome_version
|
87
|
+
chrome.channel = ChromeBuildProto.Channel.CHANNEL_STABLE # 1
|
88
|
+
|
89
|
+
checkin = AndroidCheckinProto()
|
90
|
+
checkin.type = DEVICE_CHROME_BROWSER # 3
|
91
|
+
checkin.chrome_build.CopyFrom(chrome)
|
92
|
+
|
93
|
+
payload = AndroidCheckinRequest()
|
94
|
+
payload.user_serial_number = 0
|
95
|
+
payload.checkin.CopyFrom(checkin)
|
96
|
+
payload.version = 3
|
97
|
+
if android_id and security_token:
|
98
|
+
payload.id = int(android_id)
|
99
|
+
payload.security_token = int(security_token)
|
100
|
+
|
101
|
+
return payload
|
102
|
+
|
103
|
+
async def gcm_check_in_and_register(
|
104
|
+
self,
|
105
|
+
) -> dict[str, Any] | None:
|
106
|
+
options = await self.gcm_check_in()
|
107
|
+
if not options:
|
108
|
+
raise RuntimeError("Unable to register and check in to gcm")
|
109
|
+
gcm_credentials = await self.gcm_register(options)
|
110
|
+
return gcm_credentials
|
111
|
+
|
112
|
+
async def gcm_check_in(
|
113
|
+
self,
|
114
|
+
android_id: int | None = None,
|
115
|
+
security_token: int | None = None,
|
116
|
+
) -> dict[str, Any] | None:
|
117
|
+
"""
|
118
|
+
perform check-in request
|
119
|
+
|
120
|
+
android_id, security_token can be provided if we already did the initial
|
121
|
+
check-in
|
122
|
+
|
123
|
+
returns dict with android_id, security_token and more
|
124
|
+
"""
|
125
|
+
|
126
|
+
payload = self._get_checkin_payload(android_id, security_token)
|
127
|
+
|
128
|
+
if self._log_debug_verbose:
|
129
|
+
_logger.debug("GCM check in payload:\n%s", payload)
|
130
|
+
|
131
|
+
retries = 3
|
132
|
+
acir = None
|
133
|
+
content = None
|
134
|
+
for try_num in range(retries):
|
135
|
+
try:
|
136
|
+
async with self._session.post(
|
137
|
+
url=GCM_CHECKIN_URL,
|
138
|
+
headers={"Content-Type": "application/x-protobuf"},
|
139
|
+
data=payload.SerializeToString(),
|
140
|
+
timeout=self.CLIENT_TIMEOUT,
|
141
|
+
) as resp:
|
142
|
+
if resp.status == 200:
|
143
|
+
acir = AndroidCheckinResponse()
|
144
|
+
content = await resp.read()
|
145
|
+
break
|
146
|
+
else:
|
147
|
+
text = await resp.text()
|
148
|
+
if acir and content:
|
149
|
+
break
|
150
|
+
else:
|
151
|
+
_logger.warning(
|
152
|
+
"GCM checkin failed on attempt %s out "
|
153
|
+
+ "of %s with status: %s, %s",
|
154
|
+
try_num + 1,
|
155
|
+
retries,
|
156
|
+
resp.status,
|
157
|
+
text,
|
158
|
+
)
|
159
|
+
# retry without android id and security_token
|
160
|
+
payload = self._get_checkin_payload()
|
161
|
+
await asyncio.sleep(1)
|
162
|
+
except Exception as e:
|
163
|
+
_logger.warning(
|
164
|
+
"Error during gcm checkin post attempt %s out of %s",
|
165
|
+
try_num + 1,
|
166
|
+
retries,
|
167
|
+
exc_info=e,
|
168
|
+
)
|
169
|
+
await asyncio.sleep(1)
|
170
|
+
|
171
|
+
if not acir or not content:
|
172
|
+
_logger.error("Unable to checkin to gcm after %s retries", retries)
|
173
|
+
return None
|
174
|
+
acir.ParseFromString(content)
|
175
|
+
|
176
|
+
if self._log_debug_verbose:
|
177
|
+
msg = MessageToJson(acir, indent=4)
|
178
|
+
_logger.debug("GCM check in response (raw):\n%s", msg)
|
179
|
+
|
180
|
+
return MessageToDict(acir)
|
181
|
+
|
182
|
+
async def gcm_register(
|
183
|
+
self,
|
184
|
+
options: dict[str, Any],
|
185
|
+
retries: int = 4,
|
186
|
+
) -> dict[str, str] | None:
|
187
|
+
"""
|
188
|
+
obtains a gcm token
|
189
|
+
|
190
|
+
app_id: app id as an integer
|
191
|
+
retries: number of failed requests before giving up
|
192
|
+
|
193
|
+
returns {"token": "...", "gcm_app_id": 123123, "androidId":123123,
|
194
|
+
"securityToken": 123123}
|
195
|
+
"""
|
196
|
+
# contains android_id, security_token and more
|
197
|
+
gcm_app_id = f"wp:{self.config.bundle_id}#{uuid.uuid4()}"
|
198
|
+
android_id = options["androidId"]
|
199
|
+
security_token = options["securityToken"]
|
200
|
+
|
201
|
+
headers = {
|
202
|
+
"Authorization": f"AidLogin {android_id}:{security_token}",
|
203
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
204
|
+
}
|
205
|
+
body = {
|
206
|
+
"app": "org.chromium.linux",
|
207
|
+
"X-subtype": gcm_app_id,
|
208
|
+
"device": android_id,
|
209
|
+
"sender": GCM_SERVER_KEY_B64,
|
210
|
+
}
|
211
|
+
if self._log_debug_verbose:
|
212
|
+
_logger.debug("GCM Registration request: %s", body)
|
213
|
+
|
214
|
+
last_error: str | Exception | None = None
|
215
|
+
for try_num in range(retries):
|
216
|
+
try:
|
217
|
+
async with self._session.post(
|
218
|
+
url=GCM_REGISTER_URL,
|
219
|
+
headers=headers,
|
220
|
+
data=body,
|
221
|
+
timeout=self.CLIENT_TIMEOUT,
|
222
|
+
) as resp:
|
223
|
+
response_text = await resp.text()
|
224
|
+
if "Error" in response_text:
|
225
|
+
_logger.warning(
|
226
|
+
"GCM register request attempt %s out of %s has failed with %s",
|
227
|
+
try_num + 1,
|
228
|
+
retries,
|
229
|
+
response_text,
|
230
|
+
)
|
231
|
+
last_error = response_text
|
232
|
+
await asyncio.sleep(1)
|
233
|
+
continue
|
234
|
+
token = response_text.split("=")[1]
|
235
|
+
|
236
|
+
return {
|
237
|
+
"token": token,
|
238
|
+
"app_id": gcm_app_id,
|
239
|
+
"android_id": android_id,
|
240
|
+
"security_token": security_token,
|
241
|
+
}
|
242
|
+
|
243
|
+
except Exception as e:
|
244
|
+
last_error = e
|
245
|
+
_logger.warning(
|
246
|
+
"Error during gcm auth request attempt %s out of %s",
|
247
|
+
try_num + 1,
|
248
|
+
retries,
|
249
|
+
exc_info=e,
|
250
|
+
)
|
251
|
+
await asyncio.sleep(1)
|
252
|
+
|
253
|
+
errorstr = f"Unable to complete gcm auth request after {retries} tries"
|
254
|
+
if isinstance(last_error, Exception):
|
255
|
+
_logger.error(errorstr, exc_info=last_error)
|
256
|
+
else:
|
257
|
+
errorstr += f", last error was {last_error}"
|
258
|
+
_logger.error(errorstr)
|
259
|
+
return None
|
260
|
+
|
261
|
+
async def fcm_install_and_register(
|
262
|
+
self, gcm_data: dict[str, Any], keys: dict[str, Any]
|
263
|
+
) -> dict[str, Any] | None:
|
264
|
+
if installation := await self.fcm_install():
|
265
|
+
registration = await self.fcm_register(gcm_data, installation, keys)
|
266
|
+
return {
|
267
|
+
"registration": registration,
|
268
|
+
"installation": installation,
|
269
|
+
}
|
270
|
+
return None
|
271
|
+
|
272
|
+
async def fcm_install(self) -> dict | None:
|
273
|
+
fid = bytearray(secrets.token_bytes(17))
|
274
|
+
# Replace the first 4 bits with the FID header 0b0111.
|
275
|
+
fid[0] = 0b01110000 + (fid[0] % 0b00010000)
|
276
|
+
fid64 = b64encode(fid).decode()
|
277
|
+
|
278
|
+
hb_header = b64encode(
|
279
|
+
json.dumps({"heartbeats": [], "version": 2}).encode()
|
280
|
+
).decode()
|
281
|
+
headers = {
|
282
|
+
"x-firebase-client": hb_header,
|
283
|
+
"x-goog-api-key": self.config.api_key,
|
284
|
+
}
|
285
|
+
payload = {
|
286
|
+
"appId": self.config.app_id,
|
287
|
+
"authVersion": AUTH_VERSION,
|
288
|
+
"fid": fid64,
|
289
|
+
"sdkVersion": SDK_VERSION,
|
290
|
+
}
|
291
|
+
url = FCM_INSTALLATION + f"projects/{self.config.project_id}/installations"
|
292
|
+
async with self._session.post(
|
293
|
+
url=url,
|
294
|
+
headers=headers,
|
295
|
+
data=json.dumps(payload),
|
296
|
+
timeout=self.CLIENT_TIMEOUT,
|
297
|
+
) as resp:
|
298
|
+
if resp.status == 200:
|
299
|
+
fcm_install = await resp.json()
|
300
|
+
|
301
|
+
return {
|
302
|
+
"token": fcm_install["authToken"]["token"],
|
303
|
+
"expires_in": int(fcm_install["authToken"]["expiresIn"][:-1:]),
|
304
|
+
"refresh_token": fcm_install["refreshToken"],
|
305
|
+
"fid": fcm_install["fid"],
|
306
|
+
"created_at": time.monotonic(),
|
307
|
+
}
|
308
|
+
else:
|
309
|
+
text = await resp.text()
|
310
|
+
_logger.error(
|
311
|
+
"Error during fcm_install: %s ",
|
312
|
+
text,
|
313
|
+
)
|
314
|
+
return None
|
315
|
+
|
316
|
+
async def fcm_refresh_install_token(self) -> dict | None:
|
317
|
+
hb_header = b64encode(
|
318
|
+
json.dumps({"heartbeats": [], "version": 2}).encode()
|
319
|
+
).decode()
|
320
|
+
if not self.credentials:
|
321
|
+
raise RuntimeError("Credentials must be set to refresh install token")
|
322
|
+
fcm_refresh_token = self.credentials["fcm"]["installation"]["refresh_token"]
|
323
|
+
|
324
|
+
headers = {
|
325
|
+
"Authorization": f"{AUTH_VERSION} {fcm_refresh_token}",
|
326
|
+
"x-firebase-client": hb_header,
|
327
|
+
"x-goog-api-key": self.config.api_key,
|
328
|
+
}
|
329
|
+
payload = {
|
330
|
+
"installation": {
|
331
|
+
"sdkVersion": SDK_VERSION,
|
332
|
+
"appId": self.config.app_id,
|
333
|
+
}
|
334
|
+
}
|
335
|
+
url = (
|
336
|
+
FCM_INSTALLATION + f"projects/{self.config.project_id}/"
|
337
|
+
"installations/{fid}/authTokens:generate"
|
338
|
+
)
|
339
|
+
async with self._session.post(
|
340
|
+
url=url,
|
341
|
+
headers=headers,
|
342
|
+
data=json.dumps(payload),
|
343
|
+
timeout=self.CLIENT_TIMEOUT,
|
344
|
+
) as resp:
|
345
|
+
if resp.status == 200:
|
346
|
+
fcm_refresh = await resp.json()
|
347
|
+
return {
|
348
|
+
"token": fcm_refresh["token"],
|
349
|
+
"expires_in": int(fcm_refresh["expiresIn"][:-1:]),
|
350
|
+
"created_at": time.monotonic(),
|
351
|
+
}
|
352
|
+
else:
|
353
|
+
text = await resp.text()
|
354
|
+
_logger.error(
|
355
|
+
"Error during fcm_refresh_install_token: %s ",
|
356
|
+
text,
|
357
|
+
)
|
358
|
+
return None
|
359
|
+
|
360
|
+
def generate_keys(self) -> dict:
|
361
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
362
|
+
public_key = private_key.public_key()
|
363
|
+
|
364
|
+
serialized_private = private_key.private_bytes(
|
365
|
+
encoding=serialization.Encoding.DER, # asn1
|
366
|
+
format=serialization.PrivateFormat.PKCS8,
|
367
|
+
encryption_algorithm=serialization.NoEncryption(),
|
368
|
+
)
|
369
|
+
serialized_public = public_key.public_bytes(
|
370
|
+
encoding=serialization.Encoding.DER,
|
371
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
372
|
+
)
|
373
|
+
|
374
|
+
return {
|
375
|
+
"public": urlsafe_b64encode(serialized_public[26:]).decode(
|
376
|
+
"ascii"
|
377
|
+
), # urlsafe_base64(serialized_public[26:]),
|
378
|
+
"private": urlsafe_b64encode(serialized_private).decode("ascii"),
|
379
|
+
"secret": urlsafe_b64encode(os.urandom(16)).decode("ascii"),
|
380
|
+
}
|
381
|
+
|
382
|
+
async def fcm_register(
|
383
|
+
self,
|
384
|
+
gcm_data: dict,
|
385
|
+
installation: dict,
|
386
|
+
keys: dict,
|
387
|
+
retries: int = 4,
|
388
|
+
) -> dict[str, Any] | None:
|
389
|
+
headers = {
|
390
|
+
"x-goog-api-key": self.config.api_key,
|
391
|
+
"x-goog-firebase-installations-auth": installation["token"],
|
392
|
+
}
|
393
|
+
# If vapid_key is the default do not send it here or it will error
|
394
|
+
vapid_key = (
|
395
|
+
self.config.vapid_key
|
396
|
+
if self.config.vapid_key != GCM_SERVER_KEY_B64
|
397
|
+
else None
|
398
|
+
)
|
399
|
+
payload = {
|
400
|
+
"web": {
|
401
|
+
"applicationPubKey": vapid_key,
|
402
|
+
"auth": keys["secret"],
|
403
|
+
"endpoint": FCM_SEND_URL + gcm_data["token"],
|
404
|
+
"p256dh": keys["public"],
|
405
|
+
}
|
406
|
+
}
|
407
|
+
url = FCM_REGISTRATION + f"projects/{self.config.project_id}/registrations"
|
408
|
+
if self._log_debug_verbose:
|
409
|
+
_logger.debug("FCM registration data: %s", payload)
|
410
|
+
|
411
|
+
for try_num in range(retries):
|
412
|
+
try:
|
413
|
+
async with self._session.post(
|
414
|
+
url=url,
|
415
|
+
headers=headers,
|
416
|
+
data=json.dumps(payload),
|
417
|
+
timeout=self.CLIENT_TIMEOUT,
|
418
|
+
) as resp:
|
419
|
+
if resp.status == 200:
|
420
|
+
fcm = await resp.json()
|
421
|
+
return fcm
|
422
|
+
else:
|
423
|
+
text = await resp.text()
|
424
|
+
_logger.error( # pylint: disable=duplicate-code
|
425
|
+
"Error during fmc register request "
|
426
|
+
"attempt %s out of %s: %s",
|
427
|
+
try_num + 1,
|
428
|
+
retries,
|
429
|
+
text,
|
430
|
+
)
|
431
|
+
|
432
|
+
except Exception as e:
|
433
|
+
_logger.error( # pylint: disable=duplicate-code
|
434
|
+
"Error during fmc register request attempt %s out of %s",
|
435
|
+
try_num + 1,
|
436
|
+
retries,
|
437
|
+
exc_info=e,
|
438
|
+
)
|
439
|
+
await asyncio.sleep(1)
|
440
|
+
return None
|
441
|
+
|
442
|
+
async def checkin_or_register(self, fcmCredentialsFileName) -> dict[str, Any]:
|
443
|
+
"""Check in if you have credentials otherwise register as a new client.
|
444
|
+
|
445
|
+
:param sender_id: sender id identifying push service you are connecting to.
|
446
|
+
:param app_id: identifier for your application.
|
447
|
+
:return: The FCM token which is used to identify you with the push end
|
448
|
+
point application.
|
449
|
+
"""
|
450
|
+
if self.credentials:
|
451
|
+
gcm_response = await self.gcm_check_in(
|
452
|
+
self.credentials["gcm"]["android_id"],
|
453
|
+
self.credentials["gcm"]["security_token"],
|
454
|
+
)
|
455
|
+
if gcm_response:
|
456
|
+
return self.credentials
|
457
|
+
|
458
|
+
self.credentials = await self.register()
|
459
|
+
if self.credentials_updated_callback:
|
460
|
+
await self.credentials_updated_callback(self.credentials, fcmCredentialsFileName)
|
461
|
+
|
462
|
+
return self.credentials
|
463
|
+
|
464
|
+
async def register(self) -> dict:
|
465
|
+
"""Register gcm and fcm tokens for sender_id.
|
466
|
+
Typically you would
|
467
|
+
call checkin instead of register which does not do a full registration
|
468
|
+
if credentials are present
|
469
|
+
|
470
|
+
:param sender_id: sender id identifying push service you are connecting to.
|
471
|
+
:param app_id: identifier for your application.
|
472
|
+
:return: The dict containing all credentials.
|
473
|
+
"""
|
474
|
+
|
475
|
+
keys = self.generate_keys()
|
476
|
+
|
477
|
+
gcm_data = await self.gcm_check_in_and_register()
|
478
|
+
if gcm_data is None:
|
479
|
+
raise RuntimeError(
|
480
|
+
"Unable to establish subscription with Google Cloud Messaging."
|
481
|
+
)
|
482
|
+
self._log_verbose("GCM subscription: %s", gcm_data)
|
483
|
+
|
484
|
+
fcm_data = await self.fcm_install_and_register(gcm_data, keys)
|
485
|
+
if not fcm_data:
|
486
|
+
raise RuntimeError("Unable to register with fcm")
|
487
|
+
self._log_verbose("FCM registration: %s", fcm_data)
|
488
|
+
res: dict[str, Any] = {
|
489
|
+
"keys": keys,
|
490
|
+
"gcm": gcm_data,
|
491
|
+
"fcm": fcm_data,
|
492
|
+
"config": {
|
493
|
+
"bundle_id": self.config.bundle_id,
|
494
|
+
"project_id": self.config.project_id,
|
495
|
+
"vapid_key": self.config.vapid_key,
|
496
|
+
},
|
497
|
+
}
|
498
|
+
self._log_verbose("Credential: %s", res)
|
499
|
+
_logger.info("Registered with FCM")
|
500
|
+
return res
|
501
|
+
|
502
|
+
def _log_verbose(self, msg: str, *args: object) -> None:
|
503
|
+
if self._log_debug_verbose:
|
504
|
+
_logger.debug(msg, *args)
|
505
|
+
|
506
|
+
@property
|
507
|
+
def _session(self) -> ClientSession:
|
508
|
+
if self._http_client_session:
|
509
|
+
return self._http_client_session
|
510
|
+
if self._local_session is None:
|
511
|
+
self._local_session = ClientSession()
|
512
|
+
return self._local_session
|
513
|
+
|
514
|
+
async def close(self) -> None:
|
515
|
+
"""Close aiohttp session."""
|
516
|
+
session = self._local_session
|
517
|
+
self._local_session = None
|
518
|
+
if session:
|
519
|
+
await session.close()
|
File without changes
|