mobiscroll-connect-sdk 1.0.1__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 (43) hide show
  1. mobiscroll_connect_sdk-1.0.1/.gitignore +20 -0
  2. mobiscroll_connect_sdk-1.0.1/CLAUDE.md +161 -0
  3. mobiscroll_connect_sdk-1.0.1/LICENSE +21 -0
  4. mobiscroll_connect_sdk-1.0.1/PKG-INFO +339 -0
  5. mobiscroll_connect_sdk-1.0.1/README.md +304 -0
  6. mobiscroll_connect_sdk-1.0.1/examples/.env.example +7 -0
  7. mobiscroll_connect_sdk-1.0.1/examples/async_basic.py +42 -0
  8. mobiscroll_connect_sdk-1.0.1/examples/event_crud.py +58 -0
  9. mobiscroll_connect_sdk-1.0.1/examples/sync_basic.py +61 -0
  10. mobiscroll_connect_sdk-1.0.1/minimal-app/app.py +325 -0
  11. mobiscroll_connect_sdk-1.0.1/minimal-app/requirements.txt +3 -0
  12. mobiscroll_connect_sdk-1.0.1/minimal-app/templates/calendars.html +60 -0
  13. mobiscroll_connect_sdk-1.0.1/minimal-app/templates/event_edit.html +188 -0
  14. mobiscroll_connect_sdk-1.0.1/minimal-app/templates/events.html +154 -0
  15. mobiscroll_connect_sdk-1.0.1/minimal-app/templates/ui.html +236 -0
  16. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/__init__.py +68 -0
  17. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/_internal/__init__.py +0 -0
  18. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/_internal/errors.py +58 -0
  19. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/_internal/payloads.py +100 -0
  20. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/aio/__init__.py +11 -0
  21. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/aio/client.py +69 -0
  22. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/aio/resources.py +185 -0
  23. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/api_client.py +224 -0
  24. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/async_api_client.py +212 -0
  25. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/client.py +78 -0
  26. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/config.py +25 -0
  27. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/exceptions.py +91 -0
  28. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/models.py +246 -0
  29. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/py.typed +0 -0
  30. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/__init__.py +5 -0
  31. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/auth.py +109 -0
  32. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/calendars.py +20 -0
  33. mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/events.py +106 -0
  34. mobiscroll_connect_sdk-1.0.1/pyproject.toml +67 -0
  35. mobiscroll_connect_sdk-1.0.1/tests/__init__.py +0 -0
  36. mobiscroll_connect_sdk-1.0.1/tests/conftest.py +39 -0
  37. mobiscroll_connect_sdk-1.0.1/tests/test_async.py +86 -0
  38. mobiscroll_connect_sdk-1.0.1/tests/test_auth.py +173 -0
  39. mobiscroll_connect_sdk-1.0.1/tests/test_calendars.py +37 -0
  40. mobiscroll_connect_sdk-1.0.1/tests/test_events.py +150 -0
  41. mobiscroll_connect_sdk-1.0.1/tests/test_exceptions.py +79 -0
  42. mobiscroll_connect_sdk-1.0.1/tests/test_models.py +116 -0
  43. mobiscroll_connect_sdk-1.0.1/tests/test_payloads.py +111 -0
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ .venv/
6
+ venv/
7
+ dist/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+
13
+ # Environment — copy .env.example to .env and fill in your credentials
14
+ examples/.env
15
+ .env
16
+
17
+ # IDE
18
+ .DS_Store
19
+ .vscode/
20
+ .idea/
@@ -0,0 +1,161 @@
1
+ # CLAUDE.md — Mobiscroll Connect Python SDK
2
+
3
+ ## AI Assistant Guidelines
4
+
5
+ Communication:
6
+
7
+ - Be extremely concise. Skip introductory and concluding fluff. Get straight to the answer.
8
+ - If a question is about code, provide the code block first, then explain briefly afterward.
9
+ - Never hallucinate. If you don't know something or a library version is too new, say so immediately.
10
+ - Focus on architectural patterns, edge cases, and optimization. Do not explain basic programming concepts.
11
+
12
+ Coding Standards:
13
+
14
+ - Default to Python 3.9+ compatible code with type annotations throughout.
15
+ - Favor modular, reusable code. Include proper error handling and edge cases; do not just provide the "happy path."
16
+ - Keep logic decoupled from transport concerns where possible.
17
+
18
+ Formatting:
19
+
20
+ - When modifying existing code, provide the entire function or class for easy copy-pasting. Never truncate code with comments like `# ... rest of code` unless the file is excessively long.
21
+ - Type annotations are required on all public methods and module-level functions.
22
+
23
+ ## Project Overview
24
+
25
+ Python 3.9+ client library for the Mobiscroll Connect API. Enables calendar and event management across Google Calendar, Microsoft Outlook, Apple Calendar, and CalDAV through a single SDK.
26
+
27
+ - **PyPI**: `mobiscroll-connect-sdk`
28
+ - **Package**: `mobiscroll_connect`
29
+ - **HTTP client**: httpx (sync + async, same API surface)
30
+ - **Test framework**: pytest + pytest-asyncio + respx
31
+ - **Static analysis**: mypy (strict), ruff
32
+
33
+ ---
34
+
35
+ ## Architecture
36
+
37
+ ```
38
+ MobiscrollConnectClient — public sync entry point; constructs and wires all internals
39
+ └── ApiClient — sync HTTP layer (httpx); auth headers; token refresh; error mapping
40
+ ├── resources/Auth — generate_auth_url, get_token, set_credentials, get_connection_status, disconnect
41
+ ├── resources/Calendars — list()
42
+ └── resources/Events — list(), iter_all(), create(), update(), delete()
43
+
44
+ mobiscroll_connect.aio:
45
+ AsyncMobiscrollConnectClient — async counterpart; identical public API, all methods are coroutines
46
+ └── AsyncApiClient — async HTTP layer (httpx.AsyncClient); asyncio.Lock for refresh dedup
47
+ ├── aio/resources/AsyncAuth, AsyncCalendars, AsyncEvents
48
+
49
+ Config — frozen dataclass: client_id, client_secret, redirect_uri, base_url, timeout
50
+ TokenResponse — frozen dataclass: access_token, token_type, expires_in, refresh_token
51
+ CalendarEvent — frozen dataclass for API event responses
52
+ EventsListResponse — frozen dataclass wrapping list[CalendarEvent] + pagination; iterable
53
+ Calendar — frozen dataclass for calendar list entries
54
+ ConnectionStatusResponse — frozen dataclass for /oauth/connection-status
55
+ DisconnectResponse — frozen dataclass for /oauth/disconnect
56
+
57
+ Exceptions:
58
+ MobiscrollConnectError — base; .message and .code
59
+ AuthenticationError — 401/403
60
+ ValidationError — 400/422; .details
61
+ NotFoundError — 404
62
+ RateLimitError — 429; .retry_after
63
+ ServerError — 5xx; .status_code
64
+ NetworkError — transport failures
65
+
66
+ _internal/errors.py — map_response_error, map_transport_error (pure functions, shared by sync+async)
67
+ _internal/payloads.py — build_list_events_query, build_event_payload, build_delete_query (pure, shared)
68
+ ```
69
+
70
+ ### Token refresh
71
+
72
+ `ApiClient` catches a 401 on every `get/post/put/delete` call. If a refresh token is stored, it acquires a `threading.Lock` (sync) or `asyncio.Lock` (async), POSTs to `oauth/token` with Basic auth (`base64(client_id:client_secret)`), merges the new tokens (preserving the old refresh token if the server does not issue a new one), invokes the `on_tokens_refreshed` callback, then retries the original request once. `post_form` is excluded from retry — it is the token exchange path itself.
73
+
74
+ ### Path normalisation
75
+
76
+ All request paths are `lstrip`-ed to strip leading slashes. httpx `base_url` is `https://connect.mobiscroll.com/api` with paths resolved relatively.
77
+
78
+ ### Route fallbacks in Auth
79
+
80
+ `get_connection_status` and `disconnect` attempt `/oauth/*` first, catch `MobiscrollConnectError`, then fall back to the legacy path without the `/oauth` prefix. This matches PHP SDK behavior and handles older server deployments.
81
+
82
+ ---
83
+
84
+ ## Essential Commands
85
+
86
+ ```bash
87
+ # Install in editable mode with dev dependencies
88
+ pip install -e ".[dev]"
89
+
90
+ # Run tests
91
+ pytest
92
+
93
+ # Single test file
94
+ pytest tests/test_auth.py -v
95
+
96
+ # Type checking
97
+ mypy mobiscroll_connect
98
+
99
+ # Linting
100
+ ruff check mobiscroll_connect tests
101
+
102
+ # Run the minimal Flask test app
103
+ cd minimal-app
104
+ pip install -r requirements.txt
105
+ python app.py
106
+ # Open http://localhost:8000/?action=ui
107
+ ```
108
+
109
+ ---
110
+
111
+ ## File Map
112
+
113
+ | File | Purpose |
114
+ | ------------------------------------------ | ------------------------------------------------------------------ |
115
+ | `mobiscroll_connect/client.py` | Public sync API surface; constructs ApiClient + resources |
116
+ | `mobiscroll_connect/api_client.py` | Sync HTTP methods, token refresh, error mapping |
117
+ | `mobiscroll_connect/async_api_client.py` | Async HTTP layer; identical surface, asyncio.Lock |
118
+ | `mobiscroll_connect/aio/client.py` | AsyncMobiscrollConnectClient |
119
+ | `mobiscroll_connect/aio/resources.py` | AsyncAuth, AsyncCalendars, AsyncEvents |
120
+ | `mobiscroll_connect/config.py` | Immutable client config dataclass |
121
+ | `mobiscroll_connect/models.py` | All response DTOs (frozen dataclasses with `from_dict` classmethods)|
122
+ | `mobiscroll_connect/exceptions.py` | Exception hierarchy |
123
+ | `mobiscroll_connect/resources/auth.py` | OAuth flow methods |
124
+ | `mobiscroll_connect/resources/calendars.py`| Calendar listing |
125
+ | `mobiscroll_connect/resources/events.py` | Event CRUD + iter_all pagination helper |
126
+ | `mobiscroll_connect/_internal/errors.py` | HTTP status → exception mapping (shared) |
127
+ | `mobiscroll_connect/_internal/payloads.py` | Query/payload builders (shared, pure functions) |
128
+ | `tests/` | pytest tests (auth, calendars, events, models, payloads, async) |
129
+ | `minimal-app/app.py` | Reference Flask implementation (action-dispatch pattern) |
130
+ | `minimal-app/templates/` | Dark-theme UI pages (ui, calendars, events, event_edit) |
131
+
132
+ ---
133
+
134
+ ## Coding Standards
135
+
136
+ - All models are `@dataclass(frozen=True)` — no Pydantic, zero extra runtime dependencies.
137
+ - Wire format is camelCase; all Python-facing properties are snake_case. `from_dict` classmethods handle the mapping.
138
+ - Resources are attributes on the client (`client.events.list()`), not callable getters.
139
+ - Use `from __future__ import annotations` on every file for forward-reference compatibility with Python 3.9.
140
+ - No inline comments except where logic is genuinely non-obvious.
141
+
142
+ ---
143
+
144
+ ## Testing Patterns
145
+
146
+ - HTTP is mocked with `respx` — each test function is decorated with `@respx.mock` (do not use it as a class decorator; it silently skips test methods in pytest).
147
+ - Async tests use `pytest-asyncio` with `asyncio_mode = "auto"` in `pyproject.toml` — no `@pytest.mark.asyncio` needed.
148
+ - `tests/conftest.py` provides a `make_client()` fixture with dummy credentials.
149
+ - Do not mock model parsing with hardcoded strings; prefer calling `from_dict` directly in unit tests.
150
+
151
+ ---
152
+
153
+ ## Key Invariants
154
+
155
+ - `post_form` never retries on 401 — it is the token exchange path itself.
156
+ - Token merge on refresh: `refresh_token = new_tokens.refresh_token or current.refresh_token`.
157
+ - `build_list_events_query` caps `page_size` at 1000 and serialises `single_events` as `"true"`/`"false"` strings (not booleans) — it goes into a URL query string.
158
+ - `CalendarEvent.from_dict` raises `ValueError` for missing required fields (`provider`, `id`, `calendarId`).
159
+ - All HTTP verbs set `Authorization: Bearer {token}` headers — never embed tokens in query strings or bodies.
160
+ - `on_tokens_refreshed` callback exceptions are swallowed — persistence failures must not break the refresh path.
161
+ - The async `on_tokens_refreshed` callback can be sync or async; `AsyncApiClient` detects coroutines via `asyncio.iscoroutine` and awaits them.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mobiscroll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,339 @@
1
+ Metadata-Version: 2.4
2
+ Name: mobiscroll-connect-sdk
3
+ Version: 1.0.1
4
+ Summary: Python client for the Mobiscroll Connect API — calendar and event management across Google, Microsoft, Apple, and CalDAV.
5
+ Project-URL: Homepage, https://mobiscroll.com/connect
6
+ Project-URL: Documentation, https://mobiscroll.com/docs/connect/python-sdk
7
+ Project-URL: Source, https://github.com/acidb/mobiscroll-connect-sdks
8
+ Author: Mobiscroll
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: caldav,calendar,google-calendar,mobiscroll,oauth2,outlook
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Scheduling
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx<1.0,>=0.24
26
+ Requires-Dist: typing-extensions>=4.0; python_version < '3.11'
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.0; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0; extra == 'dev'
31
+ Requires-Dist: python-dotenv>=1.0; extra == 'dev'
32
+ Requires-Dist: respx>=0.20; extra == 'dev'
33
+ Requires-Dist: ruff>=0.1; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # Mobiscroll Connect Python SDK
37
+
38
+ Python client for the Mobiscroll Connect API — calendar and event management across Google Calendar, Microsoft Outlook, Apple Calendar, and CalDAV through a single SDK.
39
+
40
+ 📖 **[Full documentation](https://mobiscroll.com/docs/connect/python-sdk)**
41
+
42
+ ## Features
43
+
44
+ - **Multi-provider**: Google, Microsoft, Apple, CalDAV
45
+ - **OAuth2**: full authorization-code flow
46
+ - **Automatic token refresh** with persistence callback
47
+ - **Sync and async** clients (`MobiscrollConnectClient` / `mobiscroll_connect.aio.AsyncMobiscrollConnectClient`)
48
+ - **Typed responses** via frozen dataclasses
49
+ - **Typed exception hierarchy** for HTTP errors
50
+ - **Pagination** helpers (`iter_all` traverses every page)
51
+ - **Type-checked** (`py.typed` shipped)
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install mobiscroll-connect-sdk
57
+ ```
58
+
59
+ Requires Python 3.9+.
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from mobiscroll_connect import MobiscrollConnectClient
65
+
66
+ with MobiscrollConnectClient(
67
+ client_id="YOUR_CLIENT_ID",
68
+ client_secret="YOUR_CLIENT_SECRET",
69
+ redirect_uri="https://yourapp.example/oauth/callback",
70
+ ) as client:
71
+ # 1. Build the auth URL and redirect the user
72
+ auth_url = client.auth.generate_auth_url(user_id="user-123")
73
+
74
+ # 2. After callback: exchange the code for tokens
75
+ tokens = client.auth.get_token(code="...")
76
+
77
+ # 3. Use the API
78
+ for calendar in client.calendars.list():
79
+ print(calendar.provider, calendar.title)
80
+ ```
81
+
82
+ ## OAuth2 flow
83
+
84
+ ```python
85
+ # Step 1 — generate auth URL (server-side)
86
+ auth_url = client.auth.generate_auth_url(
87
+ user_id="user-123",
88
+ scope="calendar", # optional
89
+ state="csrf-value", # optional
90
+ providers="google,microsoft", # optional
91
+ )
92
+
93
+ # Step 2 — exchange the code (in your callback handler)
94
+ tokens = client.auth.get_token(code=request.query_params["code"])
95
+
96
+ # Persist tokens.access_token, tokens.refresh_token, tokens.expires_in
97
+
98
+ # Step 3 — restore credentials on subsequent requests
99
+ from mobiscroll_connect import TokenResponse
100
+
101
+ client.auth.set_credentials(TokenResponse(
102
+ access_token=session["access_token"],
103
+ refresh_token=session["refresh_token"],
104
+ expires_in=session["expires_in"],
105
+ ))
106
+ ```
107
+
108
+ ## Automatic token refresh
109
+
110
+ When a request returns `401 Unauthorized` and a refresh token is present, the SDK transparently refreshes and retries. Register a callback to persist the new tokens:
111
+
112
+ ```python
113
+ def persist_tokens(tokens):
114
+ db.update_tokens(user_id, tokens.to_dict())
115
+
116
+ client.on_tokens_refreshed(persist_tokens)
117
+ ```
118
+
119
+ If the refresh itself fails (revoked, expired), `AuthenticationError` is raised — re-authorize the user.
120
+
121
+ ## Calendars
122
+
123
+ ```python
124
+ calendars = client.calendars.list()
125
+ for cal in calendars:
126
+ print(f"{cal.provider}: {cal.title} ({cal.id})")
127
+ ```
128
+
129
+ ## Events
130
+
131
+ ### List events
132
+
133
+ ```python
134
+ from datetime import datetime
135
+
136
+ response = client.events.list(
137
+ start=datetime(2024, 1, 1),
138
+ end=datetime(2024, 1, 31),
139
+ calendar_ids={"google": ["primary"]},
140
+ page_size=50,
141
+ )
142
+
143
+ for event in response: # EventsListResponse is iterable
144
+ print(event.title, event.start, event.end)
145
+
146
+ if response.has_more:
147
+ next_page = client.events.list(
148
+ next_page_token=response.next_page_token,
149
+ page_size=50,
150
+ )
151
+ ```
152
+
153
+ ### Iterate all pages
154
+
155
+ ```python
156
+ for event in client.events.iter_all(
157
+ start=datetime(2024, 1, 1),
158
+ end=datetime(2024, 12, 31),
159
+ page_size=250,
160
+ ):
161
+ process(event)
162
+ ```
163
+
164
+ ### Create
165
+
166
+ ```python
167
+ event = client.events.create({
168
+ "provider": "google",
169
+ "calendar_id": "primary",
170
+ "title": "Team Meeting",
171
+ "start": "2024-06-15T10:00:00Z",
172
+ "end": "2024-06-15T11:00:00Z",
173
+ "description": "Quarterly review",
174
+ "location": "Conference Room A",
175
+ })
176
+ print("Created:", event.id)
177
+ ```
178
+
179
+ ### Update
180
+
181
+ ```python
182
+ client.events.update({
183
+ "provider": "google",
184
+ "calendar_id": "primary",
185
+ "event_id": "evt-123",
186
+ "title": "Team Meeting (Rescheduled)",
187
+ "start": "2024-06-15T14:00:00Z",
188
+ "end": "2024-06-15T15:00:00Z",
189
+ })
190
+ ```
191
+
192
+ ### Delete
193
+
194
+ ```python
195
+ client.events.delete({
196
+ "provider": "google",
197
+ "calendar_id": "primary",
198
+ "event_id": "evt-123",
199
+ })
200
+ ```
201
+
202
+ ### Recurring events
203
+
204
+ ```python
205
+ # Update only this instance
206
+ client.events.update({
207
+ "provider": "google",
208
+ "calendar_id": "primary",
209
+ "event_id": "instance-id",
210
+ "recurring_event_id": "series-id",
211
+ "update_mode": "this",
212
+ "title": "One-off change",
213
+ })
214
+
215
+ # Delete this and all following instances
216
+ client.events.delete({
217
+ "provider": "google",
218
+ "calendar_id": "primary",
219
+ "event_id": "instance-id",
220
+ "recurring_event_id": "series-id",
221
+ "delete_mode": "following",
222
+ })
223
+ ```
224
+
225
+ ## Connection management
226
+
227
+ ```python
228
+ status = client.auth.get_connection_status()
229
+ for provider, accounts in status.connections.items():
230
+ print(f"{provider}: {len(accounts)} account(s)")
231
+
232
+ if status.limit_reached:
233
+ print(f"Connection limit of {status.limit} reached")
234
+
235
+ # Disconnect a single account
236
+ client.auth.disconnect("google", account="user@gmail.com")
237
+
238
+ # Or all accounts of a provider
239
+ client.auth.disconnect("microsoft")
240
+ ```
241
+
242
+ ## Async usage
243
+
244
+ ```python
245
+ import asyncio
246
+ from mobiscroll_connect.aio import AsyncMobiscrollConnectClient
247
+
248
+ async def main():
249
+ async with AsyncMobiscrollConnectClient(
250
+ client_id="...",
251
+ client_secret="...",
252
+ redirect_uri="...",
253
+ ) as client:
254
+ await client.auth.get_token(code="...")
255
+ async for event in client.events.iter_all(start="2024-01-01", end="2024-01-31"):
256
+ print(event.title)
257
+
258
+ asyncio.run(main())
259
+ ```
260
+
261
+ ## Error handling
262
+
263
+ | Exception | HTTP status | Extra |
264
+ | --------------------- | ------------- | -------------- |
265
+ | `AuthenticationError` | 401, 403 | — |
266
+ | `ValidationError` | 400, 422 | `.details` |
267
+ | `NotFoundError` | 404 | — |
268
+ | `RateLimitError` | 429 | `.retry_after` |
269
+ | `ServerError` | 5xx | `.status_code` |
270
+ | `NetworkError` | — (transport) | — |
271
+
272
+ All errors inherit from `MobiscrollConnectError`.
273
+
274
+ ```python
275
+ from mobiscroll_connect import (
276
+ AuthenticationError, ValidationError, NotFoundError,
277
+ RateLimitError, ServerError, NetworkError, MobiscrollConnectError,
278
+ )
279
+
280
+ try:
281
+ client.events.list()
282
+ except AuthenticationError:
283
+ # Refresh failed — re-authorize the user
284
+ ...
285
+ except ValidationError as e:
286
+ print(e.details)
287
+ except RateLimitError as e:
288
+ print(f"Retry after {e.retry_after}s")
289
+ except ServerError as e:
290
+ print(f"Server returned {e.status_code}")
291
+ except NetworkError:
292
+ # Connection / DNS / timeout
293
+ ...
294
+ except MobiscrollConnectError:
295
+ # Catch-all
296
+ ...
297
+ ```
298
+
299
+ ## Architecture
300
+
301
+ ```
302
+ mobiscroll_connect/
303
+ ├── __init__.py — public re-exports
304
+ ├── client.py — MobiscrollConnectClient (sync entry point)
305
+ ├── api_client.py — sync HTTP layer + token refresh
306
+ ├── async_api_client.py — async HTTP layer + token refresh
307
+ ├── config.py — frozen Config dataclass
308
+ ├── exceptions.py — exception hierarchy
309
+ ├── models.py — frozen dataclass response models
310
+ ├── _internal/
311
+ │ ├── errors.py — HTTP → exception mapper (shared)
312
+ │ └── payloads.py — query/payload builders (shared)
313
+ ├── resources/
314
+ │ ├── auth.py — Auth (sync)
315
+ │ ├── calendars.py — Calendars (sync)
316
+ │ └── events.py — Events (sync)
317
+ └── aio/
318
+ ├── client.py — AsyncMobiscrollConnectClient
319
+ └── resources.py — AsyncAuth / AsyncCalendars / AsyncEvents
320
+ ```
321
+
322
+ ### Why these choices
323
+
324
+ - **Frozen dataclasses, not Pydantic.** No third-party runtime dependency for models — matches the "stdlib-only DTOs" approach of the PHP and Node SDKs and keeps install size small. Validation is done where it matters (response parsing, query builders).
325
+ - **`httpx` for both sync and async.** Single dependency, identical request API. `requests` would force a separate sync transport.
326
+ - **`asyncio.Lock` and `threading.Lock` for refresh dedup.** Concurrent 401s wait on the same in-flight refresh instead of racing — same invariant as the Node SDK's `refreshTokenPromise`.
327
+ - **Resources as attributes (`client.auth`, not `client.auth()`).** Idiomatic Python; the parens-method style in the PHP SDK exists only because PHP can't expose readonly properties cleanly.
328
+ - **Pagination helper (`iter_all`).** PHP/Node make callers manage `next_page_token` by hand; Python iterators are the natural shape and remove the bookkeeping.
329
+
330
+ ## Testing
331
+
332
+ ```bash
333
+ pip install -e ".[dev]"
334
+ pytest
335
+ ```
336
+
337
+ ## License
338
+
339
+ MIT