mobiscroll-connect-sdk 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.
- mobiscroll_connect/__init__.py +68 -0
- mobiscroll_connect/_internal/__init__.py +0 -0
- mobiscroll_connect/_internal/errors.py +58 -0
- mobiscroll_connect/_internal/payloads.py +100 -0
- mobiscroll_connect/aio/__init__.py +11 -0
- mobiscroll_connect/aio/client.py +69 -0
- mobiscroll_connect/aio/resources.py +185 -0
- mobiscroll_connect/api_client.py +224 -0
- mobiscroll_connect/async_api_client.py +212 -0
- mobiscroll_connect/client.py +78 -0
- mobiscroll_connect/config.py +25 -0
- mobiscroll_connect/exceptions.py +91 -0
- mobiscroll_connect/models.py +246 -0
- mobiscroll_connect/py.typed +0 -0
- mobiscroll_connect/resources/__init__.py +5 -0
- mobiscroll_connect/resources/auth.py +109 -0
- mobiscroll_connect/resources/calendars.py +20 -0
- mobiscroll_connect/resources/events.py +106 -0
- mobiscroll_connect_sdk-1.0.1.dist-info/METADATA +339 -0
- mobiscroll_connect_sdk-1.0.1.dist-info/RECORD +22 -0
- mobiscroll_connect_sdk-1.0.1.dist-info/WHEEL +4 -0
- mobiscroll_connect_sdk-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Mobiscroll Connect SDK for Python.
|
|
2
|
+
|
|
3
|
+
Calendar and event management across Google Calendar, Microsoft Outlook,
|
|
4
|
+
Apple Calendar, and CalDAV through a single client.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from mobiscroll_connect import MobiscrollConnectClient
|
|
9
|
+
|
|
10
|
+
with MobiscrollConnectClient("client-id", "client-secret", "https://app/callback") as client:
|
|
11
|
+
url = client.auth.generate_auth_url(user_id="user-123")
|
|
12
|
+
# ...exchange code, set credentials...
|
|
13
|
+
for event in client.events.iter_all(start="2024-01-01", end="2024-01-31"):
|
|
14
|
+
print(event.title)
|
|
15
|
+
|
|
16
|
+
For asyncio, use :mod:`mobiscroll_connect.aio`::
|
|
17
|
+
|
|
18
|
+
from mobiscroll_connect.aio import AsyncMobiscrollConnectClient
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from .client import MobiscrollConnectClient
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .exceptions import (
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
MobiscrollConnectError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ServerError,
|
|
30
|
+
ValidationError,
|
|
31
|
+
)
|
|
32
|
+
from .models import (
|
|
33
|
+
Calendar,
|
|
34
|
+
CalendarEvent,
|
|
35
|
+
ConnectedAccount,
|
|
36
|
+
ConnectionStatusResponse,
|
|
37
|
+
DisconnectResponse,
|
|
38
|
+
EventAttendee,
|
|
39
|
+
EventsListResponse,
|
|
40
|
+
Provider,
|
|
41
|
+
TokenResponse,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = "1.0.1"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"Calendar",
|
|
49
|
+
"CalendarEvent",
|
|
50
|
+
"Config",
|
|
51
|
+
"ConnectedAccount",
|
|
52
|
+
"ConnectionStatusResponse",
|
|
53
|
+
"DisconnectResponse",
|
|
54
|
+
"EventAttendee",
|
|
55
|
+
"EventsListResponse",
|
|
56
|
+
"MobiscrollConnectClient",
|
|
57
|
+
# Exceptions
|
|
58
|
+
"MobiscrollConnectError",
|
|
59
|
+
"NetworkError",
|
|
60
|
+
"NotFoundError",
|
|
61
|
+
# Models
|
|
62
|
+
"Provider",
|
|
63
|
+
"RateLimitError",
|
|
64
|
+
"ServerError",
|
|
65
|
+
"TokenResponse",
|
|
66
|
+
"ValidationError",
|
|
67
|
+
"__version__",
|
|
68
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Maps HTTP responses to typed SDK exceptions. Shared by sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..exceptions import (
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
MobiscrollConnectError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe_json(response: httpx.Response) -> Mapping[str, Any]:
|
|
22
|
+
try:
|
|
23
|
+
data = response.json()
|
|
24
|
+
return data if isinstance(data, Mapping) else {}
|
|
25
|
+
except Exception:
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def map_response_error(response: httpx.Response) -> MobiscrollConnectError:
|
|
30
|
+
"""Map an HTTP response to a typed SDK exception."""
|
|
31
|
+
status = response.status_code
|
|
32
|
+
data = _safe_json(response)
|
|
33
|
+
message = str(data.get("message") or response.reason_phrase or f"HTTP {status}")
|
|
34
|
+
|
|
35
|
+
if status in (401, 403):
|
|
36
|
+
return AuthenticationError(message)
|
|
37
|
+
if status == 404:
|
|
38
|
+
return NotFoundError(message)
|
|
39
|
+
if status in (400, 422):
|
|
40
|
+
details = data.get("details") if isinstance(data, Mapping) else None
|
|
41
|
+
return ValidationError(message, details if isinstance(details, Mapping) else None)
|
|
42
|
+
if status == 429:
|
|
43
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
44
|
+
retry_after: int | None = None
|
|
45
|
+
if retry_after_raw is not None:
|
|
46
|
+
try:
|
|
47
|
+
retry_after = int(retry_after_raw)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
retry_after = None
|
|
50
|
+
return RateLimitError(message, retry_after)
|
|
51
|
+
if status in (500, 502, 503, 504):
|
|
52
|
+
return ServerError(message, status)
|
|
53
|
+
|
|
54
|
+
return MobiscrollConnectError(message, code=str(data.get("code") or "UNKNOWN_ERROR"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def map_transport_error(exc: httpx.HTTPError) -> NetworkError:
|
|
58
|
+
return NetworkError(str(exc) or exc.__class__.__name__)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Pure functions that build request payloads / queries from caller input.
|
|
2
|
+
|
|
3
|
+
Kept separate from the API client so they can be unit-tested in isolation and
|
|
4
|
+
reused by both the sync and async resources.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any, Union
|
|
13
|
+
|
|
14
|
+
DateLike = Union[datetime, str]
|
|
15
|
+
|
|
16
|
+
_LIST_QUERY_KEYS_PASSTHROUGH = ("provider", "calendarId", "eventId", "recurringEventId", "deleteMode")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_datetime(value: DateLike) -> str:
|
|
20
|
+
"""Convert to ISO 8601 with a ``Z`` suffix when UTC. String input is passed through."""
|
|
21
|
+
if isinstance(value, datetime):
|
|
22
|
+
if value.tzinfo is None:
|
|
23
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
24
|
+
iso = value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
25
|
+
return iso
|
|
26
|
+
return str(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_list_events_query(
|
|
30
|
+
*,
|
|
31
|
+
start: DateLike | None = None,
|
|
32
|
+
end: DateLike | None = None,
|
|
33
|
+
calendar_ids: Mapping[str, Iterable[str]] | None = None,
|
|
34
|
+
page_size: int | None = None,
|
|
35
|
+
next_page_token: str | None = None,
|
|
36
|
+
single_events: bool | None = None,
|
|
37
|
+
) -> dict[str, str]:
|
|
38
|
+
"""Build the query string for ``GET /events``. All values are stringified."""
|
|
39
|
+
query: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
if start is not None:
|
|
42
|
+
query["start"] = format_datetime(start)
|
|
43
|
+
if end is not None:
|
|
44
|
+
query["end"] = format_datetime(end)
|
|
45
|
+
if calendar_ids is not None:
|
|
46
|
+
# Convert iterables to lists so json.dumps emits arrays.
|
|
47
|
+
normalized = {k: list(v) for k, v in calendar_ids.items()}
|
|
48
|
+
query["calendarIds"] = json.dumps(normalized, separators=(",", ":"))
|
|
49
|
+
if page_size is not None:
|
|
50
|
+
query["pageSize"] = str(min(int(page_size), 1000))
|
|
51
|
+
if next_page_token is not None:
|
|
52
|
+
query["nextPageToken"] = str(next_page_token)
|
|
53
|
+
if single_events is not None:
|
|
54
|
+
# API expects string booleans in query string.
|
|
55
|
+
query["singleEvents"] = "true" if single_events else "false"
|
|
56
|
+
|
|
57
|
+
return query
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_event_payload(event: Mapping[str, Any]) -> dict[str, Any]:
|
|
61
|
+
"""Normalize a snake_case-friendly event dict into the API's camelCase shape.
|
|
62
|
+
|
|
63
|
+
Accepts both wire-format keys (``calendarId``, ``allDay``) and Pythonic ones
|
|
64
|
+
(``calendar_id``, ``all_day``). Datetime fields are formatted to ISO 8601.
|
|
65
|
+
"""
|
|
66
|
+
snake_to_camel = {
|
|
67
|
+
"calendar_id": "calendarId",
|
|
68
|
+
"event_id": "eventId",
|
|
69
|
+
"recurring_event_id": "recurringEventId",
|
|
70
|
+
"update_mode": "updateMode",
|
|
71
|
+
"delete_mode": "deleteMode",
|
|
72
|
+
"all_day": "allDay",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
payload: dict[str, Any] = {}
|
|
76
|
+
for key, value in event.items():
|
|
77
|
+
out_key = snake_to_camel.get(key, key)
|
|
78
|
+
if out_key in ("start", "end") and value is not None:
|
|
79
|
+
payload[out_key] = format_datetime(value)
|
|
80
|
+
else:
|
|
81
|
+
payload[out_key] = value
|
|
82
|
+
|
|
83
|
+
return payload
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_delete_query(params: Mapping[str, Any]) -> dict[str, str]:
|
|
87
|
+
"""Build the query string for ``DELETE /event``. Validates required keys."""
|
|
88
|
+
payload = build_event_payload(params)
|
|
89
|
+
query: dict[str, str] = {}
|
|
90
|
+
for key in _LIST_QUERY_KEYS_PASSTHROUGH:
|
|
91
|
+
value = payload.get(key)
|
|
92
|
+
if value is not None and value != "":
|
|
93
|
+
query[key] = str(value)
|
|
94
|
+
|
|
95
|
+
snake_for_error = {"provider": "provider", "calendarId": "calendar_id", "eventId": "event_id"}
|
|
96
|
+
for required in ("provider", "calendarId", "eventId"):
|
|
97
|
+
if required not in query:
|
|
98
|
+
raise ValueError(f"{snake_for_error[required]} is required for event deletion")
|
|
99
|
+
|
|
100
|
+
return query
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Async API. Mirrors the sync surface — every method is a coroutine."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncMobiscrollConnectClient
|
|
4
|
+
from .resources import AsyncAuth, AsyncCalendars, AsyncEvents
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AsyncAuth",
|
|
8
|
+
"AsyncCalendars",
|
|
9
|
+
"AsyncEvents",
|
|
10
|
+
"AsyncMobiscrollConnectClient",
|
|
11
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Public async client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ..async_api_client import AsyncApiClient, AsyncTokensRefreshedCallback
|
|
10
|
+
from ..config import Config
|
|
11
|
+
from ..models import TokenResponse
|
|
12
|
+
from .resources import AsyncAuth, AsyncCalendars, AsyncEvents
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncMobiscrollConnectClient:
|
|
16
|
+
"""Async variant of :class:`mobiscroll_connect.MobiscrollConnectClient`.
|
|
17
|
+
|
|
18
|
+
Use as an async context manager to release the connection pool::
|
|
19
|
+
|
|
20
|
+
async with AsyncMobiscrollConnectClient(...) as client:
|
|
21
|
+
calendars = await client.calendars.list()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
client_id: str,
|
|
27
|
+
client_secret: str,
|
|
28
|
+
redirect_uri: str,
|
|
29
|
+
*,
|
|
30
|
+
base_url: str | None = None,
|
|
31
|
+
timeout: float | None = None,
|
|
32
|
+
http_client: httpx.AsyncClient | None = None,
|
|
33
|
+
on_tokens_refreshed: AsyncTokensRefreshedCallback | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
config_kwargs: dict[str, Any] = {"client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri}
|
|
36
|
+
if base_url is not None:
|
|
37
|
+
config_kwargs["base_url"] = base_url
|
|
38
|
+
if timeout is not None:
|
|
39
|
+
config_kwargs["timeout"] = timeout
|
|
40
|
+
|
|
41
|
+
self._config = Config(**config_kwargs)
|
|
42
|
+
self._api = AsyncApiClient(
|
|
43
|
+
self._config,
|
|
44
|
+
http_client=http_client,
|
|
45
|
+
on_tokens_refreshed=on_tokens_refreshed,
|
|
46
|
+
)
|
|
47
|
+
self.auth = AsyncAuth(self._api)
|
|
48
|
+
self.calendars = AsyncCalendars(self._api)
|
|
49
|
+
self.events = AsyncEvents(self._api)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def config(self) -> Config:
|
|
53
|
+
return self._config
|
|
54
|
+
|
|
55
|
+
def set_credentials(self, tokens: TokenResponse) -> None:
|
|
56
|
+
"""Shortcut for ``client.auth.set_credentials(tokens)``."""
|
|
57
|
+
self.auth.set_credentials(tokens)
|
|
58
|
+
|
|
59
|
+
def on_tokens_refreshed(self, callback: AsyncTokensRefreshedCallback) -> None:
|
|
60
|
+
self._api.on_tokens_refreshed(callback)
|
|
61
|
+
|
|
62
|
+
async def aclose(self) -> None:
|
|
63
|
+
await self._api.aclose()
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> AsyncMobiscrollConnectClient:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
69
|
+
await self.aclose()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
5
|
+
from typing import Any, Union
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
from .._internal.payloads import (
|
|
9
|
+
DateLike,
|
|
10
|
+
build_delete_query,
|
|
11
|
+
build_event_payload,
|
|
12
|
+
build_list_events_query,
|
|
13
|
+
)
|
|
14
|
+
from ..async_api_client import AsyncApiClient
|
|
15
|
+
from ..exceptions import MobiscrollConnectError, ServerError
|
|
16
|
+
from ..models import (
|
|
17
|
+
Calendar,
|
|
18
|
+
CalendarEvent,
|
|
19
|
+
ConnectionStatusResponse,
|
|
20
|
+
DisconnectResponse,
|
|
21
|
+
EventsListResponse,
|
|
22
|
+
Provider,
|
|
23
|
+
TokenResponse,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
ProviderLike = Union[str, Provider]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AsyncAuth:
|
|
30
|
+
def __init__(self, api_client: AsyncApiClient) -> None:
|
|
31
|
+
self._api = api_client
|
|
32
|
+
|
|
33
|
+
def generate_auth_url(
|
|
34
|
+
self,
|
|
35
|
+
user_id: str,
|
|
36
|
+
*,
|
|
37
|
+
scope: str = "calendar",
|
|
38
|
+
state: str | None = None,
|
|
39
|
+
providers: str | None = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
cfg = self._api.config
|
|
42
|
+
params = {
|
|
43
|
+
"client_id": cfg.client_id,
|
|
44
|
+
"response_type": "code",
|
|
45
|
+
"user_id": user_id,
|
|
46
|
+
"redirect_uri": cfg.redirect_uri,
|
|
47
|
+
"scope": scope,
|
|
48
|
+
}
|
|
49
|
+
if state is not None:
|
|
50
|
+
params["state"] = state
|
|
51
|
+
if providers is not None:
|
|
52
|
+
params["providers"] = providers
|
|
53
|
+
return f"{self._api.base_url}/oauth/authorize?{urlencode(params)}"
|
|
54
|
+
|
|
55
|
+
async def get_token(self, code: str) -> TokenResponse:
|
|
56
|
+
cfg = self._api.config
|
|
57
|
+
credentials = base64.b64encode(
|
|
58
|
+
f"{cfg.client_id}:{cfg.client_secret}".encode()
|
|
59
|
+
).decode()
|
|
60
|
+
|
|
61
|
+
data = await self._api.post_form(
|
|
62
|
+
"oauth/token",
|
|
63
|
+
form={
|
|
64
|
+
"grant_type": "authorization_code",
|
|
65
|
+
"code": code,
|
|
66
|
+
"redirect_uri": cfg.redirect_uri,
|
|
67
|
+
},
|
|
68
|
+
headers={
|
|
69
|
+
"Authorization": f"Basic {credentials}",
|
|
70
|
+
"CLIENT_ID": cfg.client_id,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
tokens = TokenResponse.from_dict(data or {})
|
|
74
|
+
self._api.set_credentials(tokens)
|
|
75
|
+
return tokens
|
|
76
|
+
|
|
77
|
+
def set_credentials(self, tokens: TokenResponse) -> None:
|
|
78
|
+
self._api.set_credentials(tokens)
|
|
79
|
+
|
|
80
|
+
async def get_connection_status(self) -> ConnectionStatusResponse:
|
|
81
|
+
try:
|
|
82
|
+
data = await self._api.get("oauth/connection-status")
|
|
83
|
+
except MobiscrollConnectError:
|
|
84
|
+
data = await self._api.get("connection-status")
|
|
85
|
+
return ConnectionStatusResponse.from_dict(data or {})
|
|
86
|
+
|
|
87
|
+
async def disconnect(
|
|
88
|
+
self,
|
|
89
|
+
provider: ProviderLike,
|
|
90
|
+
*,
|
|
91
|
+
account: str | None = None,
|
|
92
|
+
) -> DisconnectResponse:
|
|
93
|
+
params = {"provider": str(provider.value if isinstance(provider, Provider) else provider)}
|
|
94
|
+
if account:
|
|
95
|
+
params["account"] = account
|
|
96
|
+
try:
|
|
97
|
+
data = await self._api.post("oauth/disconnect", json={}, params=params)
|
|
98
|
+
except MobiscrollConnectError:
|
|
99
|
+
data = await self._api.post("disconnect", json={}, params=params)
|
|
100
|
+
return DisconnectResponse.from_dict(data or {})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class AsyncCalendars:
|
|
104
|
+
def __init__(self, api_client: AsyncApiClient) -> None:
|
|
105
|
+
self._api = api_client
|
|
106
|
+
|
|
107
|
+
async def list(self) -> list[Calendar]:
|
|
108
|
+
data = await self._api.get("calendars")
|
|
109
|
+
if not isinstance(data, list):
|
|
110
|
+
return []
|
|
111
|
+
return [Calendar.from_dict(c) for c in data if isinstance(c, Mapping)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AsyncEvents:
|
|
115
|
+
def __init__(self, api_client: AsyncApiClient) -> None:
|
|
116
|
+
self._api = api_client
|
|
117
|
+
|
|
118
|
+
async def list(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
start: DateLike | None = None,
|
|
122
|
+
end: DateLike | None = None,
|
|
123
|
+
calendar_ids: Mapping[str, Iterable[str]] | None = None,
|
|
124
|
+
page_size: int | None = None,
|
|
125
|
+
next_page_token: str | None = None,
|
|
126
|
+
single_events: bool | None = None,
|
|
127
|
+
) -> EventsListResponse:
|
|
128
|
+
query = build_list_events_query(
|
|
129
|
+
start=start,
|
|
130
|
+
end=end,
|
|
131
|
+
calendar_ids=calendar_ids,
|
|
132
|
+
page_size=page_size,
|
|
133
|
+
next_page_token=next_page_token,
|
|
134
|
+
single_events=single_events,
|
|
135
|
+
)
|
|
136
|
+
data = await self._api.get("events", params=query or None)
|
|
137
|
+
return EventsListResponse.from_dict(data if isinstance(data, Mapping) else {})
|
|
138
|
+
|
|
139
|
+
async def iter_all(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
start: DateLike | None = None,
|
|
143
|
+
end: DateLike | None = None,
|
|
144
|
+
calendar_ids: Mapping[str, Iterable[str]] | None = None,
|
|
145
|
+
page_size: int | None = None,
|
|
146
|
+
single_events: bool | None = None,
|
|
147
|
+
) -> AsyncIterator[CalendarEvent]:
|
|
148
|
+
token: str | None = None
|
|
149
|
+
while True:
|
|
150
|
+
page = await self.list(
|
|
151
|
+
start=start,
|
|
152
|
+
end=end,
|
|
153
|
+
calendar_ids=calendar_ids,
|
|
154
|
+
page_size=page_size,
|
|
155
|
+
next_page_token=token,
|
|
156
|
+
single_events=single_events,
|
|
157
|
+
)
|
|
158
|
+
for event in page.events:
|
|
159
|
+
yield event
|
|
160
|
+
if not page.next_page_token:
|
|
161
|
+
return
|
|
162
|
+
token = page.next_page_token
|
|
163
|
+
|
|
164
|
+
async def create(self, event: Mapping[str, Any]) -> CalendarEvent:
|
|
165
|
+
payload = build_event_payload(event)
|
|
166
|
+
response = await self._api.post("event", json=payload)
|
|
167
|
+
return self._extract_event(response, "create")
|
|
168
|
+
|
|
169
|
+
async def update(self, event: Mapping[str, Any]) -> CalendarEvent:
|
|
170
|
+
payload = build_event_payload(event)
|
|
171
|
+
response = await self._api.put("event", json=payload)
|
|
172
|
+
return self._extract_event(response, "update")
|
|
173
|
+
|
|
174
|
+
async def delete(self, params: Mapping[str, Any]) -> None:
|
|
175
|
+
query = build_delete_query(params)
|
|
176
|
+
response = await self._api.delete("event", params=query)
|
|
177
|
+
if isinstance(response, Mapping) and response.get("success") is False:
|
|
178
|
+
raise ServerError(str(response.get("message") or "Failed to delete event"), 400)
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _extract_event(response: Any, operation: str) -> CalendarEvent:
|
|
182
|
+
if isinstance(response, Mapping) and isinstance(response.get("event"), Mapping):
|
|
183
|
+
return CalendarEvent.from_dict(response["event"])
|
|
184
|
+
msg = response.get("message") if isinstance(response, Mapping) else None
|
|
185
|
+
raise ServerError(msg if isinstance(msg, str) else f"Failed to {operation} event", 400)
|