appflowysdk 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AppFlowy SDK Contributors
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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: appflowysdk
3
+ Version: 0.1.0
4
+ Summary: Type-safe Python SDK for the AppFlowy Cloud REST API
5
+ Author: AppFlowy SDK Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AppFlowy-IO/appflowysdk
8
+ Project-URL: Documentation, https://github.com/AppFlowy-IO/appflowysdk/tree/main/docs
9
+ Project-URL: Repository, https://github.com/AppFlowy-IO/appflowysdk
10
+ Project-URL: Issues, https://github.com/AppFlowy-IO/appflowysdk/issues
11
+ Keywords: appflowy,api,sdk,cloud,productivity
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: httpx>=0.28.1
22
+ Requires-Dist: pydantic>=2.12.5
23
+ Dynamic: license-file
24
+
25
+ # AppFlowy SDK
26
+
27
+ Type-safe Python SDK for the [AppFlowy Cloud REST API](https://beta.appflowy.cloud).
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install appflowysdk
33
+ ## or
34
+ uv add appflowysdk
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ from appflowy import AppFlowy
41
+
42
+ with AppFlowy(email="user@example.com", password="password") as client:
43
+ # Authenticate
44
+ token = client.login()
45
+ print(f"Logged in, token expires in {token.expires_in}s")
46
+
47
+ # List workspaces
48
+ workspaces = client.get_workspaces(include_member_count=True)
49
+ for ws in workspaces:
50
+ print(f"{ws.workspace_name} ({ws.workspace_id})")
51
+
52
+ # Get folder structure
53
+ folder = client.get_workspace_folder(workspaces[0].workspace_id)
54
+
55
+ # List databases
56
+ databases = client.get_databases(workspaces[0].workspace_id)
57
+
58
+ # Get database fields
59
+ fields = client.get_database_fields(
60
+ workspaces[0].workspace_id, databases[0].id
61
+ )
62
+
63
+ # Get row IDs
64
+ rows = client.get_database_row_ids(
65
+ workspaces[0].workspace_id, databases[0].id
66
+ )
67
+
68
+ # Get row details
69
+ if rows:
70
+ details = client.get_database_row_details(
71
+ workspaces[0].workspace_id,
72
+ databases[0].id,
73
+ [rows[0].id],
74
+ with_doc=True,
75
+ )
76
+
77
+ # Create a row
78
+ row_id = client.create_database_row(
79
+ workspaces[0].workspace_id,
80
+ databases[0].id,
81
+ cells={"field_id": "value"},
82
+ )
83
+
84
+ # Upsert a row
85
+ row_id = client.upsert_database_row(
86
+ workspaces[0].workspace_id,
87
+ databases[0].id,
88
+ pre_hash="unique-key",
89
+ cells={"field_id": "value"},
90
+ )
91
+ ```
92
+
93
+ ## OAuth
94
+
95
+ ```python
96
+ client = AppFlowy()
97
+ token = client.oauth_redirect_token(
98
+ code="auth_code_from_redirect",
99
+ grant_type="authorization_code",
100
+ )
101
+ ```
102
+
103
+ ## Error Handling
104
+
105
+ All SDK errors inherit from `AppFlowyError`:
106
+
107
+ ```python
108
+ from src.exception import (
109
+ AppFlowyError,
110
+ LoginError,
111
+ RefreshTokenError,
112
+ APIError,
113
+ ValidationError,
114
+ NetworkError,
115
+ )
116
+ ```
117
+
118
+ ## API Reference
119
+
120
+ | Method | Endpoint | Description |
121
+ |--------|----------|-------------|
122
+ | `login()` | `POST /gotrue/token?grant_type=password` | Password auth |
123
+ | `refresh_token()` | `POST /gotrue/token?grant_type=refresh_token` | Refresh token |
124
+ | `oauth_redirect_token()` | `GET /web-api/oauth-redirect/token` | OAuth token exchange |
125
+ | `get_workspaces()` | `GET /api/workspace` | List workspaces |
126
+ | `get_workspace_folder()` | `GET /api/workspace/{id}/folder` | Get folder tree |
127
+ | `get_databases()` | `GET /api/workspace/{id}/database` | List databases |
128
+ | `get_database_fields()` | `GET /api/workspace/{id}/database/{id}/fields` | List fields |
129
+ | `get_database_row_ids()` | `GET /api/workspace/{id}/database/{id}/row` | List row IDs |
130
+ | `create_database_row()` | `POST /api/workspace/{id}/database/{id}/row` | Create row |
131
+ | `upsert_database_row()` | `PUT /api/workspace/{id}/database/{id}/row` | Upsert row |
132
+ | `get_database_row_ids_updated()` | `GET /api/workspace/{id}/database/{id}/row/updated` | Updated rows |
133
+ | `get_database_row_details()` | `GET /api/workspace/{id}/database/{id}/row/detail` | Row details |
@@ -0,0 +1,109 @@
1
+ # AppFlowy SDK
2
+
3
+ Type-safe Python SDK for the [AppFlowy Cloud REST API](https://beta.appflowy.cloud).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install appflowysdk
9
+ ## or
10
+ uv add appflowysdk
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from appflowy import AppFlowy
17
+
18
+ with AppFlowy(email="user@example.com", password="password") as client:
19
+ # Authenticate
20
+ token = client.login()
21
+ print(f"Logged in, token expires in {token.expires_in}s")
22
+
23
+ # List workspaces
24
+ workspaces = client.get_workspaces(include_member_count=True)
25
+ for ws in workspaces:
26
+ print(f"{ws.workspace_name} ({ws.workspace_id})")
27
+
28
+ # Get folder structure
29
+ folder = client.get_workspace_folder(workspaces[0].workspace_id)
30
+
31
+ # List databases
32
+ databases = client.get_databases(workspaces[0].workspace_id)
33
+
34
+ # Get database fields
35
+ fields = client.get_database_fields(
36
+ workspaces[0].workspace_id, databases[0].id
37
+ )
38
+
39
+ # Get row IDs
40
+ rows = client.get_database_row_ids(
41
+ workspaces[0].workspace_id, databases[0].id
42
+ )
43
+
44
+ # Get row details
45
+ if rows:
46
+ details = client.get_database_row_details(
47
+ workspaces[0].workspace_id,
48
+ databases[0].id,
49
+ [rows[0].id],
50
+ with_doc=True,
51
+ )
52
+
53
+ # Create a row
54
+ row_id = client.create_database_row(
55
+ workspaces[0].workspace_id,
56
+ databases[0].id,
57
+ cells={"field_id": "value"},
58
+ )
59
+
60
+ # Upsert a row
61
+ row_id = client.upsert_database_row(
62
+ workspaces[0].workspace_id,
63
+ databases[0].id,
64
+ pre_hash="unique-key",
65
+ cells={"field_id": "value"},
66
+ )
67
+ ```
68
+
69
+ ## OAuth
70
+
71
+ ```python
72
+ client = AppFlowy()
73
+ token = client.oauth_redirect_token(
74
+ code="auth_code_from_redirect",
75
+ grant_type="authorization_code",
76
+ )
77
+ ```
78
+
79
+ ## Error Handling
80
+
81
+ All SDK errors inherit from `AppFlowyError`:
82
+
83
+ ```python
84
+ from src.exception import (
85
+ AppFlowyError,
86
+ LoginError,
87
+ RefreshTokenError,
88
+ APIError,
89
+ ValidationError,
90
+ NetworkError,
91
+ )
92
+ ```
93
+
94
+ ## API Reference
95
+
96
+ | Method | Endpoint | Description |
97
+ |--------|----------|-------------|
98
+ | `login()` | `POST /gotrue/token?grant_type=password` | Password auth |
99
+ | `refresh_token()` | `POST /gotrue/token?grant_type=refresh_token` | Refresh token |
100
+ | `oauth_redirect_token()` | `GET /web-api/oauth-redirect/token` | OAuth token exchange |
101
+ | `get_workspaces()` | `GET /api/workspace` | List workspaces |
102
+ | `get_workspace_folder()` | `GET /api/workspace/{id}/folder` | Get folder tree |
103
+ | `get_databases()` | `GET /api/workspace/{id}/database` | List databases |
104
+ | `get_database_fields()` | `GET /api/workspace/{id}/database/{id}/fields` | List fields |
105
+ | `get_database_row_ids()` | `GET /api/workspace/{id}/database/{id}/row` | List row IDs |
106
+ | `create_database_row()` | `POST /api/workspace/{id}/database/{id}/row` | Create row |
107
+ | `upsert_database_row()` | `PUT /api/workspace/{id}/database/{id}/row` | Upsert row |
108
+ | `get_database_row_ids_updated()` | `GET /api/workspace/{id}/database/{id}/row/updated` | Updated rows |
109
+ | `get_database_row_details()` | `GET /api/workspace/{id}/database/{id}/row/detail` | Row details |
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "appflowysdk"
7
+ version = "0.1.0"
8
+ description = "Type-safe Python SDK for the AppFlowy Cloud REST API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [
13
+ {name = "AppFlowy SDK Contributors"},
14
+ ]
15
+ keywords = ["appflowy", "api", "sdk", "cloud", "productivity"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.28.1",
26
+ "pydantic>=2.12.5",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/AppFlowy-IO/appflowysdk"
31
+ Documentation = "https://github.com/AppFlowy-IO/appflowysdk/tree/main/docs"
32
+ Repository = "https://github.com/AppFlowy-IO/appflowysdk"
33
+ Issues = "https://github.com/AppFlowy-IO/appflowysdk/issues"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,70 @@
1
+ """Type-safe Python SDK for the AppFlowy Cloud REST API."""
2
+
3
+ from appflowysdk.client import AppFlowy
4
+ from appflowysdk.constants import BASE_URL
5
+ from appflowysdk.exceptions import (
6
+ APIError,
7
+ AppFlowyError,
8
+ AuthenticationError,
9
+ LoginError,
10
+ NetworkError,
11
+ NotFoundError,
12
+ RefreshTokenError,
13
+ ValidationError,
14
+ )
15
+ from appflowysdk.models import (
16
+ AddDatabaseRowRequest,
17
+ ApiResponse,
18
+ Database,
19
+ DatabaseField,
20
+ DatabaseRow,
21
+ DatabaseRowDetail,
22
+ DatabaseRowUpdated,
23
+ FolderView,
24
+ FolderViewMin,
25
+ IconType,
26
+ Role,
27
+ Token,
28
+ TokenResponse,
29
+ UpsertDatabaseRowRequest,
30
+ ViewIcon,
31
+ ViewLayout,
32
+ Workspace,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ # Client
39
+ "AppFlowy",
40
+ # Constants
41
+ "BASE_URL",
42
+ # Exceptions
43
+ "AppFlowyError",
44
+ "AuthenticationError",
45
+ "LoginError",
46
+ "RefreshTokenError",
47
+ "APIError",
48
+ "NotFoundError",
49
+ "ValidationError",
50
+ "NetworkError",
51
+ # Models
52
+ "Token",
53
+ "TokenResponse",
54
+ "ApiResponse",
55
+ "Workspace",
56
+ "ViewIcon",
57
+ "FolderViewMin",
58
+ "FolderView",
59
+ "Database",
60
+ "DatabaseField",
61
+ "DatabaseRow",
62
+ "DatabaseRowDetail",
63
+ "DatabaseRowUpdated",
64
+ "AddDatabaseRowRequest",
65
+ "UpsertDatabaseRowRequest",
66
+ # Enums
67
+ "IconType",
68
+ "ViewLayout",
69
+ "Role",
70
+ ]
@@ -0,0 +1,457 @@
1
+ """AppFlowy Cloud REST API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from appflowysdk.constants import BASE_URL
11
+ from appflowysdk.exceptions import (
12
+ APIError,
13
+ AuthenticationError,
14
+ LoginError,
15
+ NetworkError,
16
+ RefreshTokenError,
17
+ ValidationError,
18
+ )
19
+ from appflowysdk.logger import logger
20
+ from appflowysdk.models import (
21
+ AddDatabaseRowRequest,
22
+ Database,
23
+ DatabaseField,
24
+ DatabaseFieldsResponse,
25
+ DatabaseRow,
26
+ DatabaseRowDetail,
27
+ DatabaseRowDetailsResponse,
28
+ DatabaseRowsResponse,
29
+ DatabaseRowsUpdatedResponse,
30
+ DatabaseRowUpdated,
31
+ DatabasesResponse,
32
+ FolderResponse,
33
+ FolderView,
34
+ Token,
35
+ TokenResponse,
36
+ UpsertDatabaseRowRequest,
37
+ Workspace,
38
+ WorkspacesResponse,
39
+ )
40
+ from appflowysdk.tokenstore import TokenStore
41
+
42
+
43
+ class AppFlowy:
44
+ """Type-safe Python SDK for the AppFlowy Cloud REST API."""
45
+
46
+ def __init__(
47
+ self,
48
+ email: str | None = None,
49
+ password: str | None = None,
50
+ base_url: str = BASE_URL,
51
+ ) -> None:
52
+ self.email = email
53
+ self.password = password
54
+ self.base_url = base_url.rstrip("/")
55
+ self.token_store = TokenStore()
56
+ self._http_client = httpx.Client(timeout=30.0)
57
+
58
+ # ------------------------------------------------------------------
59
+ # Internal helpers
60
+ # ------------------------------------------------------------------
61
+
62
+ def _headers(self) -> dict[str, str]:
63
+ headers: dict[str, str] = {
64
+ "Content-Type": "application/json",
65
+ "Accept": "application/json",
66
+ }
67
+ access_token = self.token_store.get_access_token()
68
+ if access_token:
69
+ headers["Authorization"] = f"Bearer {access_token}"
70
+ return headers
71
+
72
+ def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
73
+ try:
74
+ body: dict[str, Any] = response.json()
75
+ except Exception:
76
+ raise APIError(
77
+ "Failed to parse response body",
78
+ status_code=response.status_code,
79
+ body=response.text,
80
+ )
81
+
82
+ if response.status_code >= 400:
83
+ message = body.get("message", f"HTTP {response.status_code}")
84
+ raise APIError(
85
+ message=message,
86
+ status_code=response.status_code,
87
+ body=body,
88
+ )
89
+
90
+ return body
91
+
92
+ def _request(
93
+ self,
94
+ method: str,
95
+ path: str,
96
+ params: dict[str, Any] | None = None,
97
+ json_body: dict[str, Any] | None = None,
98
+ ) -> dict[str, Any]:
99
+ url = f"{self.base_url}{path}"
100
+ try:
101
+ response = self._http_client.request(
102
+ method=method,
103
+ url=url,
104
+ headers=self._headers(),
105
+ params=params,
106
+ json=json_body,
107
+ )
108
+ return self._handle_response(response)
109
+ except (APIError, AuthenticationError):
110
+ raise
111
+ except httpx.NetworkError as e:
112
+ raise NetworkError(f"Network error: {e}") from e
113
+ except httpx.TimeoutException as e:
114
+ raise NetworkError(f"Request timed out: {e}") from e
115
+ except Exception as e:
116
+ raise APIError(f"Unexpected error: {e}") from e
117
+
118
+ # ------------------------------------------------------------------
119
+ # Authentication
120
+ # ------------------------------------------------------------------
121
+
122
+ def login(self) -> TokenResponse:
123
+ """Authenticate with email and password.
124
+
125
+ Returns:
126
+ TokenResponse with access and refresh tokens.
127
+
128
+ Raises:
129
+ LoginError: If credentials are missing or login fails.
130
+ NetworkError: If a network error occurs.
131
+ """
132
+ if not self.email or not self.password:
133
+ raise LoginError("Email and password are required for login.")
134
+
135
+ logger.info("Logging in as %s", self.email)
136
+ try:
137
+ body = self._request(
138
+ "POST",
139
+ "/gotrue/token?grant_type=password",
140
+ json_body={
141
+ "email": self.email,
142
+ "password": self.password,
143
+ },
144
+ )
145
+ token_response = TokenResponse(**body)
146
+ self.token_store.set_token_store(
147
+ Token(
148
+ access_token=token_response.access_token,
149
+ refresh_token=token_response.refresh_token,
150
+ expires_in=token_response.expires_in,
151
+ )
152
+ )
153
+ logger.info("Login successful")
154
+ return token_response
155
+ except APIError as e:
156
+ raise LoginError(
157
+ f"Login failed: {e.message}",
158
+ status_code=e.status_code,
159
+ body=e.body,
160
+ ) from e
161
+
162
+ def refresh_token(self) -> TokenResponse:
163
+ """Refresh the access token using the stored refresh token.
164
+
165
+ Returns:
166
+ TokenResponse with new access and refresh tokens.
167
+
168
+ Raises:
169
+ RefreshTokenError: If no refresh token is stored or refresh fails.
170
+ NetworkError: If a network error occurs.
171
+ """
172
+ stored_refresh = self.token_store.get_refresh_token()
173
+ if not stored_refresh:
174
+ raise RefreshTokenError("No refresh token available. Please login first.")
175
+
176
+ logger.info("Refreshing access token")
177
+ try:
178
+ body = self._request(
179
+ "POST",
180
+ "/gotrue/token?grant_type=refresh_token",
181
+ json_body={"refresh_token": stored_refresh},
182
+ )
183
+ token_response = TokenResponse(**body)
184
+ self.token_store.set_token_store(
185
+ Token(
186
+ access_token=token_response.access_token,
187
+ refresh_token=token_response.refresh_token,
188
+ expires_in=token_response.expires_in,
189
+ )
190
+ )
191
+ logger.info("Token refreshed successfully")
192
+ return token_response
193
+ except APIError as e:
194
+ raise RefreshTokenError(
195
+ f"Token refresh failed: {e.message}",
196
+ status_code=e.status_code,
197
+ body=e.body,
198
+ ) from e
199
+
200
+ def oauth_redirect_token(
201
+ self,
202
+ code: str,
203
+ grant_type: str,
204
+ *,
205
+ client_id: str | None = None,
206
+ client_secret: str | None = None,
207
+ redirect_uri: str | None = None,
208
+ code_verifier: str | None = None,
209
+ ) -> TokenResponse:
210
+ """Exchange an OAuth authorization code for tokens.
211
+
212
+ Returns:
213
+ TokenResponse with access and refresh tokens.
214
+ """
215
+ params: dict[str, Any] = {
216
+ "code": code,
217
+ "grant_type": grant_type,
218
+ }
219
+ if client_id is not None:
220
+ params["client_id"] = client_id
221
+ if client_secret is not None:
222
+ params["client_secret"] = client_secret
223
+ if redirect_uri is not None:
224
+ params["redirect_uri"] = redirect_uri
225
+ if code_verifier is not None:
226
+ params["code_verifier"] = code_verifier
227
+
228
+ logger.info("Exchanging OAuth code for token")
229
+ body = self._request("GET", "/web-api/oauth-redirect/token", params=params)
230
+ token_response = TokenResponse(**body)
231
+ self.token_store.set_token_store(
232
+ Token(
233
+ access_token=token_response.access_token,
234
+ refresh_token=token_response.refresh_token,
235
+ expires_in=token_response.expires_in,
236
+ )
237
+ )
238
+ return token_response
239
+
240
+ # ------------------------------------------------------------------
241
+ # Workspaces
242
+ # ------------------------------------------------------------------
243
+
244
+ def get_workspaces(
245
+ self,
246
+ *,
247
+ include_member_count: bool | None = None,
248
+ include_role: bool | None = None,
249
+ ) -> list[Workspace]:
250
+ """Retrieve all workspaces for the authenticated user."""
251
+ params: dict[str, Any] = {}
252
+ if include_member_count is not None:
253
+ params["include_member_count"] = include_member_count
254
+ if include_role is not None:
255
+ params["include_role"] = include_role
256
+
257
+ body = self._request("GET", "/api/workspace", params=params)
258
+ response = WorkspacesResponse(**body)
259
+ return response.data
260
+
261
+ # ------------------------------------------------------------------
262
+ # Workspace folder
263
+ # ------------------------------------------------------------------
264
+
265
+ def get_workspace_folder(
266
+ self,
267
+ workspace_id: str,
268
+ *,
269
+ depth: int | None = None,
270
+ root_view_id: str | None = None,
271
+ ) -> FolderView:
272
+ """Retrieve the folder structure of a workspace."""
273
+ params: dict[str, Any] = {}
274
+ if depth is not None:
275
+ params["depth"] = depth
276
+ if root_view_id is not None:
277
+ params["root_view_id"] = root_view_id
278
+
279
+ body = self._request(
280
+ "GET",
281
+ f"/api/workspace/{workspace_id}/folder",
282
+ params=params,
283
+ )
284
+ response = FolderResponse(**body)
285
+ if response.data is None:
286
+ raise APIError("Server returned null folder data")
287
+ return response.data
288
+
289
+ # ------------------------------------------------------------------
290
+ # Databases
291
+ # ------------------------------------------------------------------
292
+
293
+ def get_databases(self, workspace_id: str) -> list[Database]:
294
+ """Retrieve all databases in a workspace."""
295
+ body = self._request(
296
+ "GET",
297
+ f"/api/workspace/{workspace_id}/database",
298
+ )
299
+ response = DatabasesResponse(**body)
300
+ return response.data
301
+
302
+ def get_database_fields(
303
+ self,
304
+ workspace_id: str,
305
+ database_id: str,
306
+ ) -> list[DatabaseField]:
307
+ """Retrieve all fields in a database."""
308
+ body = self._request(
309
+ "GET",
310
+ f"/api/workspace/{workspace_id}/database/{database_id}/fields",
311
+ )
312
+ response = DatabaseFieldsResponse(**body)
313
+ return response.data
314
+
315
+ # ------------------------------------------------------------------
316
+ # Database rows
317
+ # ------------------------------------------------------------------
318
+
319
+ def get_database_row_ids(
320
+ self,
321
+ workspace_id: str,
322
+ database_id: str,
323
+ ) -> list[DatabaseRow]:
324
+ """Retrieve all row IDs in a database."""
325
+ body = self._request(
326
+ "GET",
327
+ f"/api/workspace/{workspace_id}/database/{database_id}/row",
328
+ )
329
+ response = DatabaseRowsResponse(**body)
330
+ return response.data
331
+
332
+ def create_database_row(
333
+ self,
334
+ workspace_id: str,
335
+ database_id: str,
336
+ *,
337
+ cells: dict[str, Any] | None = None,
338
+ document: str | None = None,
339
+ ) -> str:
340
+ """Create a new row in a database.
341
+
342
+ Returns:
343
+ UUID of the newly created row.
344
+ """
345
+ request = AddDatabaseRowRequest(
346
+ cells=cells or {},
347
+ document=document,
348
+ )
349
+ body = self._request(
350
+ "POST",
351
+ f"/api/workspace/{workspace_id}/database/{database_id}/row",
352
+ json_body=request.model_dump(exclude_none=True),
353
+ )
354
+ return str(body.get("data", ""))
355
+
356
+ def upsert_database_row(
357
+ self,
358
+ workspace_id: str,
359
+ database_id: str,
360
+ pre_hash: str,
361
+ *,
362
+ cells: dict[str, Any] | None = None,
363
+ document: str | None = None,
364
+ ) -> str:
365
+ """Update or insert a row identified by ``pre_hash``.
366
+
367
+ Returns:
368
+ UUID of the created or updated row.
369
+ """
370
+ request = UpsertDatabaseRowRequest(
371
+ pre_hash=pre_hash,
372
+ cells=cells or {},
373
+ document=document,
374
+ )
375
+ body = self._request(
376
+ "PUT",
377
+ f"/api/workspace/{workspace_id}/database/{database_id}/row",
378
+ json_body=request.model_dump(exclude_none=True),
379
+ )
380
+ return str(body.get("data", ""))
381
+
382
+ # ------------------------------------------------------------------
383
+ # Row updates
384
+ # ------------------------------------------------------------------
385
+
386
+ def get_database_row_ids_updated(
387
+ self,
388
+ workspace_id: str,
389
+ database_id: str,
390
+ *,
391
+ after: datetime | str | None = None,
392
+ ) -> list[DatabaseRowUpdated]:
393
+ """Retrieve row IDs updated after a given timestamp."""
394
+ params: dict[str, Any] = {}
395
+ if after is not None:
396
+ params["after"] = (
397
+ after.isoformat() if isinstance(after, datetime) else after
398
+ )
399
+
400
+ body = self._request(
401
+ "GET",
402
+ f"/api/workspace/{workspace_id}/database/{database_id}/row/updated",
403
+ params=params,
404
+ )
405
+ response = DatabaseRowsUpdatedResponse(**body)
406
+ return response.data
407
+
408
+ # ------------------------------------------------------------------
409
+ # Row details
410
+ # ------------------------------------------------------------------
411
+
412
+ def get_database_row_details(
413
+ self,
414
+ workspace_id: str,
415
+ database_id: str,
416
+ row_ids: list[str],
417
+ *,
418
+ with_doc: bool | None = None,
419
+ ) -> list[DatabaseRowDetail]:
420
+ """Retrieve detailed information for specific database rows.
421
+
422
+ Raises:
423
+ ValidationError: If no row IDs are provided.
424
+ """
425
+ if not row_ids:
426
+ raise ValidationError("At least one row ID is required.")
427
+
428
+ params: dict[str, Any] = {"ids": ",".join(row_ids)}
429
+ if with_doc is not None:
430
+ params["with_doc"] = with_doc
431
+
432
+ body = self._request(
433
+ "GET",
434
+ f"/api/workspace/{workspace_id}/database/{database_id}/row/detail",
435
+ params=params,
436
+ )
437
+ response = DatabaseRowDetailsResponse(**body)
438
+ return response.data
439
+
440
+ # ------------------------------------------------------------------
441
+ # Lifecycle
442
+ # ------------------------------------------------------------------
443
+
444
+ def close(self) -> None:
445
+ """Close the underlying HTTP client."""
446
+ self._http_client.close()
447
+
448
+ def __enter__(self) -> AppFlowy:
449
+ return self
450
+
451
+ def __exit__(
452
+ self,
453
+ exc_type: type[BaseException] | None,
454
+ exc_val: BaseException | None,
455
+ exc_tb: Any,
456
+ ) -> None:
457
+ self.close()
@@ -0,0 +1,3 @@
1
+ """AppFlowy Cloud API base URL."""
2
+
3
+ BASE_URL: str = "https://beta.appflowy.cloud"
@@ -0,0 +1,48 @@
1
+ """AppFlowy SDK exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class AppFlowyError(Exception):
9
+ """Base exception for all AppFlowy SDK errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ status_code: int | None = None,
15
+ body: Any = None,
16
+ ) -> None:
17
+ self.message = message
18
+ self.status_code = status_code
19
+ self.body = body
20
+ super().__init__(self.message)
21
+
22
+
23
+ class AuthenticationError(AppFlowyError):
24
+ """Raised when authentication fails (login or token refresh)."""
25
+
26
+
27
+ class LoginError(AuthenticationError):
28
+ """Raised when login fails."""
29
+
30
+
31
+ class RefreshTokenError(AuthenticationError):
32
+ """Raised when token refresh fails."""
33
+
34
+
35
+ class APIError(AppFlowyError):
36
+ """Raised when an API request returns a non-2xx status code."""
37
+
38
+
39
+ class NotFoundError(APIError):
40
+ """Raised when a resource is not found (404)."""
41
+
42
+
43
+ class ValidationError(AppFlowyError):
44
+ """Raised when SDK-side input validation fails."""
45
+
46
+
47
+ class NetworkError(AppFlowyError):
48
+ """Raised when a network-level error occurs."""
@@ -0,0 +1,15 @@
1
+ """Structured logging for the AppFlowy SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ logger = logging.getLogger("appflowysdk")
8
+
9
+ if not logger.handlers:
10
+ _handler = logging.StreamHandler()
11
+ _handler.setFormatter(
12
+ logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
13
+ )
14
+ logger.addHandler(_handler)
15
+ logger.setLevel(logging.INFO)
@@ -0,0 +1,218 @@
1
+ """Pydantic models and enums for the AppFlowy Cloud API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import IntEnum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Enums
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class IconType(IntEnum):
18
+ EMOJI = 0
19
+ URL = 1
20
+ ICON = 2
21
+
22
+
23
+ class ViewLayout(IntEnum):
24
+ DOCUMENT = 0
25
+ GRID = 1
26
+ BOARD = 2
27
+ CALENDAR = 3
28
+ CHAT = 4
29
+
30
+
31
+ class Role(IntEnum):
32
+ OWNER = 1
33
+ MEMBER = 2
34
+ GUEST = 3
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Token models
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class Token(BaseModel):
43
+ """Internal token storage."""
44
+
45
+ access_token: str = ""
46
+ refresh_token: str = ""
47
+ expires_in: int = 0
48
+
49
+
50
+ class TokenResponse(BaseModel):
51
+ """Server response from authentication endpoints."""
52
+
53
+ access_token: str
54
+ token_type: str | None = None
55
+ expires_in: int
56
+ expires_at: int | None = None
57
+ refresh_token: str
58
+ user: dict[str, Any] | None = None
59
+ provider_access_token: str | None = None
60
+ provider_refresh_token: str | None = None
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Generic API response
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ class ApiResponse(BaseModel):
69
+ code: int
70
+ message: str
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Workspace models
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ class Workspace(BaseModel):
79
+ workspace_id: str
80
+ database_storage_id: str | None = None
81
+ owner_uid: int | None = None
82
+ owner_name: str | None = None
83
+ owner_email: str | None = None
84
+ workspace_type: int | None = None
85
+ workspace_name: str | None = None
86
+ created_at: datetime | None = None
87
+ icon: str | None = None
88
+ member_count: int | None = None
89
+ role: Role | None = None
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # View / Folder models
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ class ViewIcon(BaseModel):
98
+ ty: IconType | None = None
99
+ value: str | None = None
100
+
101
+
102
+ class FolderViewMin(BaseModel):
103
+ view_id: str
104
+ name: str
105
+ icon: ViewIcon | None = None
106
+ layout: ViewLayout | None = None
107
+
108
+
109
+ class FolderView(BaseModel):
110
+ view_id: str
111
+ name: str
112
+ icon: ViewIcon | None = None
113
+ is_space: bool | None = None
114
+ is_private: bool | None = None
115
+ is_published: bool | None = None
116
+ layout: ViewLayout | None = None
117
+ created_at: datetime | None = None
118
+ last_edited_time: datetime | None = None
119
+ is_locked: bool | None = None
120
+ extra: dict[str, Any] | None = None
121
+ children: list[FolderView] = Field(default_factory=list)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Database models
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ class Database(BaseModel):
130
+ id: str
131
+ views: list[FolderViewMin] = Field(default_factory=list)
132
+
133
+
134
+ class DatabaseField(BaseModel):
135
+ id: str
136
+ name: str | None = None
137
+ field_type: str | None = None
138
+ type_option: dict[str, Any] | None = None
139
+ is_primary: bool | None = None
140
+
141
+
142
+ class DatabaseRow(BaseModel):
143
+ id: str
144
+
145
+
146
+ class DatabaseRowUpdated(BaseModel):
147
+ id: str
148
+ updated_at: datetime | None = None
149
+
150
+
151
+ class DatabaseRowDetail(BaseModel):
152
+ id: str
153
+ cells: dict[str, Any] = Field(default_factory=dict)
154
+ has_doc: bool | None = None
155
+ doc: str | None = None
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Request models
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ class AddDatabaseRowRequest(BaseModel):
164
+ cells: dict[str, Any] = Field(default_factory=dict)
165
+ document: str | None = None
166
+
167
+
168
+ class UpsertDatabaseRowRequest(BaseModel):
169
+ pre_hash: str
170
+ cells: dict[str, Any] = Field(default_factory=dict)
171
+ document: str | None = None
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Typed API response wrappers
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ class WorkspacesResponse(BaseModel):
180
+ code: int
181
+ message: str
182
+ data: list[Workspace] = Field(default_factory=list)
183
+
184
+
185
+ class FolderResponse(BaseModel):
186
+ code: int
187
+ message: str
188
+ data: FolderView | None = None
189
+
190
+
191
+ class DatabasesResponse(BaseModel):
192
+ code: int
193
+ message: str
194
+ data: list[Database] = Field(default_factory=list)
195
+
196
+
197
+ class DatabaseFieldsResponse(BaseModel):
198
+ code: int
199
+ message: str
200
+ data: list[DatabaseField] = Field(default_factory=list)
201
+
202
+
203
+ class DatabaseRowsResponse(BaseModel):
204
+ code: int
205
+ message: str
206
+ data: list[DatabaseRow] = Field(default_factory=list)
207
+
208
+
209
+ class DatabaseRowsUpdatedResponse(BaseModel):
210
+ code: int
211
+ message: str
212
+ data: list[DatabaseRowUpdated] = Field(default_factory=list)
213
+
214
+
215
+ class DatabaseRowDetailsResponse(BaseModel):
216
+ code: int
217
+ message: str
218
+ data: list[DatabaseRowDetail] = Field(default_factory=list)
@@ -0,0 +1,47 @@
1
+ """In-memory token storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from appflowysdk.models import Token
6
+
7
+
8
+ class TokenStore:
9
+ def __init__(self) -> None:
10
+ self._access_token: str = ""
11
+ self._refresh_token: str = ""
12
+ self._expires_in: int = 0
13
+
14
+ def set_token_store(self, token: Token) -> None:
15
+ self._access_token = token.access_token
16
+ self._refresh_token = token.refresh_token
17
+ self._expires_in = token.expires_in
18
+
19
+ def set_access_token(self, access_token: str) -> None:
20
+ self._access_token = access_token
21
+
22
+ def set_refresh_token(self, refresh_token: str) -> None:
23
+ self._refresh_token = refresh_token
24
+
25
+ def set_expires_in(self, expires_in: int) -> None:
26
+ self._expires_in = expires_in
27
+
28
+ def get_access_token(self) -> str:
29
+ return self._access_token
30
+
31
+ def get_refresh_token(self) -> str:
32
+ return self._refresh_token
33
+
34
+ def get_expires_in(self) -> int:
35
+ return self._expires_in
36
+
37
+ def get_token_store(self) -> Token:
38
+ return Token(
39
+ access_token=self._access_token,
40
+ refresh_token=self._refresh_token,
41
+ expires_in=self._expires_in,
42
+ )
43
+
44
+ def clear(self) -> None:
45
+ self._access_token = ""
46
+ self._refresh_token = ""
47
+ self._expires_in = 0
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: appflowysdk
3
+ Version: 0.1.0
4
+ Summary: Type-safe Python SDK for the AppFlowy Cloud REST API
5
+ Author: AppFlowy SDK Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AppFlowy-IO/appflowysdk
8
+ Project-URL: Documentation, https://github.com/AppFlowy-IO/appflowysdk/tree/main/docs
9
+ Project-URL: Repository, https://github.com/AppFlowy-IO/appflowysdk
10
+ Project-URL: Issues, https://github.com/AppFlowy-IO/appflowysdk/issues
11
+ Keywords: appflowy,api,sdk,cloud,productivity
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: httpx>=0.28.1
22
+ Requires-Dist: pydantic>=2.12.5
23
+ Dynamic: license-file
24
+
25
+ # AppFlowy SDK
26
+
27
+ Type-safe Python SDK for the [AppFlowy Cloud REST API](https://beta.appflowy.cloud).
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install appflowysdk
33
+ ## or
34
+ uv add appflowysdk
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ from appflowy import AppFlowy
41
+
42
+ with AppFlowy(email="user@example.com", password="password") as client:
43
+ # Authenticate
44
+ token = client.login()
45
+ print(f"Logged in, token expires in {token.expires_in}s")
46
+
47
+ # List workspaces
48
+ workspaces = client.get_workspaces(include_member_count=True)
49
+ for ws in workspaces:
50
+ print(f"{ws.workspace_name} ({ws.workspace_id})")
51
+
52
+ # Get folder structure
53
+ folder = client.get_workspace_folder(workspaces[0].workspace_id)
54
+
55
+ # List databases
56
+ databases = client.get_databases(workspaces[0].workspace_id)
57
+
58
+ # Get database fields
59
+ fields = client.get_database_fields(
60
+ workspaces[0].workspace_id, databases[0].id
61
+ )
62
+
63
+ # Get row IDs
64
+ rows = client.get_database_row_ids(
65
+ workspaces[0].workspace_id, databases[0].id
66
+ )
67
+
68
+ # Get row details
69
+ if rows:
70
+ details = client.get_database_row_details(
71
+ workspaces[0].workspace_id,
72
+ databases[0].id,
73
+ [rows[0].id],
74
+ with_doc=True,
75
+ )
76
+
77
+ # Create a row
78
+ row_id = client.create_database_row(
79
+ workspaces[0].workspace_id,
80
+ databases[0].id,
81
+ cells={"field_id": "value"},
82
+ )
83
+
84
+ # Upsert a row
85
+ row_id = client.upsert_database_row(
86
+ workspaces[0].workspace_id,
87
+ databases[0].id,
88
+ pre_hash="unique-key",
89
+ cells={"field_id": "value"},
90
+ )
91
+ ```
92
+
93
+ ## OAuth
94
+
95
+ ```python
96
+ client = AppFlowy()
97
+ token = client.oauth_redirect_token(
98
+ code="auth_code_from_redirect",
99
+ grant_type="authorization_code",
100
+ )
101
+ ```
102
+
103
+ ## Error Handling
104
+
105
+ All SDK errors inherit from `AppFlowyError`:
106
+
107
+ ```python
108
+ from src.exception import (
109
+ AppFlowyError,
110
+ LoginError,
111
+ RefreshTokenError,
112
+ APIError,
113
+ ValidationError,
114
+ NetworkError,
115
+ )
116
+ ```
117
+
118
+ ## API Reference
119
+
120
+ | Method | Endpoint | Description |
121
+ |--------|----------|-------------|
122
+ | `login()` | `POST /gotrue/token?grant_type=password` | Password auth |
123
+ | `refresh_token()` | `POST /gotrue/token?grant_type=refresh_token` | Refresh token |
124
+ | `oauth_redirect_token()` | `GET /web-api/oauth-redirect/token` | OAuth token exchange |
125
+ | `get_workspaces()` | `GET /api/workspace` | List workspaces |
126
+ | `get_workspace_folder()` | `GET /api/workspace/{id}/folder` | Get folder tree |
127
+ | `get_databases()` | `GET /api/workspace/{id}/database` | List databases |
128
+ | `get_database_fields()` | `GET /api/workspace/{id}/database/{id}/fields` | List fields |
129
+ | `get_database_row_ids()` | `GET /api/workspace/{id}/database/{id}/row` | List row IDs |
130
+ | `create_database_row()` | `POST /api/workspace/{id}/database/{id}/row` | Create row |
131
+ | `upsert_database_row()` | `PUT /api/workspace/{id}/database/{id}/row` | Upsert row |
132
+ | `get_database_row_ids_updated()` | `GET /api/workspace/{id}/database/{id}/row/updated` | Updated rows |
133
+ | `get_database_row_details()` | `GET /api/workspace/{id}/database/{id}/row/detail` | Row details |
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/appflowysdk/__init__.py
5
+ src/appflowysdk/client.py
6
+ src/appflowysdk/constants.py
7
+ src/appflowysdk/exceptions.py
8
+ src/appflowysdk/logger.py
9
+ src/appflowysdk/models.py
10
+ src/appflowysdk/tokenstore.py
11
+ src/appflowysdk.egg-info/PKG-INFO
12
+ src/appflowysdk.egg-info/SOURCES.txt
13
+ src/appflowysdk.egg-info/dependency_links.txt
14
+ src/appflowysdk.egg-info/requires.txt
15
+ src/appflowysdk.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ httpx>=0.28.1
2
+ pydantic>=2.12.5
@@ -0,0 +1 @@
1
+ appflowysdk