databubble 0.2.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.
databubble/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ # databubble/__init__.py
2
+ """
3
+ DataBubble SDK — Statistical Intelligence as a Service.
4
+
5
+ Quick start:
6
+ from databubble import DataBubble
7
+ db = DataBubble(api_key="dbk_...")
8
+ result = db.skills.univariate(df["price"])
9
+ print(result.summary)
10
+ """
11
+
12
+ from databubble.client import DataBubble
13
+ from databubble.models import SkillResult, MemoryResult, ReconciliationResult, JourneyResult
14
+ from databubble.exceptions import (
15
+ DataBubbleError, AuthError, ForbiddenError,
16
+ RateLimitError, SkillError, ServerError, SDKUsageError,
17
+ )
18
+
19
+ __version__ = "0.2.0"
20
+ __all__ = [
21
+ "DataBubble",
22
+ "SkillResult",
23
+ "MemoryResult",
24
+ "ReconciliationResult",
25
+ "JourneyResult",
26
+ "DataBubbleError",
27
+ "AuthError",
28
+ "ForbiddenError",
29
+ "RateLimitError",
30
+ "SkillError",
31
+ "ServerError",
32
+ "SDKUsageError",
33
+ ]
databubble/client.py ADDED
@@ -0,0 +1,204 @@
1
+ # databubble/client.py
2
+ """
3
+ DataBubble — root client.
4
+
5
+ Usage:
6
+ from databubble import DataBubble
7
+ db = DataBubble(api_key="dbk_...")
8
+ result = db.skills.univariate(df["price"])
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Optional
15
+
16
+ from databubble.exceptions import (
17
+ AuthError, ForbiddenError, RateLimitError,
18
+ SkillError, ServerError, DataBubbleError,
19
+ )
20
+ from databubble.skills import SkillsClient
21
+ from databubble.memory import MemoryClient
22
+ from databubble.journeys import JourneysClient
23
+
24
+
25
+ DEFAULT_BASE_URL = "https://api.databubble.ai"
26
+
27
+
28
+ class _HTTPClient:
29
+ """
30
+ Thin HTTP client. Uses httpx if available, falls back to urllib.
31
+ Handles auth header injection and error mapping.
32
+ """
33
+
34
+ def __init__(self, api_key: str, base_url: str, timeout: float):
35
+ self._api_key = api_key
36
+ self._base_url = base_url.rstrip("/")
37
+ self._timeout = timeout
38
+ self._session = None
39
+ self._init_session()
40
+
41
+ def _init_session(self):
42
+ try:
43
+ import httpx
44
+ self._session = httpx.Client(
45
+ base_url=self._base_url,
46
+ headers={"X-API-Key": self._api_key},
47
+ timeout=self._timeout,
48
+ )
49
+ self._backend = "httpx"
50
+ except ImportError:
51
+ # Fall back to urllib — no session, headers injected per-request
52
+ self._backend = "urllib"
53
+
54
+ def _raise_for_status(self, status_code: int, body: dict):
55
+ # Route-level errors wrap in {"detail": {"error": ...}};
56
+ # middleware errors use flat {"error": ...}. Extract from both (M-9).
57
+ detail = body.get("detail") or {}
58
+ msg = (
59
+ (detail.get("error") if isinstance(detail, dict) else None)
60
+ or body.get("error")
61
+ or f"HTTP {status_code}"
62
+ )
63
+ if status_code == 401:
64
+ raise AuthError(msg, status_code, body)
65
+ if status_code == 403:
66
+ raise ForbiddenError(msg, status_code, body)
67
+ if status_code == 429:
68
+ raise RateLimitError(msg, status_code, body)
69
+ if status_code in (400, 422): # 422 was falling through to generic DataBubbleError (D-4)
70
+ raise SkillError(msg, status_code, body)
71
+ if status_code >= 500:
72
+ raise ServerError(f"Server error ({status_code}): {msg}", status_code, body)
73
+ if status_code >= 400:
74
+ raise DataBubbleError(msg, status_code, body)
75
+
76
+ def post_json(self, path: str, payload: dict) -> dict:
77
+ """POST with JSON body. Returns parsed response dict."""
78
+ url = f"{self._base_url}{path}"
79
+
80
+ if self._backend == "httpx":
81
+ response = self._session.post(path, json=payload)
82
+ body = response.json()
83
+ self._raise_for_status(response.status_code, body)
84
+ return body
85
+ else:
86
+ import urllib.request, urllib.error
87
+ data = json.dumps(payload).encode()
88
+ req = urllib.request.Request(
89
+ url, data=data,
90
+ headers={
91
+ "Content-Type": "application/json",
92
+ "X-API-Key": self._api_key,
93
+ },
94
+ method="POST",
95
+ )
96
+ try:
97
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
98
+ return json.loads(resp.read())
99
+ except urllib.error.HTTPError as e:
100
+ body = json.loads(e.read())
101
+ self._raise_for_status(e.code, body)
102
+ # _raise_for_status always raises for 4xx/5xx; this is a safety net
103
+ raise ServerError(f"Unexpected response ({e.code})", e.code, body)
104
+
105
+ def post_multipart(self, path: str, fields: dict, files) -> dict:
106
+ """
107
+ POST with multipart form data. Returns parsed response dict.
108
+
109
+ files may be:
110
+ - dict: {field_name: (filename, data, content_type)} — single file per field
111
+ - list of (field_name, (filename, data, content_type)) — supports repeated field names
112
+ for list[UploadFile] parameters (M-5: memory_files needs repeated "memory_files" key)
113
+ """
114
+ url = f"{self._base_url}{path}"
115
+
116
+ if self._backend == "httpx":
117
+ form_data = {k: v for k, v in fields.items() if v is not None}
118
+ # httpx accepts files as a list of (name, content) tuples for repeated keys
119
+ if isinstance(files, dict):
120
+ file_list = [(k, v) for k, v in files.items()]
121
+ else:
122
+ file_list = list(files)
123
+ response = self._session.post(path, data=form_data, files=file_list)
124
+ body = response.json()
125
+ self._raise_for_status(response.status_code, body)
126
+ return body
127
+ else:
128
+ raise DataBubbleError(
129
+ "Multipart upload requires httpx. "
130
+ "Install with: pip install httpx"
131
+ )
132
+
133
+ def close(self):
134
+ if self._backend == "httpx" and self._session:
135
+ self._session.close()
136
+
137
+ def __enter__(self):
138
+ return self
139
+
140
+ def __exit__(self, *args):
141
+ self.close()
142
+
143
+
144
+ class DataBubble:
145
+ """
146
+ DataBubble API client.
147
+
148
+ Args:
149
+ api_key: Your API key (starts with dbk_). Get one at databubble.ai.
150
+ base_url: API base URL. Defaults to https://api.databubble.ai.
151
+ Override for local development: http://localhost:8000
152
+ timeout: Request timeout in seconds. Default 60.
153
+
154
+ Example:
155
+ from databubble import DataBubble
156
+ db = DataBubble(api_key="dbk_...")
157
+
158
+ # Single-column skill
159
+ result = db.skills.univariate(df["price"])
160
+ print(result.summary)
161
+ print(result.warnings)
162
+
163
+ # Whole-dataset skill
164
+ result = db.skills.missing_values(df)
165
+
166
+ # Memory workflow
167
+ mem = db.memory.export(df, label="POS data June 2026")
168
+ mem.save("pos_memory.json")
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ api_key: Optional[str] = None,
174
+ base_url: str = DEFAULT_BASE_URL,
175
+ timeout: float = 60.0,
176
+ ):
177
+ import os
178
+ resolved_key = api_key or os.environ.get("DATABUBBLE_API_KEY", "")
179
+ if not resolved_key:
180
+ raise ValueError(
181
+ "API key required. Pass api_key= or set DATABUBBLE_API_KEY env var. "
182
+ "Get a key at databubble.ai."
183
+ )
184
+ if not resolved_key.startswith("dbk_"):
185
+ raise ValueError(
186
+ f"Invalid API key format. Keys start with 'dbk_'. Got: {resolved_key[:8]}..."
187
+ )
188
+
189
+ self._http = _HTTPClient(resolved_key, base_url, timeout)
190
+ self.skills = SkillsClient(self._http)
191
+ self.memory = MemoryClient(self._http)
192
+ self.journeys = JourneysClient(self._http)
193
+
194
+ def close(self):
195
+ self._http.close()
196
+
197
+ def __enter__(self):
198
+ return self
199
+
200
+ def __exit__(self, *args):
201
+ self.close()
202
+
203
+ def __repr__(self):
204
+ return f"DataBubble(base_url='{self._http._base_url}')"
@@ -0,0 +1,47 @@
1
+ # databubble/exceptions.py
2
+ """
3
+ Typed exceptions for the DataBubble SDK.
4
+ Every HTTP error from the API maps to a specific exception class
5
+ so callers can handle them explicitly without parsing status codes.
6
+ """
7
+
8
+
9
+ class DataBubbleError(Exception):
10
+ """Base exception for all SDK errors."""
11
+ def __init__(self, message: str, status_code: int = 0, response_body: dict = None):
12
+ super().__init__(message)
13
+ self.status_code = status_code
14
+ self.response_body = response_body or {}
15
+
16
+
17
+ class AuthError(DataBubbleError):
18
+ """Invalid or missing API key. HTTP 401."""
19
+ pass
20
+
21
+
22
+ class ForbiddenError(DataBubbleError):
23
+ """Skill not available on this tier. HTTP 403."""
24
+ pass
25
+
26
+
27
+ class RateLimitError(DataBubbleError):
28
+ """Monthly call limit reached. HTTP 429."""
29
+ pass
30
+
31
+
32
+ class SkillError(DataBubbleError):
33
+ """Skill executed but returned an error (bad input, halted, etc). HTTP 400."""
34
+ pass
35
+
36
+
37
+ class ServerError(DataBubbleError):
38
+ """Unexpected server error. HTTP 5xx."""
39
+ pass
40
+
41
+
42
+ class SDKUsageError(Exception):
43
+ """
44
+ Raised for incorrect SDK usage — wrong argument types, missing required args.
45
+ Not an API error — never reaches the server.
46
+ """
47
+ pass
databubble/journeys.py ADDED
@@ -0,0 +1,359 @@
1
+ # databubble/journeys.py
2
+ """
3
+ JourneysClient — typed methods for DataBubble journey endpoints.
4
+
5
+ Journey endpoints require business or enterprise tier.
6
+ Each method runs a complete end-to-end analytical workflow
7
+ in a single API call and returns a typed JourneyResult.
8
+
9
+ Column map rules (same for all journeys):
10
+ - Keys are role names defined by the journey (e.g. "price_col")
11
+ - Values are actual column names in your DataFrame
12
+ - The SDK builds the row-oriented data payload automatically
13
+
14
+ All methods accept a pd.DataFrame and keyword args for the column map.
15
+ The SDK serialises the DataFrame to the row-oriented JSON format the
16
+ API expects — callers never need to think about the wire format.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Optional
22
+ from databubble.models import JourneyResult
23
+ from databubble.exceptions import SDKUsageError
24
+
25
+
26
+ def _df_to_rows_payload(df, columns: list[str]) -> dict:
27
+ """
28
+ Serialise selected DataFrame columns to row-oriented JSON payload.
29
+ NaN → None (JSON has no NaN concept).
30
+ """
31
+ rows = []
32
+ subset = df[columns]
33
+ for _, row in subset.iterrows():
34
+ rows.append([
35
+ None if (v != v) else v # NaN check without numpy
36
+ for v in row.tolist()
37
+ ])
38
+ return {
39
+ "columns": columns,
40
+ "rows": rows,
41
+ }
42
+
43
+
44
+ def _parse_journey_result(response: dict, journey_type: str) -> JourneyResult:
45
+ """Build a JourneyResult from the API response dict."""
46
+ result = response.get("result", {})
47
+ meta = response.get("_meta", {})
48
+
49
+ # Driver-specific fields
50
+ selection = result.get("selection_output", {})
51
+ recommended = selection.get("recommended", []) or result.get("recommended", [])
52
+ caution = selection.get("caution", []) or result.get("caution", [])
53
+ excluded = selection.get("excluded", []) or result.get("excluded", [])
54
+
55
+ return JourneyResult(
56
+ journey_type=journey_type,
57
+ halted=result.get("halted", False),
58
+ halt_reason=result.get("halt_reason"),
59
+ primary_estimate=result.get("primary_estimate"),
60
+ plain_english_summary=result.get("plain_english_summary", ""),
61
+ warnings=result.get("warnings", []),
62
+ assumptions_met=result.get("assumptions_met"),
63
+ adj_r_squared=result.get("adj_r_squared"),
64
+ revenue_implication=result.get("revenue_implication"),
65
+ recommended=recommended if isinstance(recommended, list) else [],
66
+ caution=caution if isinstance(caution, list) else [],
67
+ excluded=excluded if isinstance(excluded, list) else [],
68
+ tier=meta.get("tier"),
69
+ key_prefix=meta.get("key_prefix"),
70
+ raw=response,
71
+ )
72
+
73
+
74
+ def _require_dataframe(data, method: str):
75
+ try:
76
+ import pandas as pd
77
+ except ImportError:
78
+ raise SDKUsageError("pandas is required. pip install pandas")
79
+ if not isinstance(data, pd.DataFrame):
80
+ raise SDKUsageError(
81
+ f"db.journeys.{method}() requires a pd.DataFrame. "
82
+ f"Got {type(data).__name__}."
83
+ )
84
+
85
+
86
+ def _require_col(df, col: str, arg_name: str, method: str) -> str:
87
+ if col not in df.columns:
88
+ raise SDKUsageError(
89
+ f"db.journeys.{method}(): {arg_name}='{col}' not found in DataFrame. "
90
+ f"Available columns: {list(df.columns)}"
91
+ )
92
+ return col
93
+
94
+
95
+ def _require_col_list(df, cols, arg_name: str, method: str) -> list[str]:
96
+ if isinstance(cols, str):
97
+ cols = [cols]
98
+ missing = [c for c in cols if c not in df.columns]
99
+ if missing:
100
+ raise SDKUsageError(
101
+ f"db.journeys.{method}(): {arg_name} contains columns not in DataFrame: "
102
+ f"{missing}. Available: {list(df.columns)}"
103
+ )
104
+ return list(cols)
105
+
106
+
107
+ class JourneysClient:
108
+ def __init__(self, http_client):
109
+ self._http = http_client
110
+
111
+ def _call(self, endpoint: str, payload: dict, journey_type: str) -> JourneyResult:
112
+ response = self._http.post_json(f"/v1/journeys/{endpoint}", payload)
113
+ return _parse_journey_result(response, journey_type)
114
+
115
+ # -----------------------------------------------------------------------
116
+ # Elasticity
117
+ # -----------------------------------------------------------------------
118
+
119
+ def elasticity(
120
+ self,
121
+ df,
122
+ price_col: str,
123
+ sales_col: str,
124
+ confounder_cols: Optional[list[str]] = None,
125
+ ) -> JourneyResult:
126
+ """
127
+ Price elasticity of demand — log-log regression with full assumption checking.
128
+
129
+ Detects non-linearity, applies log-log transformation if warranted,
130
+ runs OLS, checks RESET + Durbin-Watson + normality, applies HAC
131
+ standard errors when autocorrelation detected, and surfaces a plain-
132
+ English revenue implication.
133
+
134
+ Args:
135
+ df: DataFrame containing price, sales, and any confounders.
136
+ price_col: Column name for the price variable (predictor).
137
+ sales_col: Column name for the sales/demand variable (outcome).
138
+ confounder_cols: Optional list of confounder column names
139
+ (promotions, regional dummies, seasonality flags).
140
+
141
+ Returns:
142
+ JourneyResult with:
143
+ primary_estimate — elasticity coefficient β (log-log)
144
+ revenue_implication — plain-English revenue direction
145
+ assumptions_met — False when RESET, DW, or normality fails
146
+ is_reliable() — quick gate: not halted AND assumptions_met
147
+
148
+ Example:
149
+ result = db.journeys.elasticity(
150
+ df,
151
+ price_col="price",
152
+ sales_col="revenue",
153
+ confounder_cols=["region", "promo_flag"],
154
+ )
155
+ if result.is_reliable():
156
+ print(result.revenue_implication)
157
+ else:
158
+ print(result.warnings)
159
+
160
+ Tier: Business and Enterprise only.
161
+ """
162
+ _require_dataframe(df, "elasticity")
163
+ _require_col(df, price_col, "price_col", "elasticity")
164
+ _require_col(df, sales_col, "sales_col", "elasticity")
165
+ confounders = confounder_cols or []
166
+ if confounders:
167
+ _require_col_list(df, confounders, "confounder_cols", "elasticity")
168
+
169
+ all_cols = list(dict.fromkeys([price_col, sales_col] + confounders))
170
+ payload = {
171
+ "column_map": {
172
+ "price_col": price_col,
173
+ "sales_col": sales_col,
174
+ "confounder_cols": confounders,
175
+ },
176
+ "data": _df_to_rows_payload(df, all_cols),
177
+ "options": {},
178
+ }
179
+ return self._call("elasticity", payload, "elasticity")
180
+
181
+ # -----------------------------------------------------------------------
182
+ # Driver analysis
183
+ # -----------------------------------------------------------------------
184
+
185
+ def driver(
186
+ self,
187
+ df,
188
+ outcome_col: str,
189
+ candidate_cols: list[str],
190
+ ) -> JourneyResult:
191
+ """
192
+ Driver analysis — which variables drive the outcome?
193
+
194
+ Screens candidates for signal, collinearity, and leakage.
195
+ Elects one representative from each collinear cluster.
196
+ Returns recommended, caution, and excluded lists with reasons.
197
+
198
+ Args:
199
+ df: DataFrame containing outcome and all candidates.
200
+ outcome_col: Column name for the variable to explain.
201
+ candidate_cols: List of candidate predictor column names.
202
+
203
+ Returns:
204
+ JourneyResult with:
205
+ recommended — predictors with clean signal
206
+ caution — predictors with collinearity or leakage concerns
207
+ excluded — predictors without sufficient signal
208
+ primary_estimate — effect size of top recommended driver
209
+
210
+ Example:
211
+ result = db.journeys.driver(
212
+ df,
213
+ outcome_col="sales",
214
+ candidate_cols=["price", "promotion", "region", "advertising"],
215
+ )
216
+ print(result.recommended)
217
+ print(result.caution)
218
+ print(result.primary_estimate)
219
+
220
+ Tier: Business and Enterprise only.
221
+ """
222
+ _require_dataframe(df, "driver")
223
+ _require_col(df, outcome_col, "outcome_col", "driver")
224
+ candidates = _require_col_list(df, candidate_cols, "candidate_cols", "driver")
225
+
226
+ all_cols = list(dict.fromkeys([outcome_col] + candidates))
227
+ payload = {
228
+ "column_map": {
229
+ "outcome_col": outcome_col,
230
+ "candidate_cols": candidates,
231
+ },
232
+ "data": _df_to_rows_payload(df, all_cols),
233
+ "options": {},
234
+ }
235
+ return self._call("driver", payload, "driver")
236
+
237
+ # -----------------------------------------------------------------------
238
+ # Segmentation
239
+ # -----------------------------------------------------------------------
240
+
241
+ def segmentation(
242
+ self,
243
+ df,
244
+ feature_cols: list[str],
245
+ label_col: Optional[str] = None,
246
+ ) -> JourneyResult:
247
+ """
248
+ Customer segmentation — discovery or classification mode.
249
+
250
+ Discovery mode (no label_col): finds natural groups via clustering.
251
+ Classification mode (with label_col): trains a classifier to predict
252
+ pre-defined segments and scores new observations.
253
+
254
+ Args:
255
+ df: DataFrame containing features and optionally labels.
256
+ feature_cols: Columns to use as segmentation features.
257
+ label_col: Pre-defined segment column (classification mode).
258
+ Omit for discovery mode.
259
+
260
+ Returns:
261
+ JourneyResult with:
262
+ Discovery: silhouette score, cluster profiles in plain_english_summary
263
+ Classification: accuracy, F1, confusion matrix summary in plain_english_summary
264
+
265
+ Example:
266
+ # Discovery
267
+ result = db.journeys.segmentation(
268
+ df, feature_cols=["recency", "frequency", "spend"]
269
+ )
270
+
271
+ # Classification
272
+ result = db.journeys.segmentation(
273
+ df,
274
+ feature_cols=["recency", "frequency", "spend"],
275
+ label_col="segment",
276
+ )
277
+
278
+ Tier: Business and Enterprise only.
279
+ """
280
+ _require_dataframe(df, "segmentation")
281
+ features = _require_col_list(df, feature_cols, "feature_cols", "segmentation")
282
+ if label_col:
283
+ _require_col(df, label_col, "label_col", "segmentation")
284
+
285
+ all_cols = list(dict.fromkeys(
286
+ features + ([label_col] if label_col else [])
287
+ ))
288
+ column_map = {"feature_cols": features}
289
+ if label_col:
290
+ column_map["label_col"] = label_col
291
+
292
+ payload = {
293
+ "column_map": column_map,
294
+ "data": _df_to_rows_payload(df, all_cols),
295
+ "options": {},
296
+ }
297
+ return self._call("segmentation", payload, "segmentation")
298
+
299
+ # -----------------------------------------------------------------------
300
+ # Time series
301
+ # -----------------------------------------------------------------------
302
+
303
+ def time_series(
304
+ self,
305
+ df,
306
+ date_col: str,
307
+ value_col: str,
308
+ objective: str = "forecast",
309
+ ) -> JourneyResult:
310
+ """
311
+ Time series forecasting and decomposition.
312
+
313
+ Validates the time index, fills gaps, selects between ETS and SARIMA
314
+ via AICc, produces prediction intervals from the model's native PI
315
+ method, and validates against a seasonal naïve benchmark via walk-
316
+ forward cross-validation.
317
+
318
+ Args:
319
+ df: DataFrame with date and value columns.
320
+ date_col: Column name for the date/timestamp variable.
321
+ value_col: Column name for the series to model.
322
+ objective: "forecast" (default) or "decompose".
323
+ Decompose returns trend, seasonal, and remainder components.
324
+
325
+ Returns:
326
+ JourneyResult with:
327
+ Forecast: primary_estimate = horizon-1 point forecast,
328
+ plain_english_summary includes PI and benchmark comparison
329
+ Decompose: plain_english_summary describes trend and seasonal pattern
330
+
331
+ Example:
332
+ result = db.journeys.time_series(
333
+ df,
334
+ date_col="week",
335
+ value_col="weekly_sales",
336
+ objective="forecast",
337
+ )
338
+ print(result.primary_estimate) # h=1 point forecast
339
+ print(result.plain_english_summary)
340
+
341
+ Tier: Business and Enterprise only.
342
+ """
343
+ _require_dataframe(df, "time_series")
344
+ _require_col(df, date_col, "date_col", "time_series")
345
+ _require_col(df, value_col, "value_col", "time_series")
346
+ if objective not in ("forecast", "decompose"):
347
+ raise SDKUsageError(
348
+ f"objective must be 'forecast' or 'decompose'. Got: '{objective}'"
349
+ )
350
+
351
+ payload = {
352
+ "column_map": {
353
+ "date_col": date_col,
354
+ "value_col": value_col,
355
+ },
356
+ "data": _df_to_rows_payload(df, [date_col, value_col]),
357
+ "options": {"objective": objective},
358
+ }
359
+ return self._call("time_series", payload, "time_series")