Skip to content

Commit 59426aa

Browse files
Merge pull request #490 from reef-technologies/notifications
Event Notifications API support
2 parents 08cd487 + 3d47ee1 commit 59426aa

17 files changed

+671
-106
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: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@
1010
from __future__ import annotations
1111

1212
import base64
13+
import functools
14+
import warnings
1315
from abc import ABCMeta, abstractmethod
1416
from enum import Enum, unique
1517
from logging import getLogger
16-
from typing import Any
18+
from typing import Any, Iterable
1719

1820
from .utils.escape import unprintable_to_hex
1921
from .utils.typing import JSON
22+
from .version_utils import FeaturePreviewWarning
2023

2124
try:
22-
from typing_extensions import NotRequired, TypedDict
25+
from typing_extensions import Literal, NotRequired, TypedDict
2326
except ImportError:
24-
from typing import NotRequired, TypedDict
27+
from typing import Literal, NotRequired, TypedDict
2528

2629
from .encryption.setting import EncryptionMode, EncryptionSetting
2730
from .exception import (
@@ -73,6 +76,8 @@
7376
'shareFiles',
7477
'writeFiles',
7578
'deleteFiles',
79+
'readBucketNotifications',
80+
'writeBucketNotifications',
7681
]
7782

7883
# API version number to use when calling the service
@@ -102,6 +107,82 @@ class LifecycleRule(TypedDict):
102107
daysFromUploadingToHiding: NotRequired[int | None]
103108

104109

110+
class NameValueDict(TypedDict):
111+
name: str
112+
value: str
113+
114+
115+
class NotificationTargetConfiguration(TypedDict):
116+
"""
117+
Notification Target Configuration.
118+
119+
`hmacSha256SigningSecret`, if present, has to be a string of 32 alphanumeric characters.
120+
"""
121+
# TODO: add URL to the documentation
122+
123+
targetType: Literal['webhook']
124+
url: str
125+
customHeaders: NotRequired[list[NameValueDict] | None]
126+
hmacSha256SigningSecret: NotRequired[str]
127+
128+
129+
EVENT_TYPE = Literal[
130+
'b2:ObjectCreated:*', 'b2:ObjectCreated:Upload', 'b2:ObjectCreated:MultipartUpload',
131+
'b2:ObjectCreated:Copy', 'b2:ObjectCreated:Replica', 'b2:ObjectCreated:MultipartReplica',
132+
'b2:ObjectDeleted:*', 'b2:ObjectDeleted:Delete', 'b2:ObjectDeleted:LifecycleRule',
133+
'b2:HideMarkerCreated:*', 'b2:HideMarkerCreated:Hide', 'b2:HideMarkerCreated:LifecycleRule',]
134+
135+
136+
class _NotificationRule(TypedDict):
137+
"""
138+
Notification Rule.
139+
"""
140+
eventTypes: list[EVENT_TYPE]
141+
isEnabled: bool
142+
name: str
143+
objectNamePrefix: str
144+
targetConfiguration: NotificationTargetConfiguration
145+
suspensionReason: NotRequired[str]
146+
147+
148+
class NotificationRule(_NotificationRule):
149+
"""
150+
Notification Rule.
151+
152+
When creating or modifying a notification rule, `isSuspended` and `suspensionReason` are ignored.
153+
"""
154+
isSuspended: NotRequired[bool]
155+
156+
157+
class NotificationRuleResponse(_NotificationRule):
158+
isSuspended: bool
159+
160+
161+
def _bucket_notification_rule_feature_preview_warning(func):
162+
@functools.wraps(func)
163+
def wrapper(*args, **kwargs):
164+
warnings.warn(
165+
"Event Notifications feature is in \"Private Preview\" state and may change without notice. "
166+
"See https://www.backblaze.com/blog/announcing-event-notifications/ for details.",
167+
FeaturePreviewWarning,
168+
stacklevel=2,
169+
)
170+
return func(*args, **kwargs)
171+
172+
return wrapper
173+
174+
175+
@_bucket_notification_rule_feature_preview_warning
176+
def notification_rule_response_to_request(rule: NotificationRuleResponse) -> NotificationRule:
177+
"""
178+
Convert NotificationRuleResponse to NotificationRule.
179+
"""
180+
rule = rule.copy()
181+
for key in ('isSuspended', 'suspensionReason'):
182+
rule.pop(key, None)
183+
return rule
184+
185+
105186
class AbstractRawApi(metaclass=ABCMeta):
106187
"""
107188
Direct access to the B2 web apis.
@@ -415,6 +496,18 @@ def get_download_url_by_id(self, download_url, file_id):
415496
def get_download_url_by_name(self, download_url, bucket_name, file_name):
416497
return download_url + '/file/' + bucket_name + '/' + b2_url_encode(file_name)
417498

499+
@abstractmethod
500+
def set_bucket_notification_rules(
501+
self, api_url: str, account_auth_token: str, bucket_id: str,
502+
rules: Iterable[NotificationRule]
503+
) -> list[NotificationRuleResponse]:
504+
pass
505+
506+
@abstractmethod
507+
def get_bucket_notification_rules(self, api_url: str, account_auth_token: str,
508+
bucket_id: str) -> list[NotificationRuleResponse]:
509+
pass
510+
418511

419512
class B2RawHTTPApi(AbstractRawApi):
420513
"""
@@ -1088,6 +1181,32 @@ def copy_part(
10881181
except AccessDenied:
10891182
raise SSECKeyError()
10901183

1184+
@_bucket_notification_rule_feature_preview_warning
1185+
def set_bucket_notification_rules(
1186+
self, api_url: str, account_auth_token: str, bucket_id: str, rules: list[NotificationRule]
1187+
) -> list[NotificationRuleResponse]:
1188+
return self._post_json(
1189+
api_url,
1190+
'b2_set_bucket_notification_rules',
1191+
account_auth_token,
1192+
**{
1193+
'bucketId': bucket_id,
1194+
'eventNotificationRules': rules,
1195+
},
1196+
)["eventNotificationRules"]
1197+
1198+
@_bucket_notification_rule_feature_preview_warning
1199+
def get_bucket_notification_rules(self, api_url: str, account_auth_token: str,
1200+
bucket_id: str) -> list[NotificationRuleResponse]:
1201+
return self._get_json(
1202+
api_url,
1203+
'b2_get_bucket_notification_rules',
1204+
account_auth_token,
1205+
**{
1206+
'bucketId': bucket_id,
1207+
},
1208+
)["eventNotificationRules"]
1209+
10911210

10921211
def _add_range_header(headers, range_):
10931212
if range_ is not None:

b2sdk/_internal/raw_simulator.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import threading
1919
import time
2020
from contextlib import contextmanager, suppress
21+
from typing import Iterable
2122

2223
from requests.structures import CaseInsensitiveDict
2324

@@ -40,6 +41,7 @@
4041
MissingPart,
4142
NonExistentBucket,
4243
PartSha1Mismatch,
44+
ResourceNotFound,
4345
SourceReplicationConflict,
4446
SSECKeyError,
4547
Unauthorized,
@@ -54,7 +56,14 @@
5456
)
5557
from .file_version import UNVERIFIED_CHECKSUM_PREFIX
5658
from .http_constants import FILE_INFO_HEADER_PREFIX, HEX_DIGITS_AT_END
57-
from .raw_api import ALL_CAPABILITIES, AbstractRawApi, LifecycleRule, MetadataDirectiveMode
59+
from .raw_api import (
60+
ALL_CAPABILITIES,
61+
AbstractRawApi,
62+
LifecycleRule,
63+
MetadataDirectiveMode,
64+
NotificationRule,
65+
NotificationRuleResponse,
66+
)
5867
from .replication.setting import ReplicationConfiguration
5968
from .replication.types import ReplicationStatus
6069
from .stream.hashing import StreamWithHash
@@ -542,6 +551,7 @@ def __init__(
542551
self.bucket_info = bucket_info or {}
543552
self.cors_rules = cors_rules or []
544553
self.lifecycle_rules = lifecycle_rules or []
554+
self._notification_rules = []
545555
self.options_set = options_set or set()
546556
self.revision = 1
547557
self.upload_url_counter = iter(range(200))
@@ -1160,6 +1170,44 @@ def _chunks_number(self, content_length, chunk_size):
11601170
def _next_file_id(self):
11611171
return str(next(self.file_id_counter))
11621172

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

11641212
class RawSimulator(AbstractRawApi):
11651213
"""
@@ -1235,6 +1283,8 @@ def expire_auth_token(self, auth_token):
12351283

12361284
def create_account(self):
12371285
"""
1286+
Simulate creating an account.
1287+
12381288
Return (accountId, masterApplicationKey) for a newly created account.
12391289
"""
12401290
# Pick the IDs for the account and the key
@@ -1973,3 +2023,21 @@ def _get_bucket_by_name(self, bucket_name):
19732023
if bucket_name not in self.bucket_name_to_bucket:
19742024
raise NonExistentBucket(bucket_name)
19752025
return self.bucket_name_to_bucket[bucket_name]
2026+
2027+
def set_bucket_notification_rules(
2028+
self, api_url: str, account_auth_token: str, bucket_id: str,
2029+
rules: Iterable[NotificationRule]
2030+
):
2031+
bucket = self._get_bucket_by_id(bucket_id)
2032+
self._assert_account_auth(
2033+
api_url, account_auth_token, bucket.account_id, 'writeBucketNotifications'
2034+
)
2035+
return bucket.set_notification_rules(rules)
2036+
2037+
def get_bucket_notification_rules(self, api_url: str, account_auth_token: str,
2038+
bucket_id: str) -> list[NotificationRule]:
2039+
bucket = self._get_bucket_by_id(bucket_id)
2040+
self._assert_account_auth(
2041+
api_url, account_auth_token, bucket.account_id, 'readBucketNotifications'
2042+
)
2043+
return bucket.get_notification_rules()

0 commit comments

Comments
 (0)