Skip to content

Commit aaf8569

Browse files
add notification rules support
1 parent 71d01f2 commit aaf8569

File tree

12 files changed

+414
-37
lines changed

12 files changed

+414
-37
lines changed

b2sdk/_internal/bucket.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import logging
1515
import pathlib
1616
from contextlib import suppress
17-
from typing import Sequence
17+
from typing import Iterable, Sequence
1818

1919
from .encryption.setting import EncryptionSetting, EncryptionSettingFactory
2020
from .encryption.types import EncryptionMode
@@ -37,7 +37,7 @@
3737
from .filter import Filter, FilterMatcher
3838
from .http_constants import LIST_FILE_NAMES_MAX_LIMIT
3939
from .progress import AbstractProgressListener, DoNothingProgressListener
40-
from .raw_api import LifecycleRule
40+
from .raw_api import LifecycleRule, NotificationRule, NotificationRuleResponse
4141
from .replication.setting import ReplicationConfiguration, ReplicationConfigurationFactory
4242
from .transfer.emerge.executor import AUTO_CONTENT_TYPE
4343
from .transfer.emerge.unbound_write_intent import UnboundWriteIntentGenerator
@@ -1492,6 +1492,19 @@ def as_dict(self):
14921492
def __repr__(self):
14931493
return f'Bucket<{self.id_},{self.name},{self.type_}>'
14941494

1495+
def get_notification_rules(self) -> list[NotificationRuleResponse]:
1496+
"""
1497+
Get all notification rules for this bucket.
1498+
"""
1499+
return self.api.session.get_bucket_notification_rules(self.id_)
1500+
1501+
def set_notification_rules(self,
1502+
rules: Iterable[NotificationRule]) -> list[NotificationRuleResponse]:
1503+
"""
1504+
Set notification rules for this bucket.
1505+
"""
1506+
return self.api.session.set_bucket_notification_rules(self.id_, rules)
1507+
14951508

14961509
class BucketFactory:
14971510
"""

b2sdk/_internal/exception.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import logging
1313
import re
14+
import typing
1415
import warnings
1516
from abc import ABCMeta
1617
from typing import Any
@@ -574,6 +575,47 @@ class DestinationDirectoryDoesntAllowOperation(DestinationDirectoryError):
574575
pass
575576

576577

578+
class EventTypeError(BadRequest):
579+
pass
580+
581+
582+
class EventTypeCategoriesError(EventTypeError):
583+
pass
584+
585+
586+
class EventTypeOverlapError(EventTypeError):
587+
pass
588+
589+
590+
class EventTypesEmptyError(EventTypeError):
591+
pass
592+
593+
594+
class EventTypeInvalidError(EventTypeError):
595+
pass
596+
597+
598+
def _event_type_invalid_error(code: str, message: str, **_) -> B2Error:
599+
from b2sdk._internal.raw_api import EVENT_TYPE
600+
601+
valid_types = sorted(typing.get_args(EVENT_TYPE))
602+
return EventTypeInvalidError(
603+
f"Event Type error: {message!r}. Valid types: {sorted(valid_types)!r}", code
604+
)
605+
606+
607+
_error_handlers: dict[tuple[int, str | None], typing.Callable] = {
608+
(400, "event_type_categories"):
609+
lambda code, message, **_: EventTypeCategoriesError(message, code),
610+
(400, "event_type_overlap"):
611+
lambda code, message, **_: EventTypeOverlapError(message, code),
612+
(400, "event_types_empty"):
613+
lambda code, message, **_: EventTypesEmptyError(message, code),
614+
(400, "event_type_invalid"):
615+
_event_type_invalid_error,
616+
}
617+
618+
577619
@trace_call(logger)
578620
def interpret_b2_error(
579621
status: int,
@@ -583,6 +625,19 @@ def interpret_b2_error(
583625
post_params: dict[str, Any] | None = None
584626
) -> B2Error:
585627
post_params = post_params or {}
628+
629+
handler = _error_handlers.get((status, code))
630+
if handler:
631+
error = handler(
632+
status=status,
633+
code=code,
634+
message=message,
635+
response_headers=response_headers,
636+
post_params=post_params
637+
)
638+
if error:
639+
return error
640+
586641
if status == 400 and code == "already_hidden":
587642
return FileAlreadyHidden(post_params.get('fileName'))
588643
elif status == 400 and code == 'bad_json':

b2sdk/_internal/raw_api.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
from abc import ABCMeta, abstractmethod
1414
from enum import Enum, unique
1515
from logging import getLogger
16-
from typing import Any
16+
from typing import Any, Iterable
1717

1818
from .utils.escape import unprintable_to_hex
1919
from .utils.typing import JSON
2020

2121
try:
22-
from typing_extensions import NotRequired, TypedDict
22+
from typing_extensions import Literal, NotRequired, TypedDict
2323
except ImportError:
24-
from typing import NotRequired, TypedDict
24+
from typing import Literal, NotRequired, TypedDict
2525

2626
from .encryption.setting import EncryptionMode, EncryptionSetting
2727
from .exception import (
@@ -73,6 +73,8 @@
7373
'shareFiles',
7474
'writeFiles',
7575
'deleteFiles',
76+
'readBucketNotifications',
77+
'writeBucketNotifications',
7678
]
7779

7880
# API version number to use when calling the service
@@ -102,6 +104,67 @@ class LifecycleRule(TypedDict):
102104
daysFromUploadingToHiding: NotRequired[int | None]
103105

104106

107+
class NameValueDict(TypedDict):
108+
name: str
109+
value: str
110+
111+
112+
class NotificationTargetConfiguration(TypedDict):
113+
"""
114+
Notification Target Configuration.
115+
116+
`hmacSha256SigningSecret`, if present, has to be a string of 32 alphanumeric characters.
117+
"""
118+
# TODO: add URL to the documentation
119+
120+
targetType: Literal['webhook']
121+
url: str
122+
customHeaders: NotRequired[list[NameValueDict] | None]
123+
hmacSha256SigningSecret: NotRequired[str]
124+
125+
126+
EVENT_TYPE = Literal[
127+
'b2:ObjectCreated:*', 'b2:ObjectCreated:Upload', 'b2:ObjectCreated:MultipartUpload',
128+
'b2:ObjectCreated:Copy', 'b2:ObjectCreated:Replica', 'b2:ObjectCreated:MultipartReplica',
129+
'b2:ObjectDeleted:*', 'b2:ObjectDeleted:Delete', 'b2:ObjectDeleted:LifecycleRule',
130+
'b2:HideMarkerCreated:*', 'b2:HideMarkerCreated:Hide', 'b2:HideMarkerCreated:LifecycleRule',]
131+
132+
133+
class _NotificationRule(TypedDict):
134+
"""
135+
Notification Rule.
136+
"""
137+
eventTypes: list[EVENT_TYPE]
138+
isEnabled: bool
139+
name: str
140+
objectNamePrefix: str
141+
targetConfiguration: NotificationTargetConfiguration
142+
suspensionReason: NotRequired[str]
143+
144+
145+
class NotificationRule(_NotificationRule):
146+
"""
147+
Notification Rule.
148+
149+
When creating or modifying a notification rule, `isSuspended` and `suspensionReason` are ignored.
150+
"""
151+
isSuspended: NotRequired[bool]
152+
153+
154+
class NotificationRuleResponse(_NotificationRule):
155+
isSuspended: bool
156+
157+
158+
def notification_rule_response_to_request(rule: NotificationRuleResponse) -> NotificationRule:
159+
"""
160+
Convert NotificationRuleResponse to NotificationRule.
161+
"""
162+
rule = rule.copy()
163+
for key in ('isSuspended', 'suspensionReason'):
164+
rule.pop(key, None)
165+
return rule
166+
167+
105168
class AbstractRawApi(metaclass=ABCMeta):
106169
"""
107170
Direct access to the B2 web apis.
@@ -415,6 +478,18 @@ def get_download_url_by_id(self, download_url, file_id):
415478
def get_download_url_by_name(self, download_url, bucket_name, file_name):
416479
return download_url + '/file/' + bucket_name + '/' + b2_url_encode(file_name)
417480

481+
@abstractmethod
482+
def set_bucket_notification_rules(
483+
self, api_url: str, account_auth_token: str, bucket_id: str,
484+
rules: Iterable[NotificationRule]
485+
) -> list[NotificationRuleResponse]:
486+
pass
487+
488+
@abstractmethod
489+
def get_bucket_notification_rules(self, api_url: str, account_auth_token: str,
490+
bucket_id: str) -> list[NotificationRuleResponse]:
491+
pass
492+
418493

419494
class B2RawHTTPApi(AbstractRawApi):
420495
"""
@@ -1088,6 +1163,30 @@ def copy_part(
10881163
except AccessDenied:
10891164
raise SSECKeyError()
10901165

1166+
def set_bucket_notification_rules(
1167+
self, api_url: str, account_auth_token: str, bucket_id: str, rules: list[NotificationRule]
1168+
) -> list[NotificationRuleResponse]:
1169+
return self._post_json(
1170+
api_url,
1171+
'b2_set_bucket_notification_rules',
1172+
account_auth_token,
1173+
**{
1174+
'bucketId': bucket_id,
1175+
'eventNotificationRules': rules,
1176+
},
1177+
)["eventNotificationRules"]
1178+
1179+
def get_bucket_notification_rules(self, api_url: str, account_auth_token: str,
1180+
bucket_id: str) -> list[NotificationRuleResponse]:
1181+
return self._get_json(
1182+
api_url,
1183+
'b2_get_bucket_notification_rules',
1184+
account_auth_token,
1185+
**{
1186+
'bucketId': bucket_id,
1187+
},
1188+
)["eventNotificationRules"]
1189+
10911190

10921191
def _add_range_header(headers, range_):
10931192
if range_ is not None:

b2sdk/_internal/raw_simulator.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
import logging
1616
import random
1717
import re
18+
import secrets
1819
import threading
1920
import time
2021
from contextlib import contextmanager, suppress
22+
from typing import Iterable
2123

2224
from requests.structures import CaseInsensitiveDict
2325

@@ -40,6 +42,7 @@
4042
MissingPart,
4143
NonExistentBucket,
4244
PartSha1Mismatch,
45+
ResourceNotFound,
4346
SourceReplicationConflict,
4447
SSECKeyError,
4548
Unauthorized,
@@ -54,7 +57,14 @@
5457
)
5558
from .file_version import UNVERIFIED_CHECKSUM_PREFIX
5659
from .http_constants import FILE_INFO_HEADER_PREFIX, HEX_DIGITS_AT_END
57-
from .raw_api import ALL_CAPABILITIES, AbstractRawApi, LifecycleRule, MetadataDirectiveMode
60+
from .raw_api import (
61+
ALL_CAPABILITIES,
62+
AbstractRawApi,
63+
LifecycleRule,
64+
MetadataDirectiveMode,
65+
NotificationRule,
66+
NotificationRuleResponse,
67+
)
5868
from .replication.setting import ReplicationConfiguration
5969
from .replication.types import ReplicationStatus
6070
from .stream.hashing import StreamWithHash
@@ -542,6 +552,7 @@ def __init__(
542552
self.bucket_info = bucket_info or {}
543553
self.cors_rules = cors_rules or []
544554
self.lifecycle_rules = lifecycle_rules or []
555+
self._notification_rules = []
545556
self.options_set = options_set or set()
546557
self.revision = 1
547558
self.upload_url_counter = iter(range(200))
@@ -1160,6 +1171,44 @@ def _chunks_number(self, content_length, chunk_size):
11601171
def _next_file_id(self):
11611172
return str(next(self.file_id_counter))
11621173

1174+
def get_notification_rules(self) -> list[NotificationRule]:
1175+
return self._notification_rules
1176+
1177+
def set_notification_rules(self,
1178+
rules: Iterable[NotificationRule]) -> list[NotificationRuleResponse]:
1179+
old_rules_by_name = {rule["name"]: rule for rule in self._notification_rules}
1180+
new_rules: list[NotificationRuleResponse] = []
1181+
for rule in rules:
1182+
for field in ("isSuspended", "suspensionReason"):
1183+
rule.pop(field, None)
1184+
old_rule = old_rules_by_name.get(rule["name"], {"targetConfiguration": {}})
1185+
new_rule = {
1186+
**{
1187+
"isSuspended": False,
1188+
"suspensionReason": "",
1189+
},
1190+
**old_rule,
1191+
**rule,
1192+
"targetConfiguration":
1193+
{
1194+
**old_rule.get("targetConfiguration", {}),
1195+
**rule.get("targetConfiguration", {}),
1196+
},
1197+
}
1198+
new_rules.append(new_rule)
1199+
self._notification_rules = new_rules
1200+
return self._notification_rules
1201+
1202+
def simulate_notification_rule_suspension(
1203+
self, rule_name: str, reason: str, is_suspended: bool | None = None
1204+
) -> None:
1205+
for rule in self._notification_rules:
1206+
if rule["name"] == rule_name:
1207+
rule["isSuspended"] = bool(reason) if is_suspended is None else is_suspended
1208+
rule["suspensionReason"] = reason
1209+
return
1210+
raise ResourceNotFound(f"Rule {rule_name} not found")
1211+
11631212

11641213
class RawSimulator(AbstractRawApi):
11651214
"""
@@ -1235,6 +1284,8 @@ def expire_auth_token(self, auth_token):
12351284

12361285
def create_account(self):
12371286
"""
1287+
Simulate creating an account.
1288+
12381289
Return (accountId, masterApplicationKey) for a newly created account.
12391290
"""
12401291
# Pick the IDs for the account and the key
@@ -1973,3 +2024,21 @@ def _get_bucket_by_name(self, bucket_name):
19732024
if bucket_name not in self.bucket_name_to_bucket:
19742025
raise NonExistentBucket(bucket_name)
19752026
return self.bucket_name_to_bucket[bucket_name]
2027+
2028+
def set_bucket_notification_rules(
2029+
self, api_url: str, account_auth_token: str, bucket_id: str,
2030+
rules: Iterable[NotificationRule]
2031+
):
2032+
bucket = self._get_bucket_by_id(bucket_id)
2033+
self._assert_account_auth(
2034+
api_url, account_auth_token, bucket.account_id, 'writeBucketNotifications'
2035+
)
2036+
return bucket.set_notification_rules(rules)
2037+
2038+
def get_bucket_notification_rules(self, api_url: str, account_auth_token: str,
2039+
bucket_id: str) -> list[NotificationRule]:
2040+
bucket = self._get_bucket_by_id(bucket_id)
2041+
self._assert_account_auth(
2042+
api_url, account_auth_token, bucket.account_id, 'readBucketNotifications'
2043+
)
2044+
return bucket.get_notification_rules()

b2sdk/_internal/session.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,11 @@ def update_file_legal_hold(
572572
file_name,
573573
legal_hold,
574574
)
575+
576+
def get_bucket_notification_rules(self, bucket_id):
577+
return self._wrap_default_token(self.raw_api.get_bucket_notification_rules, bucket_id)
578+
579+
def set_bucket_notification_rules(self, bucket_id, rules):
580+
return self._wrap_default_token(
581+
self.raw_api.set_bucket_notification_rules, bucket_id, rules
582+
)

0 commit comments

Comments
 (0)