bb-studio 1.0.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.
- bb_studio-1.0.0/LICENSE +21 -0
- bb_studio-1.0.0/PKG-INFO +57 -0
- bb_studio-1.0.0/README.md +44 -0
- bb_studio-1.0.0/pyproject.toml +21 -0
- bb_studio-1.0.0/setup.cfg +4 -0
- bb_studio-1.0.0/src/bb_studio/__init__.py +139 -0
- bb_studio-1.0.0/src/bb_studio/client.py +832 -0
- bb_studio-1.0.0/src/bb_studio.egg-info/PKG-INFO +57 -0
- bb_studio-1.0.0/src/bb_studio.egg-info/SOURCES.txt +10 -0
- bb_studio-1.0.0/src/bb_studio.egg-info/dependency_links.txt +1 -0
- bb_studio-1.0.0/src/bb_studio.egg-info/requires.txt +1 -0
- bb_studio-1.0.0/src/bb_studio.egg-info/top_level.txt +1 -0
bb_studio-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BB-Studio
|
|
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.
|
bb_studio-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bb_studio
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for BB-Studio (Data CRUD, Transactions, End-User Auth, Storage Buckets, Cloud Functions & Telemetry)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://bb.previewbox.dpdns.org
|
|
7
|
+
Keywords: bb-studio,baas,database,auth,storage,cloud-functions
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# `bb_studio` (Python SDK)
|
|
15
|
+
|
|
16
|
+
Official Python client SDK for **BB-Studio**.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install bb_studio
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from bb_studio import (
|
|
28
|
+
BBStudio,
|
|
29
|
+
bb_get,
|
|
30
|
+
bb_create,
|
|
31
|
+
bb_is,
|
|
32
|
+
bb_sort_desc,
|
|
33
|
+
bb_limit,
|
|
34
|
+
bb_auth,
|
|
35
|
+
bb_upload_file,
|
|
36
|
+
bb_invoke,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
bb = BBStudio.initialize(
|
|
40
|
+
application="APP-XXXXXXXX",
|
|
41
|
+
endpoint="https://bb.previewbox.dpdns.org",
|
|
42
|
+
)
|
|
43
|
+
app1 = bb.application("RT-XXXXXXXX")
|
|
44
|
+
|
|
45
|
+
# Query records
|
|
46
|
+
users = bb_get(app1.users, bb_is("status", "ACTIVE"), bb_sort_desc("createdAt"), bb_limit(20))
|
|
47
|
+
|
|
48
|
+
# Authenticate end-user
|
|
49
|
+
bb_auth.login({"email": "alice@example.com", "password": "Secret123!"})
|
|
50
|
+
|
|
51
|
+
# Upload file to granted bucket1
|
|
52
|
+
with open("avatar.png", "rb") as f:
|
|
53
|
+
uploaded = bb_upload_file("https://bb.previewbox.dpdns.org/api/v1/storage/b/BKT-XXXXXXXX", f)
|
|
54
|
+
|
|
55
|
+
# Invoke Cloud Function
|
|
56
|
+
result = bb_invoke("send-welcome", {"name": "Alice"})
|
|
57
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# `bb_studio` (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python client SDK for **BB-Studio**.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install bb_studio
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from bb_studio import (
|
|
15
|
+
BBStudio,
|
|
16
|
+
bb_get,
|
|
17
|
+
bb_create,
|
|
18
|
+
bb_is,
|
|
19
|
+
bb_sort_desc,
|
|
20
|
+
bb_limit,
|
|
21
|
+
bb_auth,
|
|
22
|
+
bb_upload_file,
|
|
23
|
+
bb_invoke,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
bb = BBStudio.initialize(
|
|
27
|
+
application="APP-XXXXXXXX",
|
|
28
|
+
endpoint="https://bb.previewbox.dpdns.org",
|
|
29
|
+
)
|
|
30
|
+
app1 = bb.application("RT-XXXXXXXX")
|
|
31
|
+
|
|
32
|
+
# Query records
|
|
33
|
+
users = bb_get(app1.users, bb_is("status", "ACTIVE"), bb_sort_desc("createdAt"), bb_limit(20))
|
|
34
|
+
|
|
35
|
+
# Authenticate end-user
|
|
36
|
+
bb_auth.login({"email": "alice@example.com", "password": "Secret123!"})
|
|
37
|
+
|
|
38
|
+
# Upload file to granted bucket1
|
|
39
|
+
with open("avatar.png", "rb") as f:
|
|
40
|
+
uploaded = bb_upload_file("https://bb.previewbox.dpdns.org/api/v1/storage/b/BKT-XXXXXXXX", f)
|
|
41
|
+
|
|
42
|
+
# Invoke Cloud Function
|
|
43
|
+
result = bb_invoke("send-welcome", {"name": "Alice"})
|
|
44
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bb_studio"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for BB-Studio (Data CRUD, Transactions, End-User Auth, Storage Buckets, Cloud Functions & Telemetry)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["bb-studio", "baas", "database", "auth", "storage", "cloud-functions"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.27.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://bb.previewbox.dpdns.org"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
ApplicationProxy,
|
|
3
|
+
BBStudio,
|
|
4
|
+
BBStudioClient,
|
|
5
|
+
BBStudioError,
|
|
6
|
+
DatasetRef,
|
|
7
|
+
TransactionContext,
|
|
8
|
+
bb_above,
|
|
9
|
+
bb_atLeast,
|
|
10
|
+
bb_atMost,
|
|
11
|
+
bb_at_least,
|
|
12
|
+
bb_at_most,
|
|
13
|
+
bb_auth,
|
|
14
|
+
bb_below,
|
|
15
|
+
bb_contains,
|
|
16
|
+
bb_create,
|
|
17
|
+
bb_createMany,
|
|
18
|
+
bb_create_many,
|
|
19
|
+
bb_delete,
|
|
20
|
+
bb_deleteFile,
|
|
21
|
+
bb_deleteMany,
|
|
22
|
+
bb_delete_file,
|
|
23
|
+
bb_delete_many,
|
|
24
|
+
bb_empty,
|
|
25
|
+
bb_endsWith,
|
|
26
|
+
bb_ends_with,
|
|
27
|
+
bb_exists,
|
|
28
|
+
bb_expectedVersion,
|
|
29
|
+
bb_expected_version,
|
|
30
|
+
bb_get,
|
|
31
|
+
bb_getFileUrl,
|
|
32
|
+
bb_get_file_url,
|
|
33
|
+
bb_inside,
|
|
34
|
+
bb_invoke,
|
|
35
|
+
bb_is,
|
|
36
|
+
bb_isNot,
|
|
37
|
+
bb_isNull,
|
|
38
|
+
bb_is_not,
|
|
39
|
+
bb_is_null,
|
|
40
|
+
bb_limit,
|
|
41
|
+
bb_listFiles,
|
|
42
|
+
bb_list_files,
|
|
43
|
+
bb_noneOf,
|
|
44
|
+
bb_none_of,
|
|
45
|
+
bb_notEmpty,
|
|
46
|
+
bb_notExists,
|
|
47
|
+
bb_notNull,
|
|
48
|
+
bb_not_empty,
|
|
49
|
+
bb_not_exists,
|
|
50
|
+
bb_not_null,
|
|
51
|
+
bb_offset,
|
|
52
|
+
bb_oneOf,
|
|
53
|
+
bb_one_of,
|
|
54
|
+
bb_outside,
|
|
55
|
+
bb_sortAsc,
|
|
56
|
+
bb_sortDesc,
|
|
57
|
+
bb_sort_asc,
|
|
58
|
+
bb_sort_desc,
|
|
59
|
+
bb_startsWith,
|
|
60
|
+
bb_starts_with,
|
|
61
|
+
bb_storage,
|
|
62
|
+
bb_track,
|
|
63
|
+
bb_transaction,
|
|
64
|
+
bb_update,
|
|
65
|
+
bb_updateMany,
|
|
66
|
+
bb_update_many,
|
|
67
|
+
bb_uploadFile,
|
|
68
|
+
bb_upload_file,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"ApplicationProxy",
|
|
73
|
+
"BBStudio",
|
|
74
|
+
"BBStudioClient",
|
|
75
|
+
"BBStudioError",
|
|
76
|
+
"DatasetRef",
|
|
77
|
+
"TransactionContext",
|
|
78
|
+
"bb_above",
|
|
79
|
+
"bb_atLeast",
|
|
80
|
+
"bb_atMost",
|
|
81
|
+
"bb_at_least",
|
|
82
|
+
"bb_at_most",
|
|
83
|
+
"bb_auth",
|
|
84
|
+
"bb_below",
|
|
85
|
+
"bb_contains",
|
|
86
|
+
"bb_create",
|
|
87
|
+
"bb_createMany",
|
|
88
|
+
"bb_create_many",
|
|
89
|
+
"bb_delete",
|
|
90
|
+
"bb_deleteFile",
|
|
91
|
+
"bb_deleteMany",
|
|
92
|
+
"bb_delete_file",
|
|
93
|
+
"bb_delete_many",
|
|
94
|
+
"bb_empty",
|
|
95
|
+
"bb_endsWith",
|
|
96
|
+
"bb_ends_with",
|
|
97
|
+
"bb_exists",
|
|
98
|
+
"bb_expectedVersion",
|
|
99
|
+
"bb_expected_version",
|
|
100
|
+
"bb_get",
|
|
101
|
+
"bb_getFileUrl",
|
|
102
|
+
"bb_get_file_url",
|
|
103
|
+
"bb_inside",
|
|
104
|
+
"bb_invoke",
|
|
105
|
+
"bb_is",
|
|
106
|
+
"bb_isNot",
|
|
107
|
+
"bb_isNull",
|
|
108
|
+
"bb_is_not",
|
|
109
|
+
"bb_is_null",
|
|
110
|
+
"bb_limit",
|
|
111
|
+
"bb_listFiles",
|
|
112
|
+
"bb_list_files",
|
|
113
|
+
"bb_noneOf",
|
|
114
|
+
"bb_none_of",
|
|
115
|
+
"bb_notEmpty",
|
|
116
|
+
"bb_notExists",
|
|
117
|
+
"bb_notNull",
|
|
118
|
+
"bb_not_empty",
|
|
119
|
+
"bb_not_exists",
|
|
120
|
+
"bb_not_null",
|
|
121
|
+
"bb_offset",
|
|
122
|
+
"bb_oneOf",
|
|
123
|
+
"bb_one_of",
|
|
124
|
+
"bb_outside",
|
|
125
|
+
"bb_sortAsc",
|
|
126
|
+
"bb_sortDesc",
|
|
127
|
+
"bb_sort_asc",
|
|
128
|
+
"bb_sort_desc",
|
|
129
|
+
"bb_startsWith",
|
|
130
|
+
"bb_starts_with",
|
|
131
|
+
"bb_storage",
|
|
132
|
+
"bb_track",
|
|
133
|
+
"bb_transaction",
|
|
134
|
+
"bb_update",
|
|
135
|
+
"bb_updateMany",
|
|
136
|
+
"bb_update_many",
|
|
137
|
+
"bb_uploadFile",
|
|
138
|
+
"bb_upload_file",
|
|
139
|
+
]
|
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BBStudioError(Exception):
|
|
12
|
+
"""Structured exception raised by BB-Studio SDK operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, code: str, message: str, status_code: Optional[int] = None) -> None:
|
|
15
|
+
self.code = code
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
super().__init__(f"{code}: {message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _join_url(base: str, path: str) -> str:
|
|
22
|
+
b = (base or "").strip().rstrip("/")
|
|
23
|
+
p = (path or "").strip().lstrip("/")
|
|
24
|
+
return f"{b}/{p}" if b else f"/{p}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_base_api(endpoint: str) -> str:
|
|
28
|
+
ep = (endpoint or "").strip().rstrip("/")
|
|
29
|
+
return ep if "/api/v1" in ep else _join_url(ep, "api/v1")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class DatasetRef:
|
|
34
|
+
app_id: str
|
|
35
|
+
runtime_ref: str
|
|
36
|
+
dataset_name: str
|
|
37
|
+
endpoint: str
|
|
38
|
+
_bb_studio_ref: bool = True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApplicationProxy:
|
|
42
|
+
def __init__(self, client: BBStudioClient, runtime_ref: str) -> None:
|
|
43
|
+
self._client = client
|
|
44
|
+
self._app_id = client.application_id
|
|
45
|
+
self._runtime_ref = runtime_ref
|
|
46
|
+
self.endpoint = client.endpoint
|
|
47
|
+
|
|
48
|
+
def dataset(self, name: str) -> DatasetRef:
|
|
49
|
+
return DatasetRef(
|
|
50
|
+
app_id=self._app_id,
|
|
51
|
+
runtime_ref=self._runtime_ref,
|
|
52
|
+
dataset_name=name,
|
|
53
|
+
endpoint=self.endpoint,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def __getattr__(self, name: str) -> DatasetRef:
|
|
57
|
+
if name.startswith("_"):
|
|
58
|
+
raise AttributeError(name)
|
|
59
|
+
return self.dataset(name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BBStudioClient:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
application: str,
|
|
66
|
+
endpoint: str,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
) -> None:
|
|
69
|
+
if not application or not application.strip():
|
|
70
|
+
raise ValueError("BBStudio application ID is required.")
|
|
71
|
+
ep = (endpoint or "").strip().rstrip("/")
|
|
72
|
+
if not ep:
|
|
73
|
+
raise ValueError("BBStudio backend endpoint is required and cannot be empty.")
|
|
74
|
+
self.application_id = application.strip()
|
|
75
|
+
self.endpoint = ep
|
|
76
|
+
self.http = httpx.Client(timeout=timeout)
|
|
77
|
+
self._tokens: Dict[str, str] = {}
|
|
78
|
+
|
|
79
|
+
def application(self, runtime_ref: str) -> ApplicationProxy:
|
|
80
|
+
return ApplicationProxy(self, runtime_ref)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BBStudio:
|
|
84
|
+
_default_client: Optional[BBStudioClient] = None
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def initialize(
|
|
88
|
+
cls,
|
|
89
|
+
application: str,
|
|
90
|
+
endpoint: str,
|
|
91
|
+
timeout: float = 30.0,
|
|
92
|
+
) -> BBStudioClient:
|
|
93
|
+
client = BBStudioClient(application=application, endpoint=endpoint, timeout=timeout)
|
|
94
|
+
cls._default_client = client
|
|
95
|
+
return client
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def default_client(cls) -> BBStudioClient:
|
|
99
|
+
if cls._default_client is None:
|
|
100
|
+
raise BBStudioError("NOT_INITIALIZED", "Call BBStudio.initialize(...) first.")
|
|
101
|
+
return cls._default_client
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Condition, Sort, Pagination & Version Builders
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def bb_is(field: str, value: Any) -> Dict[str, Any]:
|
|
109
|
+
return {"type": "IS", "field": field, "value": value}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def bb_is_not(field: str, value: Any) -> Dict[str, Any]:
|
|
113
|
+
return {"type": "IS_NOT", "field": field, "value": value}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def bb_above(field: str, value: Any) -> Dict[str, Any]:
|
|
117
|
+
return {"type": "ABOVE", "field": field, "value": value}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def bb_below(field: str, value: Any) -> Dict[str, Any]:
|
|
121
|
+
return {"type": "BELOW", "field": field, "value": value}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def bb_at_least(field: str, value: Any) -> Dict[str, Any]:
|
|
125
|
+
return {"type": "AT_LEAST", "field": field, "value": value}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def bb_at_most(field: str, value: Any) -> Dict[str, Any]:
|
|
129
|
+
return {"type": "AT_MOST", "field": field, "value": value}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def bb_empty(field: str) -> Dict[str, Any]:
|
|
133
|
+
return {"type": "EMPTY", "field": field}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def bb_not_empty(field: str) -> Dict[str, Any]:
|
|
137
|
+
return {"type": "NOT_EMPTY", "field": field}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def bb_is_null(field: str) -> Dict[str, Any]:
|
|
141
|
+
return {"type": "IS_NULL", "field": field}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def bb_not_null(field: str) -> Dict[str, Any]:
|
|
145
|
+
return {"type": "NOT_NULL", "field": field}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def bb_inside(field: str, lower: Any, upper: Any) -> Dict[str, Any]:
|
|
149
|
+
return {"type": "INSIDE", "field": field, "lower": lower, "upper": upper}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def bb_outside(field: str, lower: Any, upper: Any) -> Dict[str, Any]:
|
|
153
|
+
return {"type": "OUTSIDE", "field": field, "lower": lower, "upper": upper}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def bb_one_of(field: str, values: List[Any]) -> Dict[str, Any]:
|
|
157
|
+
return {"type": "ONE_OF", "field": field, "values": values}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def bb_none_of(field: str, values: List[Any]) -> Dict[str, Any]:
|
|
161
|
+
return {"type": "NONE_OF", "field": field, "values": values}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def bb_contains(field: str, text: str) -> Dict[str, Any]:
|
|
165
|
+
return {"type": "CONTAINS", "field": field, "text": text}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def bb_starts_with(field: str, text: str) -> Dict[str, Any]:
|
|
169
|
+
return {"type": "STARTS_WITH", "field": field, "text": text}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def bb_ends_with(field: str, text: str) -> Dict[str, Any]:
|
|
173
|
+
return {"type": "ENDS_WITH", "field": field, "text": text}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def bb_sort_asc(field: str) -> Dict[str, Any]:
|
|
177
|
+
return {"_control": "SORT", "field": field, "direction": "ASC"}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def bb_sort_desc(field: str) -> Dict[str, Any]:
|
|
181
|
+
return {"_control": "SORT", "field": field, "direction": "DESC"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def bb_limit(count: int) -> Dict[str, Any]:
|
|
185
|
+
return {"_control": "LIMIT", "count": count}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def bb_offset(count: int) -> Dict[str, Any]:
|
|
189
|
+
return {"_control": "OFFSET", "count": count}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def bb_exists(reference: Any) -> Dict[str, Any]:
|
|
193
|
+
return {"type": "EXISTS", "reference": reference}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def bb_not_exists(reference: Any) -> Dict[str, Any]:
|
|
197
|
+
return {"type": "NOT_EXISTS", "reference": reference}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def bb_expected_version(version: int) -> Dict[str, Any]:
|
|
201
|
+
return {"type": "EXPECTED_VERSION", "value": version}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# JS-style camelCase aliases
|
|
205
|
+
bb_isNot = bb_is_not
|
|
206
|
+
bb_atLeast = bb_at_least
|
|
207
|
+
bb_atMost = bb_at_most
|
|
208
|
+
bb_notEmpty = bb_not_empty
|
|
209
|
+
bb_isNull = bb_is_null
|
|
210
|
+
bb_notNull = bb_not_null
|
|
211
|
+
bb_oneOf = bb_one_of
|
|
212
|
+
bb_noneOf = bb_none_of
|
|
213
|
+
bb_startsWith = bb_starts_with
|
|
214
|
+
bb_endsWith = bb_ends_with
|
|
215
|
+
bb_sortAsc = bb_sort_asc
|
|
216
|
+
bb_sortDesc = bb_sort_desc
|
|
217
|
+
bb_notExists = bb_not_exists
|
|
218
|
+
bb_expectedVersion = bb_expected_version
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Internal HTTP Helper
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def _get_http(client: Optional[BBStudioClient] = None) -> httpx.Client:
|
|
226
|
+
if client is not None:
|
|
227
|
+
return client.http
|
|
228
|
+
if BBStudio._default_client is not None:
|
|
229
|
+
return BBStudio._default_client.http
|
|
230
|
+
return httpx.Client(timeout=30.0)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _req(target: DatasetRef, path: str, body: Dict[str, Any]) -> Any:
|
|
234
|
+
if not isinstance(target, DatasetRef):
|
|
235
|
+
raise BBStudioError(
|
|
236
|
+
"INVALID_REFERENCE",
|
|
237
|
+
"Dataset reference invalid. Expected app.dataset (e.g. app1.users).",
|
|
238
|
+
)
|
|
239
|
+
base_api = _resolve_base_api(target.endpoint)
|
|
240
|
+
url = _join_url(base_api, f"frontend-apps/{quote(target.app_id, safe='')}/data/{path}")
|
|
241
|
+
res = _get_http().post(
|
|
242
|
+
url,
|
|
243
|
+
headers={"Content-Type": "application/json", "x-frontend-app-id": target.app_id},
|
|
244
|
+
json=body,
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
data = res.json()
|
|
248
|
+
except Exception:
|
|
249
|
+
data = None
|
|
250
|
+
if not res.is_success:
|
|
251
|
+
raise BBStudioError(
|
|
252
|
+
(data or {}).get("code", "DATABASE_ERROR"),
|
|
253
|
+
(data or {}).get("message", f"Request failed with status {res.status_code}"),
|
|
254
|
+
res.status_code,
|
|
255
|
+
)
|
|
256
|
+
return data
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Data CRUD & Batch Operations
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def bb_get(target: DatasetRef, *args: Dict[str, Any]) -> Any:
|
|
264
|
+
conditions: List[Dict[str, Any]] = []
|
|
265
|
+
sorts: List[Dict[str, str]] = []
|
|
266
|
+
limit: Optional[int] = None
|
|
267
|
+
offset: Optional[int] = None
|
|
268
|
+
|
|
269
|
+
for a in args:
|
|
270
|
+
if not a:
|
|
271
|
+
continue
|
|
272
|
+
ctrl = a.get("_control")
|
|
273
|
+
if ctrl == "SORT":
|
|
274
|
+
sorts.append({"field": a["field"], "direction": a["direction"]})
|
|
275
|
+
elif ctrl == "LIMIT":
|
|
276
|
+
limit = a["count"]
|
|
277
|
+
elif ctrl == "OFFSET":
|
|
278
|
+
offset = a["count"]
|
|
279
|
+
elif a.get("type"):
|
|
280
|
+
conditions.append(a)
|
|
281
|
+
|
|
282
|
+
if not conditions:
|
|
283
|
+
raise BBStudioError("INVALID_INPUT", "At least one filter condition is required for bb_get.")
|
|
284
|
+
|
|
285
|
+
payload: Dict[str, Any] = {
|
|
286
|
+
"runtimeRef": target.runtime_ref,
|
|
287
|
+
"datasetName": target.dataset_name,
|
|
288
|
+
"conditions": conditions,
|
|
289
|
+
}
|
|
290
|
+
if sorts:
|
|
291
|
+
payload["sorts"] = sorts
|
|
292
|
+
if limit is not None:
|
|
293
|
+
payload["limit"] = limit
|
|
294
|
+
if offset is not None:
|
|
295
|
+
payload["offset"] = offset
|
|
296
|
+
return _req(target, "query", payload)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def bb_create(target: DatasetRef, record: Dict[str, Any]) -> Any:
|
|
300
|
+
return _req(
|
|
301
|
+
target,
|
|
302
|
+
"create",
|
|
303
|
+
{"runtimeRef": target.runtime_ref, "datasetName": target.dataset_name, "record": record},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def bb_update(target: DatasetRef, data: Dict[str, Any], *conditions: Dict[str, Any]) -> Any:
|
|
308
|
+
valid = [c for c in conditions if c and c.get("type")]
|
|
309
|
+
if not valid:
|
|
310
|
+
raise BBStudioError("INVALID_INPUT", "At least one filter condition is required for bb_update.")
|
|
311
|
+
return _req(
|
|
312
|
+
target,
|
|
313
|
+
"update",
|
|
314
|
+
{
|
|
315
|
+
"runtimeRef": target.runtime_ref,
|
|
316
|
+
"datasetName": target.dataset_name,
|
|
317
|
+
"data": data,
|
|
318
|
+
"conditions": valid,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def bb_delete(target: DatasetRef, *conditions: Dict[str, Any]) -> Any:
|
|
324
|
+
valid = [c for c in conditions if c and c.get("type")]
|
|
325
|
+
if not valid:
|
|
326
|
+
raise BBStudioError("INVALID_INPUT", "At least one filter condition is required for bb_delete.")
|
|
327
|
+
return _req(
|
|
328
|
+
target,
|
|
329
|
+
"delete",
|
|
330
|
+
{"runtimeRef": target.runtime_ref, "datasetName": target.dataset_name, "conditions": valid},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def bb_create_many(target: DatasetRef, records: List[Dict[str, Any]]) -> Any:
|
|
335
|
+
return _req(
|
|
336
|
+
target,
|
|
337
|
+
"batch/create",
|
|
338
|
+
{"runtimeRef": target.runtime_ref, "datasetName": target.dataset_name, "records": records},
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def bb_update_many(target: DatasetRef, data: Dict[str, Any], *conditions: Dict[str, Any]) -> Any:
|
|
343
|
+
valid = [c for c in conditions if c and c.get("type")]
|
|
344
|
+
if not valid:
|
|
345
|
+
raise BBStudioError("INVALID_INPUT", "At least one filter condition is required for bb_update_many.")
|
|
346
|
+
return _req(
|
|
347
|
+
target,
|
|
348
|
+
"batch/update",
|
|
349
|
+
{
|
|
350
|
+
"runtimeRef": target.runtime_ref,
|
|
351
|
+
"datasetName": target.dataset_name,
|
|
352
|
+
"data": data,
|
|
353
|
+
"conditions": valid,
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def bb_delete_many(target: DatasetRef, *conditions: Dict[str, Any]) -> Any:
|
|
359
|
+
valid = [c for c in conditions if c and c.get("type")]
|
|
360
|
+
if not valid:
|
|
361
|
+
raise BBStudioError("INVALID_INPUT", "At least one filter condition is required for bb_delete_many.")
|
|
362
|
+
return _req(
|
|
363
|
+
target,
|
|
364
|
+
"batch/delete",
|
|
365
|
+
{"runtimeRef": target.runtime_ref, "datasetName": target.dataset_name, "conditions": valid},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
bb_createMany = bb_create_many
|
|
370
|
+
bb_updateMany = bb_update_many
|
|
371
|
+
bb_deleteMany = bb_delete_many
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
# Atomic Transactions
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
class TransactionContext:
|
|
379
|
+
def __init__(self, op_fn: Callable[[Dict[str, Any]], Any]) -> None:
|
|
380
|
+
self._op = op_fn
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _resolve_name(d: Union[DatasetRef, str]) -> str:
|
|
384
|
+
return d if isinstance(d, str) else d.dataset_name
|
|
385
|
+
|
|
386
|
+
def get(self, dataset: Union[DatasetRef, str], *args: Dict[str, Any]) -> Any:
|
|
387
|
+
c: List[Dict[str, Any]] = []
|
|
388
|
+
s: List[Dict[str, str]] = []
|
|
389
|
+
l: Optional[int] = None
|
|
390
|
+
o: Optional[int] = None
|
|
391
|
+
for a in args:
|
|
392
|
+
if not a:
|
|
393
|
+
continue
|
|
394
|
+
ctrl = a.get("_control")
|
|
395
|
+
if ctrl == "SORT":
|
|
396
|
+
s.append({"field": a["field"], "direction": a["direction"]})
|
|
397
|
+
elif ctrl == "LIMIT":
|
|
398
|
+
l = a["count"]
|
|
399
|
+
elif ctrl == "OFFSET":
|
|
400
|
+
o = a["count"]
|
|
401
|
+
elif a.get("type"):
|
|
402
|
+
c.append(a)
|
|
403
|
+
if not c:
|
|
404
|
+
raise BBStudioError("INVALID_INPUT", "At least one filter condition required")
|
|
405
|
+
body: Dict[str, Any] = {
|
|
406
|
+
"datasetName": self._resolve_name(dataset),
|
|
407
|
+
"operation": "GET",
|
|
408
|
+
"conditions": c,
|
|
409
|
+
}
|
|
410
|
+
if s:
|
|
411
|
+
body["sorts"] = s
|
|
412
|
+
if l is not None:
|
|
413
|
+
body["limit"] = l
|
|
414
|
+
if o is not None:
|
|
415
|
+
body["offset"] = o
|
|
416
|
+
return self._op(body)
|
|
417
|
+
|
|
418
|
+
def create(self, dataset: Union[DatasetRef, str], record: Dict[str, Any]) -> Any:
|
|
419
|
+
return self._op({"datasetName": self._resolve_name(dataset), "operation": "CREATE", "record": record})
|
|
420
|
+
|
|
421
|
+
def update(self, dataset: Union[DatasetRef, str], data: Dict[str, Any], *conditions: Dict[str, Any]) -> Any:
|
|
422
|
+
c = [x for x in conditions if x and x.get("type")]
|
|
423
|
+
if not c:
|
|
424
|
+
raise BBStudioError("INVALID_INPUT", "Filter condition required")
|
|
425
|
+
return self._op(
|
|
426
|
+
{"datasetName": self._resolve_name(dataset), "operation": "UPDATE", "data": data, "conditions": c}
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def delete(self, dataset: Union[DatasetRef, str], *conditions: Dict[str, Any]) -> Any:
|
|
430
|
+
c = [x for x in conditions if x and x.get("type")]
|
|
431
|
+
if not c:
|
|
432
|
+
raise BBStudioError("INVALID_INPUT", "Filter condition required")
|
|
433
|
+
return self._op({"datasetName": self._resolve_name(dataset), "operation": "DELETE", "conditions": c})
|
|
434
|
+
|
|
435
|
+
def create_many(self, dataset: Union[DatasetRef, str], records: List[Dict[str, Any]]) -> Any:
|
|
436
|
+
return self._op({"datasetName": self._resolve_name(dataset), "operation": "CREATE_MANY", "records": records})
|
|
437
|
+
|
|
438
|
+
def update_many(self, dataset: Union[DatasetRef, str], data: Dict[str, Any], *conditions: Dict[str, Any]) -> Any:
|
|
439
|
+
c = [x for x in conditions if x and x.get("type")]
|
|
440
|
+
if not c:
|
|
441
|
+
raise BBStudioError("INVALID_INPUT", "Filter condition required")
|
|
442
|
+
return self._op(
|
|
443
|
+
{"datasetName": self._resolve_name(dataset), "operation": "UPDATE_MANY", "data": data, "conditions": c}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def delete_many(self, dataset: Union[DatasetRef, str], *conditions: Dict[str, Any]) -> Any:
|
|
447
|
+
c = [x for x in conditions if x and x.get("type")]
|
|
448
|
+
if not c:
|
|
449
|
+
raise BBStudioError("INVALID_INPUT", "Filter condition required")
|
|
450
|
+
return self._op({"datasetName": self._resolve_name(dataset), "operation": "DELETE_MANY", "conditions": c})
|
|
451
|
+
|
|
452
|
+
createMany = create_many
|
|
453
|
+
updateMany = update_many
|
|
454
|
+
deleteMany = delete_many
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def bb_transaction(
|
|
458
|
+
app_ref: ApplicationProxy,
|
|
459
|
+
callback: Callable[[TransactionContext], Any],
|
|
460
|
+
idempotency_key: Optional[str] = None,
|
|
461
|
+
) -> Any:
|
|
462
|
+
base_api = _resolve_base_api(app_ref.endpoint)
|
|
463
|
+
app_id = app_ref._app_id
|
|
464
|
+
http_client = app_ref._client.http
|
|
465
|
+
|
|
466
|
+
begin_res = http_client.post(
|
|
467
|
+
_join_url(base_api, f"frontend-apps/{quote(app_id, safe='')}/transactions/begin"),
|
|
468
|
+
json={"runtimeRef": app_ref._runtime_ref, "idempotencyKey": idempotency_key},
|
|
469
|
+
)
|
|
470
|
+
try:
|
|
471
|
+
begin_data = begin_res.json()
|
|
472
|
+
except Exception:
|
|
473
|
+
begin_data = {}
|
|
474
|
+
if not begin_res.is_success:
|
|
475
|
+
raise BBStudioError(
|
|
476
|
+
begin_data.get("code", "DATABASE_ERROR"),
|
|
477
|
+
begin_data.get("message", "Transaction start failed"),
|
|
478
|
+
begin_res.status_code,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
tx_id = begin_data["txId"]
|
|
482
|
+
active = True
|
|
483
|
+
|
|
484
|
+
def tx_op(body: Dict[str, Any]) -> Any:
|
|
485
|
+
if not active:
|
|
486
|
+
raise BBStudioError("TRANSACTION_ABORTED", "Transaction closed")
|
|
487
|
+
r = http_client.post(
|
|
488
|
+
_join_url(base_api, f"frontend-apps/{quote(app_id, safe='')}/transactions/{quote(tx_id, safe='')}/operation"),
|
|
489
|
+
json=body,
|
|
490
|
+
)
|
|
491
|
+
try:
|
|
492
|
+
d = r.json()
|
|
493
|
+
except Exception:
|
|
494
|
+
d = {}
|
|
495
|
+
if not r.is_success:
|
|
496
|
+
raise BBStudioError(d.get("code", "DATABASE_ERROR"), d.get("message", "Operation failed"), r.status_code)
|
|
497
|
+
return d
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
res = callback(TransactionContext(tx_op))
|
|
501
|
+
cr = http_client.post(
|
|
502
|
+
_join_url(base_api, f"frontend-apps/{quote(app_id, safe='')}/transactions/{quote(tx_id, safe='')}/commit"),
|
|
503
|
+
json={"idempotencyKey": idempotency_key},
|
|
504
|
+
)
|
|
505
|
+
try:
|
|
506
|
+
cd = cr.json()
|
|
507
|
+
except Exception:
|
|
508
|
+
cd = {}
|
|
509
|
+
if not cr.is_success or cd.get("committed") is False:
|
|
510
|
+
raise BBStudioError(
|
|
511
|
+
cd.get("code", "DATABASE_ERROR"),
|
|
512
|
+
cd.get("message", "Transaction commit failed"),
|
|
513
|
+
cr.status_code,
|
|
514
|
+
)
|
|
515
|
+
active = False
|
|
516
|
+
return res
|
|
517
|
+
except Exception as err:
|
|
518
|
+
active = False
|
|
519
|
+
try:
|
|
520
|
+
http_client.post(
|
|
521
|
+
_join_url(
|
|
522
|
+
base_api,
|
|
523
|
+
f"frontend-apps/{quote(app_id, safe='')}/transactions/{quote(tx_id, safe='')}/rollback",
|
|
524
|
+
),
|
|
525
|
+
json={"reason": str(err)},
|
|
526
|
+
)
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
raise
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# End-User Authentication (bb_auth)
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
class _BBAuth:
|
|
537
|
+
@staticmethod
|
|
538
|
+
def get_token(client: Optional[BBStudioClient] = None) -> Optional[str]:
|
|
539
|
+
c = client or BBStudio.default_client()
|
|
540
|
+
return c._tokens.get(c.application_id)
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def logout(client: Optional[BBStudioClient] = None) -> None:
|
|
544
|
+
c = client or BBStudio.default_client()
|
|
545
|
+
c._tokens.pop(c.application_id, None)
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def register(payload: Dict[str, Any], client: Optional[BBStudioClient] = None) -> Any:
|
|
549
|
+
c = client or BBStudio.default_client()
|
|
550
|
+
base_api = _resolve_base_api(c.endpoint)
|
|
551
|
+
res = c.http.post(_join_url(base_api, f"app-auth/{quote(c.application_id, safe='')}/register"), json=payload)
|
|
552
|
+
try:
|
|
553
|
+
data = res.json()
|
|
554
|
+
except Exception:
|
|
555
|
+
data = None
|
|
556
|
+
if not res.is_success:
|
|
557
|
+
raise BBStudioError("AUTH_ERROR", (data or {}).get("message", "Registration failed"), res.status_code)
|
|
558
|
+
if isinstance(data, dict) and data.get("token"):
|
|
559
|
+
c._tokens[c.application_id] = data["token"]
|
|
560
|
+
return data
|
|
561
|
+
|
|
562
|
+
@staticmethod
|
|
563
|
+
def login(payload: Dict[str, Any], client: Optional[BBStudioClient] = None) -> Any:
|
|
564
|
+
c = client or BBStudio.default_client()
|
|
565
|
+
base_api = _resolve_base_api(c.endpoint)
|
|
566
|
+
res = c.http.post(_join_url(base_api, f"app-auth/{quote(c.application_id, safe='')}/login"), json=payload)
|
|
567
|
+
try:
|
|
568
|
+
data = res.json()
|
|
569
|
+
except Exception:
|
|
570
|
+
data = None
|
|
571
|
+
if not res.is_success:
|
|
572
|
+
raise BBStudioError("AUTH_ERROR", (data or {}).get("message", "Login failed"), res.status_code)
|
|
573
|
+
if isinstance(data, dict) and data.get("token"):
|
|
574
|
+
c._tokens[c.application_id] = data["token"]
|
|
575
|
+
return data
|
|
576
|
+
|
|
577
|
+
@staticmethod
|
|
578
|
+
def me(client: Optional[BBStudioClient] = None) -> Any:
|
|
579
|
+
c = client or BBStudio.default_client()
|
|
580
|
+
tok = _BBAuth.get_token(c)
|
|
581
|
+
if not tok:
|
|
582
|
+
return None
|
|
583
|
+
base_api = _resolve_base_api(c.endpoint)
|
|
584
|
+
res = c.http.get(
|
|
585
|
+
_join_url(base_api, f"app-auth/{quote(c.application_id, safe='')}/me"),
|
|
586
|
+
headers={"x-bb-auth-token": tok},
|
|
587
|
+
)
|
|
588
|
+
if res.status_code in (401, 403):
|
|
589
|
+
_BBAuth.logout(c)
|
|
590
|
+
return None
|
|
591
|
+
try:
|
|
592
|
+
data = res.json()
|
|
593
|
+
except Exception:
|
|
594
|
+
data = None
|
|
595
|
+
if not res.is_success:
|
|
596
|
+
raise BBStudioError("AUTH_ERROR", (data or {}).get("message", "Failed to load profile"), res.status_code)
|
|
597
|
+
return data
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def update_profile(payload: Dict[str, Any], client: Optional[BBStudioClient] = None) -> Any:
|
|
601
|
+
c = client or BBStudio.default_client()
|
|
602
|
+
tok = _BBAuth.get_token(c)
|
|
603
|
+
if not tok:
|
|
604
|
+
raise BBStudioError("UNAUTHORIZED", "Not signed in.")
|
|
605
|
+
base_api = _resolve_base_api(c.endpoint)
|
|
606
|
+
res = c.http.patch(
|
|
607
|
+
_join_url(base_api, f"app-auth/{quote(c.application_id, safe='')}/me"),
|
|
608
|
+
headers={"x-bb-auth-token": tok},
|
|
609
|
+
json=payload,
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
data = res.json()
|
|
613
|
+
except Exception:
|
|
614
|
+
data = None
|
|
615
|
+
if not res.is_success:
|
|
616
|
+
raise BBStudioError("AUTH_ERROR", (data or {}).get("message", "Failed to update profile"), res.status_code)
|
|
617
|
+
return data
|
|
618
|
+
|
|
619
|
+
getToken = get_token
|
|
620
|
+
updateProfile = update_profile
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
bb_auth = _BBAuth()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# ---------------------------------------------------------------------------
|
|
627
|
+
# File & Media Storage (bb_storage)
|
|
628
|
+
# ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
def bb_upload_file(
|
|
631
|
+
bucket_or_file: Union[str, Any],
|
|
632
|
+
file_obj: Any = None,
|
|
633
|
+
*,
|
|
634
|
+
folder: Optional[str] = None,
|
|
635
|
+
private: bool = False,
|
|
636
|
+
filename: str = "upload.bin",
|
|
637
|
+
client: Optional[BBStudioClient] = None,
|
|
638
|
+
) -> Any:
|
|
639
|
+
c = client or BBStudio._default_client
|
|
640
|
+
bucket_url = bucket_or_file if isinstance(bucket_or_file, str) else None
|
|
641
|
+
actual_file = file_obj if bucket_url else bucket_or_file
|
|
642
|
+
if actual_file is None:
|
|
643
|
+
raise BBStudioError("INVALID_INPUT", "A file object or bytes is required for bb_upload_file.")
|
|
644
|
+
if not bucket_url and c is None:
|
|
645
|
+
raise BBStudioError("INVALID_INPUT", "Bucket URL or initialized BBStudio client is required.")
|
|
646
|
+
|
|
647
|
+
tok = bb_auth.get_token(c) if c else None
|
|
648
|
+
headers = {"x-bb-auth-token": tok} if tok else {}
|
|
649
|
+
target_url = (
|
|
650
|
+
_join_url(bucket_url, "upload")
|
|
651
|
+
if bucket_url
|
|
652
|
+
else _join_url(_resolve_base_api(c.endpoint), f"storage/{quote(c.application_id, safe='')}/upload") # type: ignore[union-attr]
|
|
653
|
+
)
|
|
654
|
+
form_data: Dict[str, str] = {}
|
|
655
|
+
if folder:
|
|
656
|
+
form_data["folder"] = folder
|
|
657
|
+
if private:
|
|
658
|
+
form_data["private"] = "true"
|
|
659
|
+
|
|
660
|
+
fname = getattr(actual_file, "name", filename)
|
|
661
|
+
res = _get_http(c).post(
|
|
662
|
+
target_url,
|
|
663
|
+
headers=headers,
|
|
664
|
+
data=form_data,
|
|
665
|
+
files={"file": (fname, actual_file)},
|
|
666
|
+
)
|
|
667
|
+
try:
|
|
668
|
+
data = res.json()
|
|
669
|
+
except Exception:
|
|
670
|
+
data = None
|
|
671
|
+
if not res.is_success:
|
|
672
|
+
raise BBStudioError("STORAGE_ERROR", (data or {}).get("message", "File upload failed"), res.status_code)
|
|
673
|
+
if isinstance(data, dict) and data.get("id") and not data.get("fileId"):
|
|
674
|
+
data["fileId"] = data["id"]
|
|
675
|
+
return data
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def bb_list_files(bucket_url: str, client: Optional[BBStudioClient] = None) -> Any:
|
|
679
|
+
c = client or BBStudio._default_client
|
|
680
|
+
tok = bb_auth.get_token(c) if c else None
|
|
681
|
+
headers = {"x-bb-auth-token": tok} if tok else {}
|
|
682
|
+
res = _get_http(c).get(_join_url(bucket_url, "files"), headers=headers)
|
|
683
|
+
try:
|
|
684
|
+
data = res.json()
|
|
685
|
+
except Exception:
|
|
686
|
+
data = None
|
|
687
|
+
if not res.is_success:
|
|
688
|
+
raise BBStudioError("STORAGE_ERROR", (data or {}).get("message", "Could not list bucket files"), res.status_code)
|
|
689
|
+
return data
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def bb_get_file_url(
|
|
693
|
+
file_id: str,
|
|
694
|
+
*,
|
|
695
|
+
bucket_url: Optional[str] = None,
|
|
696
|
+
expires_in_seconds: Optional[int] = None,
|
|
697
|
+
client: Optional[BBStudioClient] = None,
|
|
698
|
+
) -> Any:
|
|
699
|
+
c = client or BBStudio._default_client
|
|
700
|
+
tok = bb_auth.get_token(c) if c else None
|
|
701
|
+
headers = {"x-bb-auth-token": tok} if tok else {}
|
|
702
|
+
qs = f"?expiresInSeconds={expires_in_seconds}" if expires_in_seconds else ""
|
|
703
|
+
target_url = (
|
|
704
|
+
_join_url(bucket_url, f"{quote(file_id, safe='')}/url{qs}")
|
|
705
|
+
if bucket_url
|
|
706
|
+
else _join_url(
|
|
707
|
+
_resolve_base_api(c.endpoint), # type: ignore[union-attr]
|
|
708
|
+
f"storage/{quote(c.application_id, safe='')}/{quote(file_id, safe='')}/url{qs}", # type: ignore[union-attr]
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
res = _get_http(c).get(target_url, headers=headers)
|
|
712
|
+
try:
|
|
713
|
+
data = res.json()
|
|
714
|
+
except Exception:
|
|
715
|
+
data = None
|
|
716
|
+
if not res.is_success:
|
|
717
|
+
raise BBStudioError("STORAGE_ERROR", (data or {}).get("message", "Could not resolve file URL"), res.status_code)
|
|
718
|
+
return data
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def bb_delete_file(
|
|
722
|
+
file_id: str,
|
|
723
|
+
*,
|
|
724
|
+
bucket_url: Optional[str] = None,
|
|
725
|
+
client: Optional[BBStudioClient] = None,
|
|
726
|
+
) -> Any:
|
|
727
|
+
c = client or BBStudio._default_client
|
|
728
|
+
tok = bb_auth.get_token(c) if c else None
|
|
729
|
+
headers = {"x-bb-auth-token": tok} if tok else {}
|
|
730
|
+
target_url = (
|
|
731
|
+
_join_url(bucket_url, quote(file_id, safe=""))
|
|
732
|
+
if bucket_url
|
|
733
|
+
else _join_url(
|
|
734
|
+
_resolve_base_api(c.endpoint), # type: ignore[union-attr]
|
|
735
|
+
f"storage/{quote(c.application_id, safe='')}/{quote(file_id, safe='')}", # type: ignore[union-attr]
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
res = _get_http(c).delete(target_url, headers=headers)
|
|
739
|
+
try:
|
|
740
|
+
data = res.json()
|
|
741
|
+
except Exception:
|
|
742
|
+
data = None
|
|
743
|
+
if not res.is_success:
|
|
744
|
+
raise BBStudioError("STORAGE_ERROR", (data or {}).get("message", "Could not delete file"), res.status_code)
|
|
745
|
+
return data
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
bb_uploadFile = bb_upload_file
|
|
749
|
+
bb_listFiles = bb_list_files
|
|
750
|
+
bb_getFileUrl = bb_get_file_url
|
|
751
|
+
bb_deleteFile = bb_delete_file
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
class _BBStorage:
|
|
755
|
+
upload = staticmethod(bb_upload_file)
|
|
756
|
+
list = staticmethod(bb_list_files)
|
|
757
|
+
get_url = staticmethod(bb_get_file_url)
|
|
758
|
+
getUrl = staticmethod(bb_get_file_url)
|
|
759
|
+
delete = staticmethod(bb_delete_file)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
bb_storage = _BBStorage()
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# ---------------------------------------------------------------------------
|
|
766
|
+
# Cloud Functions & Telemetry
|
|
767
|
+
# ---------------------------------------------------------------------------
|
|
768
|
+
|
|
769
|
+
def bb_invoke(
|
|
770
|
+
slug: str,
|
|
771
|
+
payload: Any = None,
|
|
772
|
+
method: str = "POST",
|
|
773
|
+
headers: Optional[Dict[str, str]] = None,
|
|
774
|
+
client: Optional[BBStudioClient] = None,
|
|
775
|
+
) -> Any:
|
|
776
|
+
c = client or BBStudio.default_client()
|
|
777
|
+
base_api = _resolve_base_api(c.endpoint)
|
|
778
|
+
tok = bb_auth.get_token(c)
|
|
779
|
+
req_headers = {
|
|
780
|
+
"Content-Type": "application/json",
|
|
781
|
+
"x-bb-sdk": "1",
|
|
782
|
+
**({"x-bb-auth-token": tok} if tok else {}),
|
|
783
|
+
**(headers or {}),
|
|
784
|
+
}
|
|
785
|
+
url = _join_url(base_api, f"fn/{quote(c.application_id, safe='')}/{quote(slug, safe='')}")
|
|
786
|
+
upper = method.upper()
|
|
787
|
+
t0 = time.monotonic()
|
|
788
|
+
res = c.http.request(
|
|
789
|
+
upper,
|
|
790
|
+
url,
|
|
791
|
+
headers=req_headers,
|
|
792
|
+
json=None if upper in ("GET", "HEAD") else ({} if payload is None else payload),
|
|
793
|
+
)
|
|
794
|
+
latency_ms = int((time.monotonic() - t0) * 1000)
|
|
795
|
+
bb_track("API_CALL", path=f"/fn/{slug}", status_code=res.status_code, latency_ms=latency_ms, client=c)
|
|
796
|
+
try:
|
|
797
|
+
data = res.json()
|
|
798
|
+
except Exception:
|
|
799
|
+
data = None
|
|
800
|
+
if not res.is_success:
|
|
801
|
+
raise BBStudioError(
|
|
802
|
+
(data or {}).get("code", "FUNCTION_ERROR"),
|
|
803
|
+
(data or {}).get("error") or (data or {}).get("message") or f"Cloud Function failed with status {res.status_code}",
|
|
804
|
+
res.status_code,
|
|
805
|
+
)
|
|
806
|
+
return data
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def bb_track(
|
|
810
|
+
event_type: str,
|
|
811
|
+
*,
|
|
812
|
+
path: str = "/",
|
|
813
|
+
status_code: Optional[int] = None,
|
|
814
|
+
latency_ms: Optional[int] = None,
|
|
815
|
+
message: Optional[str] = None,
|
|
816
|
+
client: Optional[BBStudioClient] = None,
|
|
817
|
+
) -> None:
|
|
818
|
+
try:
|
|
819
|
+
c = client or BBStudio._default_client
|
|
820
|
+
if c is None:
|
|
821
|
+
return
|
|
822
|
+
base_api = _resolve_base_api(c.endpoint)
|
|
823
|
+
body: Dict[str, Any] = {"eventType": event_type or "CUSTOM", "path": path}
|
|
824
|
+
if status_code is not None:
|
|
825
|
+
body["statusCode"] = status_code
|
|
826
|
+
if latency_ms is not None:
|
|
827
|
+
body["latencyMs"] = latency_ms
|
|
828
|
+
if message is not None:
|
|
829
|
+
body["message"] = message
|
|
830
|
+
c.http.post(_join_url(base_api, f"telemetry/{quote(c.application_id, safe='')}/ingest"), json=body)
|
|
831
|
+
except Exception:
|
|
832
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bb_studio
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for BB-Studio (Data CRUD, Transactions, End-User Auth, Storage Buckets, Cloud Functions & Telemetry)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://bb.previewbox.dpdns.org
|
|
7
|
+
Keywords: bb-studio,baas,database,auth,storage,cloud-functions
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# `bb_studio` (Python SDK)
|
|
15
|
+
|
|
16
|
+
Official Python client SDK for **BB-Studio**.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install bb_studio
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from bb_studio import (
|
|
28
|
+
BBStudio,
|
|
29
|
+
bb_get,
|
|
30
|
+
bb_create,
|
|
31
|
+
bb_is,
|
|
32
|
+
bb_sort_desc,
|
|
33
|
+
bb_limit,
|
|
34
|
+
bb_auth,
|
|
35
|
+
bb_upload_file,
|
|
36
|
+
bb_invoke,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
bb = BBStudio.initialize(
|
|
40
|
+
application="APP-XXXXXXXX",
|
|
41
|
+
endpoint="https://bb.previewbox.dpdns.org",
|
|
42
|
+
)
|
|
43
|
+
app1 = bb.application("RT-XXXXXXXX")
|
|
44
|
+
|
|
45
|
+
# Query records
|
|
46
|
+
users = bb_get(app1.users, bb_is("status", "ACTIVE"), bb_sort_desc("createdAt"), bb_limit(20))
|
|
47
|
+
|
|
48
|
+
# Authenticate end-user
|
|
49
|
+
bb_auth.login({"email": "alice@example.com", "password": "Secret123!"})
|
|
50
|
+
|
|
51
|
+
# Upload file to granted bucket1
|
|
52
|
+
with open("avatar.png", "rb") as f:
|
|
53
|
+
uploaded = bb_upload_file("https://bb.previewbox.dpdns.org/api/v1/storage/b/BKT-XXXXXXXX", f)
|
|
54
|
+
|
|
55
|
+
# Invoke Cloud Function
|
|
56
|
+
result = bb_invoke("send-welcome", {"name": "Alice"})
|
|
57
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bb_studio/__init__.py
|
|
5
|
+
src/bb_studio/client.py
|
|
6
|
+
src/bb_studio.egg-info/PKG-INFO
|
|
7
|
+
src/bb_studio.egg-info/SOURCES.txt
|
|
8
|
+
src/bb_studio.egg-info/dependency_links.txt
|
|
9
|
+
src/bb_studio.egg-info/requires.txt
|
|
10
|
+
src/bb_studio.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bb_studio
|