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.
- {databar-2.5.0/src/databar.egg-info → databar-2.6.0}/PKG-INFO +6 -3
- {databar-2.5.0 → databar-2.6.0}/README.md +5 -2
- {databar-2.5.0 → databar-2.6.0}/pyproject.toml +1 -1
- {databar-2.5.0 → databar-2.6.0}/src/databar/__init__.py +7 -1
- databar-2.6.0/src/databar/cli/_auth.py +187 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/_guide.py +2 -1
- databar-2.6.0/src/databar/cli/_oauth.py +190 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/_output.py +18 -2
- {databar-2.5.0 → databar-2.6.0}/src/databar/client.py +53 -2
- {databar-2.5.0 → databar-2.6.0}/src/databar/models.py +30 -0
- {databar-2.5.0 → databar-2.6.0/src/databar.egg-info}/PKG-INFO +6 -3
- {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/SOURCES.txt +3 -1
- {databar-2.5.0 → databar-2.6.0}/tests/test_cli.py +51 -1
- {databar-2.5.0 → databar-2.6.0}/tests/test_client.py +60 -0
- databar-2.6.0/tests/test_oauth_login.py +61 -0
- databar-2.5.0/src/databar/cli/_auth.py +0 -125
- {databar-2.5.0 → databar-2.6.0}/LICENSE +0 -0
- {databar-2.5.0 → databar-2.6.0}/setup.cfg +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/__init__.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/_onboard.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/app.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/enrichments.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/flows.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/tables.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/tasks.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/cli/waterfalls.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar/exceptions.py +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/dependency_links.txt +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/entry_points.txt +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/requires.txt +0 -0
- {databar-2.5.0 → databar-2.6.0}/src/databar.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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` —
|
|
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 =
|
|
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={
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|