bb-studio 1.0.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
bb_studio/client.py ADDED
@@ -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,7 @@
1
+ bb_studio/__init__.py,sha256=wLZ2xG1pqduFbWzv8wMcNUxB91RWoadKccPuMIwNeKA,2467
2
+ bb_studio/client.py,sha256=Zhn9bbMYGKjE2QPBDP6Masog_MfTHZ4LU0F1SPGQbHg,28257
3
+ bb_studio-1.0.0.dist-info/licenses/LICENSE,sha256=keKPU_HsHCjH4NB4V02-4mho9rD2TEb9ko8SZVr3qzA,1066
4
+ bb_studio-1.0.0.dist-info/METADATA,sha256=rWcL99uVomgpbgBJ1HbgLKwpwjjfw4iBTJKNusIlV14,1368
5
+ bb_studio-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ bb_studio-1.0.0.dist-info/top_level.txt,sha256=5gPoQ3nJSolm-e0cz4qd2Ru6VVUjIUxmsRFGcjT63OE,10
7
+ bb_studio-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ bb_studio