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.
@@ -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)