firebase-admin 7.6.0__tar.gz → 7.7.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 (66) hide show
  1. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/PKG-INFO +1 -1
  2. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/__about__.py +1 -1
  3. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/app_check.py +1 -1
  4. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/messaging.py +339 -8
  5. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/PKG-INFO +1 -1
  6. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_app_check.py +4 -2
  7. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_messaging.py +564 -18
  8. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/LICENSE +0 -0
  9. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/README.md +0 -0
  10. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/__init__.py +0 -0
  11. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_auth_client.py +0 -0
  12. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_auth_providers.py +0 -0
  13. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_auth_utils.py +0 -0
  14. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_http_client.py +0 -0
  15. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_messaging_encoder.py +0 -0
  16. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_messaging_utils.py +0 -0
  17. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_retry.py +0 -0
  18. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_rfc3339.py +0 -0
  19. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_sseclient.py +0 -0
  20. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_token_gen.py +0 -0
  21. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_user_identifier.py +0 -0
  22. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_user_import.py +0 -0
  23. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_user_mgt.py +0 -0
  24. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_utils.py +0 -0
  25. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/auth.py +0 -0
  26. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/credentials.py +0 -0
  27. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/db.py +0 -0
  28. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/exceptions.py +0 -0
  29. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/firestore.py +0 -0
  30. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/firestore_async.py +0 -0
  31. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/functions.py +0 -0
  32. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/instance_id.py +0 -0
  33. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/ml.py +0 -0
  34. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/phone_number_verification.py +0 -0
  35. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/project_management.py +0 -0
  36. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/remote_config.py +0 -0
  37. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/storage.py +0 -0
  38. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/tenant_mgt.py +0 -0
  39. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/SOURCES.txt +0 -0
  40. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/dependency_links.txt +0 -0
  41. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/requires.txt +0 -0
  42. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/top_level.txt +0 -0
  43. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/setup.cfg +0 -0
  44. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/setup.py +0 -0
  45. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_app.py +0 -0
  46. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_auth_providers.py +0 -0
  47. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_credentials.py +0 -0
  48. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_db.py +0 -0
  49. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_exceptions.py +0 -0
  50. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_firestore.py +0 -0
  51. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_firestore_async.py +0 -0
  52. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_functions.py +0 -0
  53. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_http_client.py +0 -0
  54. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_instance_id.py +0 -0
  55. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_ml.py +0 -0
  56. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_phone_number_verification.py +0 -0
  57. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_project_management.py +0 -0
  58. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_remote_config.py +0 -0
  59. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_retry.py +0 -0
  60. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_rfc3339.py +0 -0
  61. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_sseclient.py +0 -0
  62. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_storage.py +0 -0
  63. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_tenant_mgt.py +0 -0
  64. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_token_gen.py +0 -0
  65. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_user_mgt.py +0 -0
  66. {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/testutils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firebase_admin
3
- Version: 7.6.0
3
+ Version: 7.7.0
4
4
  Summary: Firebase Admin Python SDK
5
5
  Home-page: https://firebase.google.com/docs/admin/setup/
6
6
  Author: Firebase
@@ -14,7 +14,7 @@
14
14
 
15
15
  """About information (version, etc) for Firebase Admin SDK."""
16
16
 
17
- __version__ = '7.6.0'
17
+ __version__ = '7.7.0'
18
18
  __title__ = 'firebase_admin'
19
19
  __author__ = 'Firebase'
20
20
  __license__ = 'Apache License 2.0'
@@ -54,7 +54,7 @@ class _AppCheckService:
54
54
  _APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'
55
55
  _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'
56
56
  _VERIFY_URL_FORMAT = (
57
- 'https://firebaseappcheck.googleapis.com/v1/projects/{project_id}:verifyAppCheckToken'
57
+ 'https://firebaseappcheck.googleapis.com/v1beta/projects/{project_id}:verifyAppCheckToken'
58
58
  )
59
59
  _project_id = None
60
60
  _scoped_project_id = None
@@ -15,14 +15,17 @@
15
15
  """Firebase Cloud Messaging module."""
16
16
 
17
17
  from __future__ import annotations
18
- from typing import Any, Callable, Dict, List, Optional, cast
18
+ import asyncio
19
19
  import concurrent.futures
20
20
  import json
21
- import asyncio
22
21
  import logging
22
+ import re
23
+ from typing import Any, Callable, Dict, List, Optional, Union, cast
24
+ import urllib.parse
23
25
  import warnings
24
- import requests
26
+
25
27
  import httpx
28
+ import requests
26
29
 
27
30
  import firebase_admin
28
31
  from firebase_admin import (
@@ -73,7 +76,11 @@ __all__ = [
73
76
  'send_each_for_multicast',
74
77
  'send_each_for_multicast_async',
75
78
  'subscribe_to_topic',
79
+ 'subscribe_to_topic_async',
80
+ 'subscribe_to_topic_legacy',
76
81
  'unsubscribe_from_topic',
82
+ 'unsubscribe_from_topic_async',
83
+ 'unsubscribe_from_topic_legacy',
77
84
  ]
78
85
 
79
86
 
@@ -252,9 +259,53 @@ def send_each_for_multicast(multicast_message, dry_run=False, app=None):
252
259
  messages = _get_messages_from_multicast(multicast_message)
253
260
  return _get_messaging_service(app).send_each(messages, dry_run)
254
261
 
255
- def subscribe_to_topic(tokens, topic, app=None):
262
+ def subscribe_to_topic(
263
+ tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
264
+ ) -> TopicManagementResponse:
256
265
  """Subscribes a list of registration tokens to an FCM topic.
257
266
 
267
+ Args:
268
+ tokens: A non-empty list of device registration tokens. List may not have more than 1000
269
+ elements.
270
+ topic: Name of the topic to subscribe to. May contain the ``/topics/`` prefix.
271
+ app: An App instance (optional).
272
+
273
+ Returns:
274
+ TopicManagementResponse: A ``TopicManagementResponse`` instance.
275
+
276
+ Raises:
277
+ FirebaseError: If an error occurs while communicating with the FCM service.
278
+ ValueError: If the input arguments are invalid.
279
+ """
280
+ return _get_messaging_service(app).subscribe_to_topic(tokens, topic)
281
+
282
+ async def subscribe_to_topic_async(
283
+ tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
284
+ ) -> TopicManagementResponse:
285
+ """Subscribes a list of registration tokens to an FCM topic asynchronously.
286
+
287
+ Args:
288
+ tokens: A non-empty list of device registration tokens. List may not have more than 1000
289
+ elements.
290
+ topic: Name of the topic to subscribe to. May contain the ``/topics/`` prefix.
291
+ app: An App instance (optional).
292
+
293
+ Returns:
294
+ TopicManagementResponse: A ``TopicManagementResponse`` instance.
295
+
296
+ Raises:
297
+ FirebaseError: If an error occurs while communicating with the FCM service.
298
+ ValueError: If the input arguments are invalid.
299
+ """
300
+ return await _get_messaging_service(app).subscribe_to_topic_async(tokens, topic)
301
+
302
+ def subscribe_to_topic_legacy(
303
+ tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
304
+ ) -> TopicManagementResponse:
305
+ """Subscribes a list of registration tokens to an FCM topic using the legacy Instance ID API.
306
+
307
+ subscribe_to_topic_legacy is deprecated. Use subscribe_to_topic instead.
308
+
258
309
  Args:
259
310
  tokens: A non-empty list of device registration tokens. List may not have more than 1000
260
311
  elements.
@@ -268,12 +319,61 @@ def subscribe_to_topic(tokens, topic, app=None):
268
319
  FirebaseError: If an error occurs while communicating with instance ID service.
269
320
  ValueError: If the input arguments are invalid.
270
321
  """
322
+ warnings.warn(
323
+ 'subscribe_to_topic_legacy is deprecated. Use subscribe_to_topic instead.',
324
+ DeprecationWarning,
325
+ stacklevel=2)
271
326
  return _get_messaging_service(app).make_topic_management_request(
272
327
  tokens, topic, 'iid/v1:batchAdd')
273
328
 
274
- def unsubscribe_from_topic(tokens, topic, app=None):
329
+ def unsubscribe_from_topic(
330
+ tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
331
+ ) -> TopicManagementResponse:
275
332
  """Unsubscribes a list of registration tokens from an FCM topic.
276
333
 
334
+ Args:
335
+ tokens: A non-empty list of device registration tokens. List may not have more than 1000
336
+ elements.
337
+ topic: Name of the topic to unsubscribe from. May contain the ``/topics/`` prefix.
338
+ app: An App instance (optional).
339
+
340
+ Returns:
341
+ TopicManagementResponse: A ``TopicManagementResponse`` instance.
342
+
343
+ Raises:
344
+ FirebaseError: If an error occurs while communicating with the FCM service.
345
+ ValueError: If the input arguments are invalid.
346
+ """
347
+ return _get_messaging_service(app).unsubscribe_from_topic(tokens, topic)
348
+
349
+ async def unsubscribe_from_topic_async(
350
+ tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
351
+ ) -> TopicManagementResponse:
352
+ """Unsubscribes a list of registration tokens from an FCM topic asynchronously.
353
+
354
+ Args:
355
+ tokens: A non-empty list of device registration tokens. List may not have more than 1000
356
+ elements.
357
+ topic: Name of the topic to unsubscribe from. May contain the ``/topics/`` prefix.
358
+ app: An App instance (optional).
359
+
360
+ Returns:
361
+ TopicManagementResponse: A ``TopicManagementResponse`` instance.
362
+
363
+ Raises:
364
+ FirebaseError: If an error occurs while communicating with the FCM service.
365
+ ValueError: If the input arguments are invalid.
366
+ """
367
+ return await _get_messaging_service(app).unsubscribe_from_topic_async(tokens, topic)
368
+
369
+ def unsubscribe_from_topic_legacy(
370
+ tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
371
+ ) -> TopicManagementResponse:
372
+ """Unsubscribes a list of registration tokens from an FCM topic using the legacy
373
+ Instance ID API.
374
+
375
+ unsubscribe_from_topic_legacy is deprecated. Use unsubscribe_from_topic instead.
376
+
277
377
  Args:
278
378
  tokens: A non-empty list of device registration tokens. List may not have more than 1000
279
379
  elements.
@@ -287,6 +387,10 @@ def unsubscribe_from_topic(tokens, topic, app=None):
287
387
  FirebaseError: If an error occurs while communicating with instance ID service.
288
388
  ValueError: If the input arguments are invalid.
289
389
  """
390
+ warnings.warn(
391
+ 'unsubscribe_from_topic_legacy is deprecated. Use unsubscribe_from_topic instead.',
392
+ DeprecationWarning,
393
+ stacklevel=2)
290
394
  return _get_messaging_service(app).make_topic_management_request(
291
395
  tokens, topic, 'iid/v1:batchRemove')
292
396
 
@@ -410,7 +514,9 @@ class _MessagingService:
410
514
  'Project ID is required to access Cloud Messaging service. Either set the '
411
515
  'projectId option, or use service account credentials. Alternatively, set the '
412
516
  'GOOGLE_CLOUD_PROJECT environment variable.')
517
+ self._project_id = project_id
413
518
  self._fcm_url = _MessagingService.FCM_URL.format(project_id)
519
+ self._fcm_topic_url = f'https://fcm.googleapis.com/v1/projects/{project_id}/registrations'
414
520
  self._fcm_headers = {
415
521
  'X-GOOG-API-FORMAT-VERSION': '2',
416
522
  'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}',
@@ -418,6 +524,12 @@ class _MessagingService:
418
524
  timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
419
525
  self._credential = app.credential.get_credential()
420
526
  self._client = _http_client.JsonHttpClient(credential=self._credential, timeout=timeout)
527
+ fcm_adapter = requests.adapters.HTTPAdapter(
528
+ pool_connections=100,
529
+ pool_maxsize=100,
530
+ max_retries=_http_client.DEFAULT_RETRY_CONFIG
531
+ )
532
+ self._client.session.mount('https://fcm.googleapis.com', fcm_adapter)
421
533
  self._async_client = _http_client.HttpxAsyncClient(
422
534
  credential=self._credential, timeout=timeout)
423
535
 
@@ -434,7 +546,7 @@ class _MessagingService:
434
546
  resp = self._client.body(
435
547
  'post',
436
548
  url=self._fcm_url,
437
- headers=self._fcm_headers,
549
+ headers=dict(self._fcm_headers),
438
550
  json=data
439
551
  )
440
552
  except requests.exceptions.RequestException as error:
@@ -453,7 +565,7 @@ class _MessagingService:
453
565
  resp = self._client.body(
454
566
  'post',
455
567
  url=self._fcm_url,
456
- headers=self._fcm_headers,
568
+ headers=dict(self._fcm_headers),
457
569
  json=data)
458
570
  except requests.exceptions.RequestException as exception:
459
571
  return SendResponse(resp=None, exception=self._handle_fcm_error(exception))
@@ -481,7 +593,7 @@ class _MessagingService:
481
593
  resp = await self._async_client.request(
482
594
  'post',
483
595
  url=self._fcm_url,
484
- headers=self._fcm_headers,
596
+ headers=dict(self._fcm_headers),
485
597
  json=data)
486
598
  except httpx.HTTPError as exception:
487
599
  return SendResponse(resp=None, exception=self._handle_fcm_httpx_error(exception))
@@ -499,6 +611,225 @@ class _MessagingService:
499
611
  message=f'Unknown error while making remote service calls: {error}',
500
612
  cause=error)
501
613
 
614
+ def _validate_topic_management_args(self, tokens, topic):
615
+ """Validates and formats topic management arguments."""
616
+ if isinstance(tokens, str):
617
+ tokens = [tokens]
618
+ if not isinstance(tokens, list) or not tokens:
619
+ raise ValueError('Tokens must be a string or a non-empty list of strings.')
620
+ invalid_str = [t for t in tokens if not isinstance(t, str) or not t]
621
+ if invalid_str:
622
+ raise ValueError('Tokens must be non-empty strings.')
623
+ if len(tokens) > 1000:
624
+ raise ValueError('tokens must not contain more than 1000 elements.')
625
+
626
+ if not isinstance(topic, str) or not topic:
627
+ raise ValueError('Topic must be a non-empty string.')
628
+ topic_name = topic
629
+ if topic_name.startswith('/topics/'):
630
+ topic_name = topic_name[len('/topics/'):]
631
+ if not topic_name or not re.match(r'^[a-zA-Z0-9-_\.~%]+\Z', topic_name):
632
+ raise ValueError('Malformed topic name.')
633
+
634
+ return tokens, topic_name
635
+
636
+ def subscribe_to_topic(
637
+ self, tokens: Union[str, List[str]], topic: str
638
+ ) -> TopicManagementResponse:
639
+ """Subscribes a list of registration tokens to an FCM topic via the FCM v1 API."""
640
+ return self._make_topic_management_request_v1(tokens, topic, is_subscribe=True)
641
+
642
+ def unsubscribe_from_topic(
643
+ self, tokens: Union[str, List[str]], topic: str
644
+ ) -> TopicManagementResponse:
645
+ """Unsubscribes a list of registration tokens from an FCM topic via the FCM v1 API."""
646
+ return self._make_topic_management_request_v1(tokens, topic, is_subscribe=False)
647
+
648
+ async def subscribe_to_topic_async(
649
+ self, tokens: Union[str, List[str]], topic: str
650
+ ) -> TopicManagementResponse:
651
+ """Subscribes a list of registration tokens to an FCM topic asynchronously
652
+ via the FCM v1 API."""
653
+ return await self._make_topic_management_request_v1_async(
654
+ tokens, topic, is_subscribe=True)
655
+
656
+ async def unsubscribe_from_topic_async(
657
+ self, tokens: Union[str, List[str]], topic: str
658
+ ) -> TopicManagementResponse:
659
+ """Unsubscribes a list of registration tokens from an FCM topic asynchronously
660
+ via the FCM v1 API."""
661
+ return await self._make_topic_management_request_v1_async(
662
+ tokens, topic, is_subscribe=False)
663
+
664
+ def _make_topic_management_request_v1(
665
+ self, tokens, topic, is_subscribe: bool
666
+ ) -> TopicManagementResponse:
667
+ """Helper method that sends topic subscription requests via FCM v1 API."""
668
+ tokens_list, topic_name = self._validate_topic_management_args(tokens, topic)
669
+ encoded_topic = urllib.parse.quote(topic_name, safe='')
670
+
671
+ def send_request(token: str):
672
+ encoded_token = urllib.parse.quote(token, safe='')
673
+ base_url = f'{self._fcm_topic_url}/{encoded_token}/topicSubscriptions'
674
+ if is_subscribe:
675
+ url = f'{base_url}?topic_name={encoded_topic}'
676
+ method = 'post'
677
+ json_data = {}
678
+ else:
679
+ url = f'{base_url}/{encoded_topic}?allow_missing=true'
680
+ method = 'delete'
681
+ json_data = None
682
+
683
+ try:
684
+ self._client.request(
685
+ method,
686
+ url=url,
687
+ headers=dict(self._fcm_headers),
688
+ json=json_data,
689
+ )
690
+ return {'success': True}
691
+ except requests.exceptions.RequestException as error:
692
+ return self._build_topic_subscription_result_from_requests_error(
693
+ error, is_subscribe)
694
+
695
+ try:
696
+ with concurrent.futures.ThreadPoolExecutor(
697
+ max_workers=min(len(tokens_list), 100)
698
+ ) as executor:
699
+ results = list(executor.map(send_request, tokens_list))
700
+ return self._parse_topic_management_results(results)
701
+ except Exception as error:
702
+ raise exceptions.UnknownError(
703
+ message=f'Unknown error while making remote service calls: {error}',
704
+ cause=error)
705
+
706
+ async def _make_topic_management_request_v1_async(
707
+ self, tokens, topic, is_subscribe: bool
708
+ ) -> TopicManagementResponse:
709
+ """Helper method that sends topic subscription requests asynchronously via FCM v1 API."""
710
+ tokens_list, topic_name = self._validate_topic_management_args(tokens, topic)
711
+ encoded_topic = urllib.parse.quote(topic_name, safe='')
712
+ semaphore = asyncio.Semaphore(100)
713
+
714
+ async def send_request_async(token: str):
715
+ encoded_token = urllib.parse.quote(token, safe='')
716
+ base_url = f'{self._fcm_topic_url}/{encoded_token}/topicSubscriptions'
717
+ if is_subscribe:
718
+ url = f'{base_url}?topic_name={encoded_topic}'
719
+ method = 'post'
720
+ json_data = {}
721
+ else:
722
+ url = f'{base_url}/{encoded_topic}?allow_missing=true'
723
+ method = 'delete'
724
+ json_data = None
725
+
726
+ async with semaphore:
727
+ try:
728
+ await self._async_client.request(
729
+ method,
730
+ url=url,
731
+ headers=dict(self._fcm_headers),
732
+ json=json_data,
733
+ )
734
+ return {'success': True}
735
+ except httpx.HTTPError as error:
736
+ return self._build_topic_subscription_result_from_httpx_error(
737
+ error, is_subscribe)
738
+ except requests.exceptions.RequestException as error:
739
+ return self._build_topic_subscription_result_from_requests_error(
740
+ error, is_subscribe)
741
+
742
+ try:
743
+ results = await asyncio.gather(*[send_request_async(token) for token in tokens_list])
744
+ return self._parse_topic_management_results(results)
745
+ except Exception as error:
746
+ raise exceptions.UnknownError(
747
+ message=f'Unknown error while making remote service calls: {error}',
748
+ cause=error)
749
+
750
+ @classmethod
751
+ def _get_topic_error_code(cls, error_dict: dict, status_code: int) -> str:
752
+ """Extracts the error code for a topic subscription error response."""
753
+ error_data = error_dict.get('error')
754
+ if isinstance(error_data, str) and error_data:
755
+ return error_data
756
+ if isinstance(error_data, dict):
757
+ details = error_data.get('details')
758
+ if isinstance(details, list):
759
+ fcm_error_type = 'type.googleapis.com/google.firebase.fcm.v1.FcmError'
760
+ for element in details:
761
+ if isinstance(element, dict) and element.get('@type') == fcm_error_type:
762
+ code = element.get('errorCode')
763
+ if code:
764
+ return code
765
+ status = error_data.get('status')
766
+ if status:
767
+ return status
768
+
769
+ status_map = {
770
+ 400: 'INVALID_ARGUMENT',
771
+ 401: 'UNAUTHENTICATED',
772
+ 403: 'PERMISSION_DENIED',
773
+ 404: 'NOT_FOUND',
774
+ 408: 'DEADLINE_EXCEEDED',
775
+ 429: 'RESOURCE_EXHAUSTED',
776
+ 500: 'INTERNAL',
777
+ 503: 'UNAVAILABLE',
778
+ 504: 'DEADLINE_EXCEEDED',
779
+ }
780
+ if status_code in status_map:
781
+ return status_map[status_code]
782
+
783
+ if isinstance(error_data, dict):
784
+ message = error_data.get('message')
785
+ if message:
786
+ return message
787
+
788
+ return 'UNKNOWN_ERROR'
789
+
790
+ def _build_topic_subscription_result(self, response, is_subscribe):
791
+ """Constructs a result dict from a response object."""
792
+ if response is not None:
793
+ if is_subscribe and response.status_code == 409:
794
+ return {'success': True}
795
+ error_dict = {}
796
+ try:
797
+ parsed = response.json()
798
+ if isinstance(parsed, dict):
799
+ error_dict = parsed
800
+ except ValueError:
801
+ pass
802
+
803
+ error_data = error_dict.get('error')
804
+ if is_subscribe and isinstance(error_data, dict) and (
805
+ error_data.get('status') == 'ALREADY_EXISTS'
806
+ ):
807
+ return {'success': True}
808
+
809
+ error_code = self._get_topic_error_code(error_dict, response.status_code)
810
+ return {'success': False, 'error': error_code}
811
+
812
+ return {'success': False, 'error': 'UNKNOWN_ERROR'}
813
+
814
+ def _build_topic_subscription_result_from_requests_error(self, error, is_subscribe):
815
+ """Constructs a result dict from a requests error."""
816
+ return self._build_topic_subscription_result(error.response, is_subscribe)
817
+
818
+ def _build_topic_subscription_result_from_httpx_error(self, error, is_subscribe):
819
+ """Constructs a result dict from an httpx error."""
820
+ response = error.response if isinstance(error, httpx.HTTPStatusError) else None
821
+ return self._build_topic_subscription_result(response, is_subscribe)
822
+
823
+ def _parse_topic_management_results(self, results) -> TopicManagementResponse:
824
+ """Parses individual request results into a TopicManagementResponse."""
825
+ formatted_results = []
826
+ for result in results:
827
+ if result.get('success'):
828
+ formatted_results.append({})
829
+ else:
830
+ formatted_results.append({'error': result.get('error', 'UNKNOWN_ERROR')})
831
+ return TopicManagementResponse({'results': formatted_results})
832
+
502
833
  def make_topic_management_request(self, tokens, topic, operation):
503
834
  """Invokes the IID service for topic management functionality."""
504
835
  if isinstance(tokens, str):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firebase_admin
3
- Version: 7.6.0
3
+ Version: 7.7.0
4
4
  Summary: Firebase Admin Python SDK
5
5
  Home-page: https://firebase.google.com/docs/admin/setup/
6
6
  Author: Firebase
@@ -319,7 +319,8 @@ class TestVerifyToken(TestBatch):
319
319
  assert payload == expected
320
320
 
321
321
  expected_url = (
322
- f"https://firebaseappcheck.googleapis.com/v1/projects/{PROJECT_ID}:verifyAppCheckToken"
322
+ f"https://firebaseappcheck.googleapis.com/v1beta/projects/{PROJECT_ID}"
323
+ ":verifyAppCheckToken"
323
324
  )
324
325
  mock_body.assert_called_once_with(
325
326
  "post", expected_url, json={"app_check_token": "encoded"}
@@ -340,7 +341,8 @@ class TestVerifyToken(TestBatch):
340
341
  assert payload == expected
341
342
 
342
343
  expected_url = (
343
- f"https://firebaseappcheck.googleapis.com/v1/projects/{PROJECT_ID}:verifyAppCheckToken"
344
+ f"https://firebaseappcheck.googleapis.com/v1beta/projects/{PROJECT_ID}"
345
+ ":verifyAppCheckToken"
344
346
  )
345
347
  mock_body.assert_called_once_with(
346
348
  "post", expected_url, json={"app_check_token": "encoded"}