databar 2.5.0__tar.gz → 2.6.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.
Files changed (32) hide show
  1. {databar-2.5.0/src/databar.egg-info → databar-2.6.0}/PKG-INFO +6 -3
  2. {databar-2.5.0 → databar-2.6.0}/README.md +5 -2
  3. {databar-2.5.0 → databar-2.6.0}/pyproject.toml +1 -1
  4. {databar-2.5.0 → databar-2.6.0}/src/databar/__init__.py +7 -1
  5. databar-2.6.0/src/databar/cli/_auth.py +187 -0
  6. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/_guide.py +2 -1
  7. databar-2.6.0/src/databar/cli/_oauth.py +190 -0
  8. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/_output.py +18 -2
  9. {databar-2.5.0 → databar-2.6.0}/src/databar/client.py +53 -2
  10. {databar-2.5.0 → databar-2.6.0}/src/databar/models.py +30 -0
  11. {databar-2.5.0 → databar-2.6.0/src/databar.egg-info}/PKG-INFO +6 -3
  12. {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/SOURCES.txt +3 -1
  13. {databar-2.5.0 → databar-2.6.0}/tests/test_cli.py +51 -1
  14. {databar-2.5.0 → databar-2.6.0}/tests/test_client.py +60 -0
  15. databar-2.6.0/tests/test_oauth_login.py +61 -0
  16. databar-2.5.0/src/databar/cli/_auth.py +0 -125
  17. {databar-2.5.0 → databar-2.6.0}/LICENSE +0 -0
  18. {databar-2.5.0 → databar-2.6.0}/setup.cfg +0 -0
  19. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/__init__.py +0 -0
  20. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/_onboard.py +0 -0
  21. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/app.py +0 -0
  22. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/enrichments.py +0 -0
  23. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/flows.py +0 -0
  24. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/tables.py +0 -0
  25. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/tasks.py +0 -0
  26. {databar-2.5.0 → databar-2.6.0}/src/databar/cli/waterfalls.py +0 -0
  27. {databar-2.5.0 → databar-2.6.0}/src/databar/exceptions.py +0 -0
  28. {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/dependency_links.txt +0 -0
  29. {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/entry_points.txt +0 -0
  30. {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/requires.txt +0 -0
  31. {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/top_level.txt +0 -0
  32. {databar-2.5.0 → databar-2.6.0}/tests/test_new_features.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databar
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Databar.ai Python SDK and CLI — connect to enrichments, waterfalls, and tables via api.databar.ai
5
5
  Author-email: "Databar.ai Team" <info@databar.ai>
6
6
  License: MIT License
@@ -81,7 +81,8 @@ Get your API key from [databar.ai](https://databar.ai) → **Integrations**.
81
81
  ```bash
82
82
  databar login
83
83
  ```
84
- Saves your key to `~/.databar/config`.
84
+ Opens the browser to sign in and saves your workspace API key to `~/.databar/config`.
85
+ For CI or agents, use `databar login --api-key <key>` instead.
85
86
 
86
87
  **Option 2 — Environment variable:**
87
88
  ```bash
@@ -423,7 +424,9 @@ After installing, the `databar` command is available in your terminal.
423
424
  ### Authentication
424
425
 
425
426
  ```bash
426
- databar login # save API key interactively
427
+ databar login # browser OAuth → save API key
428
+ databar login --api-key … # paste a key (CI / agents)
429
+ databar logout # remove saved key
427
430
  databar whoami # show name, email, balance, plan
428
431
  databar whoami --format json
429
432
  ```
@@ -26,7 +26,8 @@ Get your API key from [databar.ai](https://databar.ai) → **Integrations**.
26
26
  ```bash
27
27
  databar login
28
28
  ```
29
- Saves your key to `~/.databar/config`.
29
+ Opens the browser to sign in and saves your workspace API key to `~/.databar/config`.
30
+ For CI or agents, use `databar login --api-key <key>` instead.
30
31
 
31
32
  **Option 2 — Environment variable:**
32
33
  ```bash
@@ -368,7 +369,9 @@ After installing, the `databar` command is available in your terminal.
368
369
  ### Authentication
369
370
 
370
371
  ```bash
371
- databar login # save API key interactively
372
+ databar login # browser OAuth → save API key
373
+ databar login --api-key … # paste a key (CI / agents)
374
+ databar logout # remove saved key
372
375
  databar whoami # show name, email, balance, plan
373
376
  databar whoami --format json
374
377
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databar"
7
- version = "2.5.0"
7
+ version = "2.6.0"
8
8
  description = "Official Databar.ai Python SDK and CLI — connect to enrichments, waterfalls, and tables via api.databar.ai"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -63,8 +63,11 @@ from .models import (
63
63
  WaterfallEnrichment,
64
64
  # Flows
65
65
  Flow,
66
+ FlowConfigError,
66
67
  FlowConfigOpsResult,
68
+ FlowCostEstimate,
67
69
  FlowDetail,
70
+ FlowValidateResult,
68
71
  FlowInput,
69
72
  FlowOutput,
70
73
  FlowVersion,
@@ -107,7 +110,7 @@ from .models import (
107
110
  Folder,
108
111
  )
109
112
 
110
- __version__ = "2.5.0"
113
+ __version__ = "2.6.0"
111
114
  __all__ = [
112
115
  "DatabarClient",
113
116
  # exceptions
@@ -147,8 +150,11 @@ __all__ = [
147
150
  "WaterfallEnrichment",
148
151
  # flows
149
152
  "Flow",
153
+ "FlowConfigError",
150
154
  "FlowConfigOpsResult",
155
+ "FlowCostEstimate",
151
156
  "FlowDetail",
157
+ "FlowValidateResult",
152
158
  "FlowInput",
153
159
  "FlowOutput",
154
160
  "FlowVersion",
@@ -0,0 +1,187 @@
1
+ """
2
+ API key management and auth-related CLI commands.
3
+
4
+ Key resolution order (same as MCP server):
5
+ 1. DATABAR_API_KEY environment variable
6
+ 2. ~/.databar/config file
7
+ 3. Error with helpful message pointing to `databar login`
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import typer
18
+
19
+ from databar.client import DatabarClient
20
+ from databar.exceptions import DatabarError
21
+
22
+ from ._output import console, error, output, success, OutputFormat
23
+
24
+ CONFIG_DIR = Path.home() / ".databar"
25
+ CONFIG_FILE = CONFIG_DIR / "config"
26
+
27
+ _KEY_PREFIX = "api_key="
28
+
29
+
30
+ def get_api_key() -> str:
31
+ """
32
+ Resolve the API key using the standard priority order.
33
+ Exits with a helpful error if no key is found.
34
+ """
35
+ key = os.environ.get("DATABAR_API_KEY")
36
+ if key:
37
+ return key.strip()
38
+
39
+ if CONFIG_FILE.exists():
40
+ for line in CONFIG_FILE.read_text().splitlines():
41
+ if line.startswith(_KEY_PREFIX):
42
+ key = line[len(_KEY_PREFIX) :].strip()
43
+ if key:
44
+ return key
45
+
46
+ error(
47
+ "No API key found.\n"
48
+ " Run [bold]databar login[/bold] to sign in via browser, or set the "
49
+ "[bold]DATABAR_API_KEY[/bold] environment variable.\n"
50
+ " Or paste a key with [bold]databar login --api-key[/bold].",
51
+ code="auth_missing",
52
+ )
53
+ raise typer.Exit(1) # unreachable but satisfies type checkers
54
+
55
+
56
+ def get_client() -> DatabarClient:
57
+ """Return a configured DatabarClient using the resolved API key."""
58
+ return DatabarClient(api_key=get_api_key(), client_source="cli")
59
+
60
+
61
+ def _save_api_key(api_key: str) -> None:
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ # Preserve non-key lines (preferred_interface, etc.) when rewriting.
64
+ other_lines: list[str] = []
65
+ if CONFIG_FILE.exists():
66
+ for line in CONFIG_FILE.read_text().splitlines():
67
+ if not line.startswith(_KEY_PREFIX):
68
+ other_lines.append(line)
69
+ body = "\n".join([f"{_KEY_PREFIX}{api_key}", *other_lines]).rstrip() + "\n"
70
+ CONFIG_FILE.write_text(body)
71
+ CONFIG_FILE.chmod(0o600)
72
+
73
+
74
+ def _path_hint() -> None:
75
+ if shutil.which("databar") is not None:
76
+ return
77
+ import sys
78
+
79
+ bin_dir = Path(sys.executable).parent
80
+ console.print(
81
+ f"\n[yellow]Note:[/yellow] The [bold]databar[/bold] command is not on your PATH.\n"
82
+ f"Add this to your shell profile ([dim]~/.zshrc[/dim] or [dim]~/.bashrc[/dim]):\n\n"
83
+ f' [bold]export PATH="{bin_dir}:$PATH"[/bold]\n\n'
84
+ f"Then restart your terminal, or run:\n\n"
85
+ f" [bold]source ~/.zshrc[/bold]\n\n"
86
+ f"Until then, use the full path: [bold]{bin_dir}/databar[/bold]"
87
+ )
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # CLI commands
92
+ # ---------------------------------------------------------------------------
93
+
94
+ app = typer.Typer(help="Authentication commands.")
95
+
96
+
97
+ @app.command("login")
98
+ def login(
99
+ api_key: Optional[str] = typer.Option(
100
+ None,
101
+ "--api-key",
102
+ "-k",
103
+ help="Paste an API key instead of opening the browser.",
104
+ hide_input=True,
105
+ ),
106
+ ) -> None:
107
+ """Sign in to Databar (opens the browser) and save your API key."""
108
+ if api_key is not None:
109
+ # Explicit --api-key (possibly empty → interactive paste).
110
+ if not api_key:
111
+ api_key = typer.prompt("Enter your Databar API key", hide_input=True)
112
+ api_key = api_key.strip()
113
+ if not api_key:
114
+ error("API key cannot be empty.")
115
+ _save_api_key(api_key)
116
+ success(f"API key saved to {CONFIG_FILE}")
117
+ console.print("[dim]Tip: You can also set DATABAR_API_KEY as an environment variable.[/dim]")
118
+ _path_hint()
119
+ return
120
+
121
+ from ._oauth import run_browser_login
122
+
123
+ def _print_url(url: str, *, opened: bool) -> None:
124
+ if opened:
125
+ console.print("[dim]Opening browser for Databar login…[/dim]")
126
+ else:
127
+ console.print("Open this URL in your browser to continue:")
128
+ console.print(f" {url}")
129
+
130
+ try:
131
+ creds = run_browser_login(print_url=_print_url)
132
+ except RuntimeError as exc:
133
+ error(str(exc))
134
+
135
+ _save_api_key(creds["api_key"])
136
+ email = creds.get("email") or ""
137
+ if email:
138
+ success(f"Logged in as {email}")
139
+ else:
140
+ success(f"API key saved to {CONFIG_FILE}")
141
+ console.print(f"[dim]Credentials saved to {CONFIG_FILE}[/dim]")
142
+ _path_hint()
143
+
144
+
145
+ @app.command("logout")
146
+ def logout() -> None:
147
+ """Remove the saved API key from ~/.databar/config."""
148
+ if not CONFIG_FILE.exists():
149
+ console.print("[dim]Already logged out — no config file found.[/dim]")
150
+ return
151
+
152
+ other_lines = [
153
+ line
154
+ for line in CONFIG_FILE.read_text().splitlines()
155
+ if not line.startswith(_KEY_PREFIX)
156
+ ]
157
+ if other_lines:
158
+ CONFIG_FILE.write_text("\n".join(other_lines).rstrip() + "\n")
159
+ CONFIG_FILE.chmod(0o600)
160
+ else:
161
+ CONFIG_FILE.unlink(missing_ok=True)
162
+
163
+ success("Logged out — API key removed from local config.")
164
+
165
+
166
+ @app.command("whoami")
167
+ def whoami(
168
+ fmt: OutputFormat = typer.Option(
169
+ OutputFormat.TABLE, "--format", "--output", "-f", help="Output format."
170
+ ),
171
+ ) -> None:
172
+ """Show current user info and credit balance."""
173
+ client = get_client()
174
+ try:
175
+ user = client.get_user()
176
+ except DatabarError as e:
177
+ error(e)
178
+ finally:
179
+ client.close()
180
+
181
+ data = {
182
+ "name": user.first_name or "(no name)",
183
+ "email": user.email,
184
+ "balance": f"{user.balance:.2f} credits",
185
+ "plan": user.plan,
186
+ }
187
+ output(data, fmt)
@@ -51,7 +51,8 @@ databar whoami --format json # verify
51
51
  ```
52
52
 
53
53
  Never run `databar onboard` — interactive wizard, not for agents.
54
- Never run `databar login` without `--api-key` — will hang on terminal input.
54
+ Never run `databar login` without `--api-key` — opens a browser OAuth flow
55
+ agents cannot complete. Always pass the key non-interactively.
55
56
 
56
57
  ---
57
58
 
@@ -0,0 +1,190 @@
1
+ """Browser OAuth 2.1 (PKCE) login for `databar login`.
2
+
3
+ Opens the existing Databar consent screen, catches the authorization code on a
4
+ local loopback redirect, exchanges it for a short-lived bearer token, then
5
+ fetches the workspace API key and writes it to ~/.databar/config.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import os
13
+ import secrets
14
+ import threading
15
+ import webbrowser
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from typing import Optional
18
+ from urllib.parse import parse_qs, urlencode, urlparse
19
+
20
+ import httpx
21
+
22
+ # Must match oauth.0002_seed_cli_oauth_application / settings.DATABAR_CLI_*.
23
+ OAUTH_CLIENT_ID = "databar-cli"
24
+ OAUTH_REDIRECT_URI = "http://127.0.0.1:53682/callback"
25
+ OAUTH_CALLBACK_PORT = 53682
26
+ OAUTH_SCOPE = "cli:access"
27
+ DEFAULT_OAUTH_ISSUER = "https://databar.ai"
28
+ _LOGIN_TIMEOUT_S = 300
29
+
30
+
31
+ def _oauth_issuer() -> str:
32
+ return (os.environ.get("DATABAR_OAUTH_ISSUER") or DEFAULT_OAUTH_ISSUER).rstrip("/")
33
+
34
+
35
+ def _pkce_pair() -> tuple[str, str]:
36
+ verifier = secrets.token_urlsafe(64)
37
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
38
+ return verifier, challenge
39
+
40
+
41
+ class _CallbackResult:
42
+ def __init__(self) -> None:
43
+ self.code: Optional[str] = None
44
+ self.state: Optional[str] = None
45
+ self.error: Optional[str] = None
46
+ self.error_description: Optional[str] = None
47
+ self.event = threading.Event()
48
+
49
+
50
+ def _make_handler(result: _CallbackResult, expected_state: str):
51
+ class Handler(BaseHTTPRequestHandler):
52
+ def log_message(self, format: str, *args) -> None: # noqa: A003
53
+ return
54
+
55
+ def do_GET(self) -> None: # noqa: N802
56
+ parsed = urlparse(self.path)
57
+ if parsed.path.rstrip("/") != "/callback":
58
+ self.send_response(404)
59
+ self.end_headers()
60
+ return
61
+
62
+ qs = parse_qs(parsed.query)
63
+ result.error = (qs.get("error") or [None])[0]
64
+ result.error_description = (qs.get("error_description") or [None])[0]
65
+ result.code = (qs.get("code") or [None])[0]
66
+ result.state = (qs.get("state") or [None])[0]
67
+
68
+ if result.error:
69
+ body = (
70
+ b"<html><body><h1>Login failed</h1>"
71
+ b"<p>You can close this window and return to the terminal.</p></body></html>"
72
+ )
73
+ self.send_response(400)
74
+ elif result.state != expected_state:
75
+ result.error = "invalid_state"
76
+ result.error_description = "OAuth state mismatch"
77
+ body = (
78
+ b"<html><body><h1>Login failed</h1>"
79
+ b"<p>State mismatch. Close this window and try again.</p></body></html>"
80
+ )
81
+ self.send_response(400)
82
+ else:
83
+ body = (
84
+ b"<html><body><h1>Logged in</h1>"
85
+ b"<p>You can close this window and return to the terminal.</p></body></html>"
86
+ )
87
+ self.send_response(200)
88
+
89
+ self.send_header("Content-Type", "text/html; charset=utf-8")
90
+ self.send_header("Content-Length", str(len(body)))
91
+ self.end_headers()
92
+ self.wfile.write(body)
93
+ result.event.set()
94
+
95
+ return Handler
96
+
97
+
98
+ def _exchange_code(issuer: str, code: str, verifier: str) -> str:
99
+ response = httpx.post(
100
+ f"{issuer}/oauth/token/",
101
+ data={
102
+ "grant_type": "authorization_code",
103
+ "code": code,
104
+ "redirect_uri": OAUTH_REDIRECT_URI,
105
+ "client_id": OAUTH_CLIENT_ID,
106
+ "code_verifier": verifier,
107
+ },
108
+ headers={"Accept": "application/json"},
109
+ timeout=30.0,
110
+ )
111
+ if response.status_code != 200:
112
+ detail = response.text[:200]
113
+ raise RuntimeError(f"Token exchange failed ({response.status_code}): {detail}")
114
+ payload = response.json()
115
+ access_token = payload.get("access_token")
116
+ if not access_token:
117
+ raise RuntimeError("Token exchange response missing access_token")
118
+ return access_token
119
+
120
+
121
+ def _fetch_api_key(issuer: str, access_token: str) -> dict:
122
+ response = httpx.get(
123
+ f"{issuer}/oauth/api/cli-credentials/",
124
+ headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
125
+ timeout=30.0,
126
+ )
127
+ if response.status_code != 200:
128
+ detail = response.text[:200]
129
+ raise RuntimeError(f"Could not fetch API key ({response.status_code}): {detail}")
130
+ payload = response.json()
131
+ if not payload.get("api_key"):
132
+ raise RuntimeError("Credentials response missing api_key")
133
+ return payload
134
+
135
+
136
+ def run_browser_login(*, print_url=None) -> dict:
137
+ """Run the full browser OAuth flow.
138
+
139
+ Returns credentials dict with ``api_key`` and ``email``.
140
+ ``print_url`` is called with the authorize URL (so the CLI can show it if
141
+ the browser fails to open).
142
+ """
143
+ issuer = _oauth_issuer()
144
+ verifier, challenge = _pkce_pair()
145
+ state = secrets.token_urlsafe(24)
146
+ result = _CallbackResult()
147
+
148
+ try:
149
+ server = HTTPServer(("127.0.0.1", OAUTH_CALLBACK_PORT), _make_handler(result, state))
150
+ except OSError as exc:
151
+ raise RuntimeError(
152
+ f"Port {OAUTH_CALLBACK_PORT} is already in use. "
153
+ f"Close the other process and run `databar login` again."
154
+ ) from exc
155
+
156
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
157
+ thread.start()
158
+
159
+ params = {
160
+ "client_id": OAUTH_CLIENT_ID,
161
+ "redirect_uri": OAUTH_REDIRECT_URI,
162
+ "response_type": "code",
163
+ "scope": OAUTH_SCOPE,
164
+ "code_challenge": challenge,
165
+ "code_challenge_method": "S256",
166
+ "state": state,
167
+ }
168
+ authorize_url = f"{issuer}/oauth/authorize/?{urlencode(params)}"
169
+
170
+ opened = webbrowser.open(authorize_url)
171
+ if print_url is not None:
172
+ print_url(authorize_url, opened=opened)
173
+
174
+ if not result.event.wait(timeout=_LOGIN_TIMEOUT_S):
175
+ server.shutdown()
176
+ thread.join(timeout=2)
177
+ raise RuntimeError("Timed out waiting for browser login. Run `databar login` again.")
178
+
179
+ server.shutdown()
180
+ thread.join(timeout=2)
181
+
182
+ if result.error:
183
+ desc = result.error_description or result.error
184
+ raise RuntimeError(f"Login was denied or failed: {desc}")
185
+
186
+ if not result.code:
187
+ raise RuntimeError("No authorization code received from the browser callback.")
188
+
189
+ access_token = _exchange_code(issuer, result.code, verifier)
190
+ return _fetch_api_key(issuer, access_token)
@@ -19,7 +19,6 @@ from enum import Enum
19
19
  from pathlib import Path
20
20
  from typing import Any, NoReturn, Optional, Union
21
21
 
22
- import click
23
22
  import typer
24
23
  from rich import print_json as rich_print_json
25
24
  from rich.console import Console
@@ -79,9 +78,26 @@ _HINTS = {
79
78
  }
80
79
 
81
80
 
81
+ def _cli_context():
82
+ """Active Typer context, or None.
83
+
84
+ Typer 0.26+ vendors Click (no ``click`` extra). Older typer uses the
85
+ standalone package. Private ``typer._click`` is the only way to read
86
+ the live context after they vendored; fall back to ``click`` for <0.26.
87
+ """
88
+ try:
89
+ from typer._click.globals import get_current_context
90
+ except ImportError:
91
+ try:
92
+ from click import get_current_context
93
+ except ImportError:
94
+ return None
95
+ return get_current_context(silent=True)
96
+
97
+
82
98
  def _current_format() -> OutputFormat:
83
99
  """Read --format from the active Click/Typer context, defaulting to table."""
84
- ctx = click.get_current_context(silent=True)
100
+ ctx = _cli_context()
85
101
  if ctx is None:
86
102
  return OutputFormat.TABLE
87
103
  fmt = ctx.params.get("fmt")
@@ -15,7 +15,7 @@ Endpoint groups:
15
15
  - Enrichments: list_enrichments, get_enrichment, run_enrichment[_bulk][_sync],
16
16
  get_param_choices
17
17
  - Waterfalls: list_waterfalls, get_waterfall, run_waterfall[_bulk][_sync]
18
- - Flows: list_flows, get_flow, run_flow[_sync]
18
+ - Flows: list_flows, get_flow, run_flow[_bulk][_sync]
19
19
  - Tables: create_table, list_tables, delete_table, rename_table,
20
20
  get_columns, create_column, rename_column, delete_column,
21
21
  get_table_enrichments, add_enrichment, run_table_enrichment,
@@ -75,7 +75,9 @@ from .models import (
75
75
  ExporterResponseField,
76
76
  Flow,
77
77
  FlowConfigOpsResult,
78
+ FlowCostEstimate,
78
79
  FlowDetail,
80
+ FlowValidateResult,
79
81
  FlowVersion,
80
82
  FlowVersionDetail,
81
83
  RestoreFlowVersionResult,
@@ -127,6 +129,7 @@ class DatabarClient:
127
129
  timeout: float = 30.0,
128
130
  max_poll_attempts: int = 150,
129
131
  poll_interval_s: float = 2.0,
132
+ client_source: str = "sdk",
130
133
  ) -> None:
131
134
  resolved_key = api_key or os.environ.get("DATABAR_API_KEY")
132
135
  if not resolved_key:
@@ -146,9 +149,20 @@ class DatabarClient:
146
149
  self._timeout = timeout
147
150
  self._max_poll_attempts = max_poll_attempts
148
151
  self._poll_interval_s = poll_interval_s
152
+ from databar import __version__
153
+
154
+ source = client_source if client_source in {"sdk", "cli"} else "sdk"
155
+ user_agent = f"databar-python/{__version__}"
156
+ if source == "cli":
157
+ user_agent = f"{user_agent} cli"
149
158
  self._http = httpx.Client(
150
159
  base_url=self._base_url,
151
- headers={"x-apikey": self._api_key, "Content-Type": "application/json"},
160
+ headers={
161
+ "x-apikey": self._api_key,
162
+ "Content-Type": "application/json",
163
+ "User-Agent": user_agent,
164
+ "X-Source": source,
165
+ },
152
166
  timeout=self._timeout,
153
167
  )
154
168
 
@@ -709,6 +723,25 @@ class DatabarClient:
709
723
  data = self._request("PATCH", f"/flows/{flow_id}/config", json=payload)
710
724
  return FlowConfigOpsResult.model_validate(data)
711
725
 
726
+ def validate_flow_config(self, config: Dict[str, Any]) -> FlowValidateResult:
727
+ """Check a (possibly unsaved) config without persisting anything.
728
+
729
+ Same checks ``create_flow()`` / ``update_flow()`` run on save. An
730
+ invalid config does not raise: ``valid`` is False and ``errors``
731
+ names the node when one is at fault.
732
+ """
733
+ data = self._request("POST", "/flows/validate-config", json={"config": config})
734
+ return FlowValidateResult.model_validate(data)
735
+
736
+ def estimate_flow_cost(self, config: Dict[str, Any]) -> FlowCostEstimate:
737
+ """Credit-cost range of one run of a (possibly unsaved) config.
738
+
739
+ ``min``/``max`` because waterfalls stop at the first success and
740
+ conditions skip branches. Call this before ``run_flow()``.
741
+ """
742
+ data = self._request("POST", "/flows/cost-estimate", json={"config": config})
743
+ return FlowCostEstimate.model_validate(data)
744
+
712
745
  def delete_flow(self, flow_id: str) -> None:
713
746
  """Delete a flow.
714
747
 
@@ -765,6 +798,24 @@ class DatabarClient:
765
798
  task = self.run_flow(flow_id, inputs)
766
799
  return self.poll_task(task.task_id)
767
800
 
801
+ def run_flow_bulk(self, flow_id: str, inputs: List[Dict[str, str]]) -> RunResponse:
802
+ """Submit a flow over many input sets. Returns one task.
803
+
804
+ The polled result is aligned to the inputs: one element per input, in the
805
+ same order as ``inputs``, with ``None`` for inputs that returned no data.
806
+ """
807
+ data = self._request("POST", f"/flows/{flow_id}/bulk-run", json={"inputs": inputs})
808
+ return RunResponse.model_validate(data)
809
+
810
+ def run_flow_bulk_sync(self, flow_id: str, inputs: List[Dict[str, str]]) -> Any:
811
+ """Submit and poll a bulk flow run, returning final data when complete.
812
+
813
+ Returns a list aligned to ``inputs``: one element per input in input
814
+ order, ``None`` for misses (``len(result) == len(inputs)``).
815
+ """
816
+ task = self.run_flow_bulk(flow_id, inputs)
817
+ return self.poll_task(task.task_id)
818
+
768
819
  # -----------------------------------------------------------------------
769
820
  # Tables — CRUD
770
821
  # -----------------------------------------------------------------------
@@ -450,6 +450,36 @@ class FlowConfigOpsResult(BaseModel):
450
450
  flow: Optional[FlowDetail] = None
451
451
 
452
452
 
453
+ class FlowConfigError(BaseModel):
454
+ """One reason a flow config would be rejected on save."""
455
+
456
+ node_id: Optional[str] = None
457
+ message: str
458
+
459
+
460
+ class FlowValidateResult(BaseModel):
461
+ """Result of validate_flow_config().
462
+
463
+ An invalid config is a valid answer — ``valid`` is False and ``errors``
464
+ names the node (when known) rather than raising.
465
+ """
466
+
467
+ valid: bool
468
+ errors: List[FlowConfigError] = Field(default_factory=list)
469
+
470
+
471
+ class FlowCostEstimate(BaseModel):
472
+ """Result of estimate_flow_cost().
473
+
474
+ ``min``/``max`` because waterfalls stop at the first success and
475
+ conditions skip branches. Both are credit amounts as strings.
476
+ """
477
+
478
+ min: str
479
+ max: str
480
+ currency: str = "credits"
481
+
482
+
453
483
  class RestoreFlowVersionResult(BaseModel):
454
484
  """Result of restore_flow_version().
455
485
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databar
3
- Version: 2.5.0
3
+ Version: 2.6.0
4
4
  Summary: Official Databar.ai Python SDK and CLI — connect to enrichments, waterfalls, and tables via api.databar.ai
5
5
  Author-email: "Databar.ai Team" <info@databar.ai>
6
6
  License: MIT License
@@ -81,7 +81,8 @@ Get your API key from [databar.ai](https://databar.ai) → **Integrations**.
81
81
  ```bash
82
82
  databar login
83
83
  ```
84
- Saves your key to `~/.databar/config`.
84
+ Opens the browser to sign in and saves your workspace API key to `~/.databar/config`.
85
+ For CI or agents, use `databar login --api-key <key>` instead.
85
86
 
86
87
  **Option 2 — Environment variable:**
87
88
  ```bash
@@ -423,7 +424,9 @@ After installing, the `databar` command is available in your terminal.
423
424
  ### Authentication
424
425
 
425
426
  ```bash
426
- databar login # save API key interactively
427
+ databar login # browser OAuth → save API key
428
+ databar login --api-key … # paste a key (CI / agents)
429
+ databar logout # remove saved key
427
430
  databar whoami # show name, email, balance, plan
428
431
  databar whoami --format json
429
432
  ```
@@ -14,6 +14,7 @@ src/databar.egg-info/top_level.txt
14
14
  src/databar/cli/__init__.py
15
15
  src/databar/cli/_auth.py
16
16
  src/databar/cli/_guide.py
17
+ src/databar/cli/_oauth.py
17
18
  src/databar/cli/_onboard.py
18
19
  src/databar/cli/_output.py
19
20
  src/databar/cli/app.py
@@ -24,4 +25,5 @@ src/databar/cli/tasks.py
24
25
  src/databar/cli/waterfalls.py
25
26
  tests/test_cli.py
26
27
  tests/test_client.py
27
- tests/test_new_features.py
28
+ tests/test_new_features.py
29
+ tests/test_oauth_login.py
@@ -175,6 +175,49 @@ def test_login_saves_key(tmp_path, monkeypatch):
175
175
  assert "my-secret-key" in config_file.read_text()
176
176
 
177
177
 
178
+ def test_login_oauth_saves_key(tmp_path, monkeypatch):
179
+ config_file = tmp_path / "config"
180
+ monkeypatch.setattr("databar.cli._auth.CONFIG_DIR", tmp_path)
181
+ monkeypatch.setattr("databar.cli._auth.CONFIG_FILE", config_file)
182
+ monkeypatch.setattr(
183
+ "databar.cli._oauth.run_browser_login",
184
+ lambda print_url=None: {"api_key": "oauth-key", "email": "ada@example.com"},
185
+ )
186
+ result = runner.invoke(app, ["login"])
187
+ assert result.exit_code == 0
188
+ assert "ada@example.com" in result.output
189
+ assert "api_key=oauth-key" in config_file.read_text()
190
+
191
+
192
+ def test_login_oauth_preserves_other_config_lines(tmp_path, monkeypatch):
193
+ config_file = tmp_path / "config"
194
+ config_file.write_text("preferred_interface=cli\napi_key=old\n")
195
+ monkeypatch.setattr("databar.cli._auth.CONFIG_DIR", tmp_path)
196
+ monkeypatch.setattr("databar.cli._auth.CONFIG_FILE", config_file)
197
+ monkeypatch.setattr(
198
+ "databar.cli._oauth.run_browser_login",
199
+ lambda print_url=None: {"api_key": "new-key", "email": "ada@example.com"},
200
+ )
201
+ result = runner.invoke(app, ["login"])
202
+ assert result.exit_code == 0
203
+ text = config_file.read_text()
204
+ assert "api_key=new-key" in text
205
+ assert "preferred_interface=cli" in text
206
+ assert "api_key=old" not in text
207
+
208
+
209
+ def test_logout_removes_key(tmp_path, monkeypatch):
210
+ config_file = tmp_path / "config"
211
+ config_file.write_text("api_key=secret\npreferred_interface=cli\n")
212
+ monkeypatch.setattr("databar.cli._auth.CONFIG_DIR", tmp_path)
213
+ monkeypatch.setattr("databar.cli._auth.CONFIG_FILE", config_file)
214
+ result = runner.invoke(app, ["logout"])
215
+ assert result.exit_code == 0
216
+ text = config_file.read_text()
217
+ assert "api_key=" not in text
218
+ assert "preferred_interface=cli" in text
219
+
220
+
178
221
  def test_whoami_table(monkeypatch):
179
222
  mock = _client_mock()
180
223
  monkeypatch.setattr("databar.cli._auth.get_client", lambda: mock)
@@ -716,4 +759,11 @@ def test_task_cancel_reports_a_finished_task(monkeypatch):
716
759
  def test_version_flag():
717
760
  result = runner.invoke(app, ["--version"])
718
761
  assert result.exit_code == 0
719
- assert "2.5.0" in result.output
762
+ assert "2.6.0" in result.output
763
+
764
+
765
+ def test_output_module_does_not_import_click():
766
+ # Typer 0.26+ vendors Click; a top-level `import click` crashes a fresh install.
767
+ import databar.cli._output as out
768
+
769
+ assert "click" not in out.__dict__
@@ -68,6 +68,20 @@ def test_client_reads_env_var(monkeypatch):
68
68
  c.close()
69
69
 
70
70
 
71
+ def test_client_identifies_as_sdk():
72
+ c = DatabarClient(api_key="key")
73
+ assert c._http.headers["X-Source"] == "sdk"
74
+ assert str(c._http.headers["User-Agent"]).startswith("databar-python/")
75
+ c.close()
76
+
77
+
78
+ def test_client_identifies_as_cli():
79
+ c = DatabarClient(api_key="key", client_source="cli")
80
+ assert c._http.headers["X-Source"] == "cli"
81
+ assert "cli" in str(c._http.headers["User-Agent"])
82
+ c.close()
83
+
84
+
71
85
  def test_client_context_manager(httpx_mock: HTTPXMock):
72
86
  httpx_mock.add_response(url=f"{BASE_URL}/user/me", json=user_payload())
73
87
  with DatabarClient(api_key="key") as c:
@@ -242,6 +256,25 @@ def test_run_flow_sync(client: DatabarClient, httpx_mock: HTTPXMock):
242
256
  assert result == {"full_name": "Alice"}
243
257
 
244
258
 
259
+ def test_run_flow_bulk_returns_task(client: DatabarClient, httpx_mock: HTTPXMock):
260
+ httpx_mock.add_response(url=f"{BASE_URL}/flows/flow-uuid-1/bulk-run", json=task_payload("processing"))
261
+ task = client.run_flow_bulk("flow-uuid-1", [{"email": "a@x.com"}, {"email": "b@x.com"}])
262
+ assert task.task_id == "task-123"
263
+ req = httpx_mock.get_requests()[-1]
264
+ body = json.loads(req.content)
265
+ assert body == {"inputs": [{"email": "a@x.com"}, {"email": "b@x.com"}]}
266
+
267
+
268
+ def test_run_flow_bulk_sync(client: DatabarClient, httpx_mock: HTTPXMock):
269
+ httpx_mock.add_response(url=f"{BASE_URL}/flows/flow-uuid-1/bulk-run", json=task_payload("processing"))
270
+ httpx_mock.add_response(
271
+ url=f"{BASE_URL}/tasks/task-123",
272
+ json=task_payload("completed", data=[{"full_name": "Alice"}, None]),
273
+ )
274
+ result = client.run_flow_bulk_sync("flow-uuid-1", [{"email": "a@x.com"}, {"email": "b@x.com"}])
275
+ assert result == [{"full_name": "Alice"}, None]
276
+
277
+
245
278
  # ===========================================================================
246
279
  # Tables
247
280
  # ===========================================================================
@@ -381,6 +414,33 @@ def test_patch_flow_config_sends_the_op_batch(client: DatabarClient, httpx_mock:
381
414
  assert body == {"ops": [{"op": "remove_node", "node_id": "m1"}], "validate_only": False}
382
415
 
383
416
 
417
+ def test_validate_flow_config_posts_the_config(client: DatabarClient, httpx_mock: HTTPXMock):
418
+ httpx_mock.add_response(
419
+ url=f"{BASE_URL}/flows/validate-config",
420
+ method="POST",
421
+ json={"valid": False, "errors": [{"node_id": "cond1", "message": "bad formula"}]},
422
+ )
423
+ result = client.validate_flow_config({"nodes": []})
424
+ assert result.valid is False
425
+ assert result.errors[0].node_id == "cond1"
426
+ body = json.loads(httpx_mock.get_requests()[-1].content)
427
+ assert body == {"config": {"nodes": []}}
428
+
429
+
430
+ def test_estimate_flow_cost_posts_the_config(client: DatabarClient, httpx_mock: HTTPXMock):
431
+ httpx_mock.add_response(
432
+ url=f"{BASE_URL}/flows/cost-estimate",
433
+ method="POST",
434
+ json={"min": "0", "max": "2", "currency": "credits"},
435
+ )
436
+ result = client.estimate_flow_cost({"nodes": []})
437
+ assert result.min == "0"
438
+ assert result.max == "2"
439
+ assert result.currency == "credits"
440
+ body = json.loads(httpx_mock.get_requests()[-1].content)
441
+ assert body == {"config": {"nodes": []}}
442
+
443
+
384
444
  def test_patch_flow_config_validate_only_returns_no_flow(client: DatabarClient, httpx_mock: HTTPXMock):
385
445
  httpx_mock.add_response(
386
446
  url=f"{BASE_URL}/flows/flow-uuid-1/config",
@@ -0,0 +1,61 @@
1
+ """Unit tests for CLI OAuth helpers (token exchange + credentials fetch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import pytest
7
+ import respx
8
+
9
+ from databar.cli._oauth import (
10
+ OAUTH_CLIENT_ID,
11
+ OAUTH_REDIRECT_URI,
12
+ _exchange_code,
13
+ _fetch_api_key,
14
+ _pkce_pair,
15
+ )
16
+
17
+
18
+ def test_pkce_pair_s256_shape():
19
+ verifier, challenge = _pkce_pair()
20
+ assert len(verifier) > 40
21
+ assert len(challenge) >= 43
22
+ assert "=" not in challenge
23
+
24
+
25
+ @respx.mock
26
+ def test_exchange_code_posts_pkce_fields():
27
+ route = respx.post("https://databar.ai/oauth/token/").mock(
28
+ return_value=httpx.Response(200, json={"access_token": "tok", "token_type": "Bearer"})
29
+ )
30
+ token = _exchange_code("https://databar.ai", "auth-code", "verifier")
31
+ assert token == "tok"
32
+ assert route.called
33
+ request = route.calls.last.request
34
+ body = request.content.decode()
35
+ assert "grant_type=authorization_code" in body
36
+ assert "code=auth-code" in body
37
+ assert f"client_id={OAUTH_CLIENT_ID}" in body
38
+ assert "code_verifier=verifier" in body
39
+ assert OAUTH_REDIRECT_URI.replace(":", "%3A").replace("/", "%2F") in body or "127.0.0.1" in body
40
+
41
+
42
+ @respx.mock
43
+ def test_fetch_api_key_sends_bearer():
44
+ respx.get("https://databar.ai/oauth/api/cli-credentials/").mock(
45
+ return_value=httpx.Response(
46
+ 200,
47
+ json={"api_key": "k", "email": "a@b.com", "workspace_identifier": "w", "workspace_name": "W"},
48
+ )
49
+ )
50
+ payload = _fetch_api_key("https://databar.ai", "tok")
51
+ assert payload["api_key"] == "k"
52
+ assert payload["email"] == "a@b.com"
53
+
54
+
55
+ @respx.mock
56
+ def test_exchange_code_raises_on_error():
57
+ respx.post("https://databar.ai/oauth/token/").mock(
58
+ return_value=httpx.Response(400, text="bad")
59
+ )
60
+ with pytest.raises(RuntimeError, match="Token exchange failed"):
61
+ _exchange_code("https://databar.ai", "bad", "v")
@@ -1,125 +0,0 @@
1
- """
2
- API key management and auth-related CLI commands.
3
-
4
- Key resolution order (same as MCP server):
5
- 1. DATABAR_API_KEY environment variable
6
- 2. ~/.databar/config file
7
- 3. Error with helpful message pointing to `databar login`
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import os
13
- import shutil
14
- from pathlib import Path
15
-
16
- import typer
17
-
18
- from databar.client import DatabarClient
19
- from databar.exceptions import DatabarError
20
-
21
- from ._output import console, error, output, success, OutputFormat
22
-
23
- CONFIG_DIR = Path.home() / ".databar"
24
- CONFIG_FILE = CONFIG_DIR / "config"
25
-
26
- _KEY_PREFIX = "api_key="
27
-
28
-
29
- def get_api_key() -> str:
30
- """
31
- Resolve the API key using the standard priority order.
32
- Exits with a helpful error if no key is found.
33
- """
34
- key = os.environ.get("DATABAR_API_KEY")
35
- if key:
36
- return key.strip()
37
-
38
- if CONFIG_FILE.exists():
39
- for line in CONFIG_FILE.read_text().splitlines():
40
- if line.startswith(_KEY_PREFIX):
41
- key = line[len(_KEY_PREFIX):].strip()
42
- if key:
43
- return key
44
-
45
- error(
46
- "No API key found.\n"
47
- " Run [bold]databar login[/bold] to save your key, or set the "
48
- "[bold]DATABAR_API_KEY[/bold] environment variable.\n"
49
- " Get your key at [link=https://databar.ai]databar.ai[/link] → Integrations.",
50
- code="auth_missing",
51
- )
52
- raise typer.Exit(1) # unreachable but satisfies type checkers
53
-
54
-
55
- def get_client() -> DatabarClient:
56
- """Return a configured DatabarClient using the resolved API key."""
57
- return DatabarClient(api_key=get_api_key())
58
-
59
-
60
- # ---------------------------------------------------------------------------
61
- # CLI commands
62
- # ---------------------------------------------------------------------------
63
-
64
- app = typer.Typer(help="Authentication commands.")
65
-
66
-
67
- @app.command("login")
68
- def login(
69
- api_key: str = typer.Option(
70
- None,
71
- "--api-key",
72
- "-k",
73
- help="Your Databar API key. Prompted interactively if not provided.",
74
- hide_input=True,
75
- )
76
- ) -> None:
77
- """Save your Databar API key to ~/.databar/config."""
78
- if not api_key:
79
- api_key = typer.prompt("Enter your Databar API key", hide_input=True)
80
-
81
- api_key = api_key.strip()
82
- if not api_key:
83
- error("API key cannot be empty.")
84
-
85
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
86
- CONFIG_FILE.write_text(f"{_KEY_PREFIX}{api_key}\n")
87
- CONFIG_FILE.chmod(0o600)
88
-
89
- success(f"API key saved to {CONFIG_FILE}")
90
- console.print("[dim]Tip: You can also set DATABAR_API_KEY as an environment variable.[/dim]")
91
-
92
- # Check if the databar binary is on PATH and print a hint if not
93
- if shutil.which("databar") is None:
94
- import sys
95
- bin_dir = Path(sys.executable).parent
96
- console.print(
97
- f"\n[yellow]Note:[/yellow] The [bold]databar[/bold] command is not on your PATH.\n"
98
- f"Add this to your shell profile ([dim]~/.zshrc[/dim] or [dim]~/.bashrc[/dim]):\n\n"
99
- f" [bold]export PATH=\"{bin_dir}:$PATH\"[/bold]\n\n"
100
- f"Then restart your terminal, or run:\n\n"
101
- f" [bold]source ~/.zshrc[/bold]\n\n"
102
- f"Until then, use the full path: [bold]{bin_dir}/databar[/bold]"
103
- )
104
-
105
-
106
- @app.command("whoami")
107
- def whoami(
108
- fmt: OutputFormat = typer.Option(OutputFormat.TABLE, "--format", "--output", "-f", help="Output format.")
109
- ) -> None:
110
- """Show current user info and credit balance."""
111
- client = get_client()
112
- try:
113
- user = client.get_user()
114
- except DatabarError as e:
115
- error(e)
116
- finally:
117
- client.close()
118
-
119
- data = {
120
- "name": user.first_name or "(no name)",
121
- "email": user.email,
122
- "balance": f"{user.balance:.2f} credits",
123
- "plan": user.plan,
124
- }
125
- output(data, fmt)
File without changes
File without changes
File without changes