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.
- appflowysdk-0.1.0/LICENSE +21 -0
- appflowysdk-0.1.0/PKG-INFO +133 -0
- appflowysdk-0.1.0/README.md +109 -0
- appflowysdk-0.1.0/pyproject.toml +36 -0
- appflowysdk-0.1.0/setup.cfg +4 -0
- appflowysdk-0.1.0/src/appflowysdk/__init__.py +70 -0
- appflowysdk-0.1.0/src/appflowysdk/client.py +457 -0
- appflowysdk-0.1.0/src/appflowysdk/constants.py +3 -0
- appflowysdk-0.1.0/src/appflowysdk/exceptions.py +48 -0
- appflowysdk-0.1.0/src/appflowysdk/logger.py +15 -0
- appflowysdk-0.1.0/src/appflowysdk/models.py +218 -0
- appflowysdk-0.1.0/src/appflowysdk/tokenstore.py +47 -0
- appflowysdk-0.1.0/src/appflowysdk.egg-info/PKG-INFO +133 -0
- appflowysdk-0.1.0/src/appflowysdk.egg-info/SOURCES.txt +15 -0
- appflowysdk-0.1.0/src/appflowysdk.egg-info/dependency_links.txt +1 -0
- appflowysdk-0.1.0/src/appflowysdk.egg-info/requires.txt +2 -0
- appflowysdk-0.1.0/src/appflowysdk.egg-info/top_level.txt +1 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
appflowysdk
|