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.
- mobiscroll_connect_sdk-1.0.1/.gitignore +20 -0
- mobiscroll_connect_sdk-1.0.1/CLAUDE.md +161 -0
- mobiscroll_connect_sdk-1.0.1/LICENSE +21 -0
- mobiscroll_connect_sdk-1.0.1/PKG-INFO +339 -0
- mobiscroll_connect_sdk-1.0.1/README.md +304 -0
- mobiscroll_connect_sdk-1.0.1/examples/.env.example +7 -0
- mobiscroll_connect_sdk-1.0.1/examples/async_basic.py +42 -0
- mobiscroll_connect_sdk-1.0.1/examples/event_crud.py +58 -0
- mobiscroll_connect_sdk-1.0.1/examples/sync_basic.py +61 -0
- mobiscroll_connect_sdk-1.0.1/minimal-app/app.py +325 -0
- mobiscroll_connect_sdk-1.0.1/minimal-app/requirements.txt +3 -0
- mobiscroll_connect_sdk-1.0.1/minimal-app/templates/calendars.html +60 -0
- mobiscroll_connect_sdk-1.0.1/minimal-app/templates/event_edit.html +188 -0
- mobiscroll_connect_sdk-1.0.1/minimal-app/templates/events.html +154 -0
- mobiscroll_connect_sdk-1.0.1/minimal-app/templates/ui.html +236 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/__init__.py +68 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/_internal/__init__.py +0 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/_internal/errors.py +58 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/_internal/payloads.py +100 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/aio/__init__.py +11 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/aio/client.py +69 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/aio/resources.py +185 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/api_client.py +224 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/async_api_client.py +212 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/client.py +78 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/config.py +25 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/exceptions.py +91 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/models.py +246 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/py.typed +0 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/__init__.py +5 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/auth.py +109 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/calendars.py +20 -0
- mobiscroll_connect_sdk-1.0.1/mobiscroll_connect/resources/events.py +106 -0
- mobiscroll_connect_sdk-1.0.1/pyproject.toml +67 -0
- mobiscroll_connect_sdk-1.0.1/tests/__init__.py +0 -0
- mobiscroll_connect_sdk-1.0.1/tests/conftest.py +39 -0
- mobiscroll_connect_sdk-1.0.1/tests/test_async.py +86 -0
- mobiscroll_connect_sdk-1.0.1/tests/test_auth.py +173 -0
- mobiscroll_connect_sdk-1.0.1/tests/test_calendars.py +37 -0
- mobiscroll_connect_sdk-1.0.1/tests/test_events.py +150 -0
- mobiscroll_connect_sdk-1.0.1/tests/test_exceptions.py +79 -0
- mobiscroll_connect_sdk-1.0.1/tests/test_models.py +116 -0
- 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
|