datalegion-cli 1.2.2__tar.gz → 1.3.1__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.
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/PKG-INFO +12 -2
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/README.md +11 -1
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/pyproject.toml +1 -1
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/config.py +1 -0
- datalegion_cli-1.3.1/src/datalegion_cli/login.py +151 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/main.py +78 -6
- datalegion_cli-1.3.1/tests/test_login.py +238 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/.gitignore +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/__init__.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/async_typer.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/client.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/__init__.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/company.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/credits.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/person.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/commands/utility.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/logging.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/output.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/src/datalegion_cli/update.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/__init__.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/conftest.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_client.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_commands.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_config.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_logging.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_output.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_package_contents.py +0 -0
- {datalegion_cli-1.2.2 → datalegion_cli-1.3.1}/tests/test_update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: datalegion-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.1
|
|
4
4
|
Summary: CLI for the Data Legion API — agent-friendly, fully async
|
|
5
5
|
Project-URL: Homepage, https://www.datalegion.ai
|
|
6
6
|
Project-URL: Documentation, https://www.datalegion.ai/docs
|
|
@@ -96,7 +96,17 @@ uv sync
|
|
|
96
96
|
|
|
97
97
|
## Configuration
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
Sign in with your Data Legion account (admin or developer role) and the CLI stores a key for this machine:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
datalegion-cli login # opens a sign-in link; confirm the code it shows
|
|
103
|
+
datalegion-cli login --team-id 7 # when your account has several teams
|
|
104
|
+
datalegion-cli login --no-browser # headless: prints the link to open on another device
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The key belongs to this machine, named with a random id (`cli-` plus 8 hex characters) that the CLI generates on first login and keeps in its config, so nothing about the machine (such as its hostname) is sent. Pass `--device <name>` for a readable label instead. Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
|
|
108
|
+
|
|
109
|
+
Or set an API key yourself (stored encrypted on disk):
|
|
100
110
|
|
|
101
111
|
```bash
|
|
102
112
|
datalegion-cli config set api_key legion_your_key_here
|
|
@@ -62,7 +62,17 @@ uv sync
|
|
|
62
62
|
|
|
63
63
|
## Configuration
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Sign in with your Data Legion account (admin or developer role) and the CLI stores a key for this machine:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
datalegion-cli login # opens a sign-in link; confirm the code it shows
|
|
69
|
+
datalegion-cli login --team-id 7 # when your account has several teams
|
|
70
|
+
datalegion-cli login --no-browser # headless: prints the link to open on another device
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The key belongs to this machine, named with a random id (`cli-` plus 8 hex characters) that the CLI generates on first login and keeps in its config, so nothing about the machine (such as its hostname) is sent. Pass `--device <name>` for a readable label instead. Signing in again replaces it, and you can disconnect it from Connected Apps in the dashboard.
|
|
74
|
+
|
|
75
|
+
Or set an API key yourself (stored encrypted on disk):
|
|
66
76
|
|
|
67
77
|
```bash
|
|
68
78
|
datalegion-cli config set api_key legion_your_key_here
|
|
@@ -27,6 +27,7 @@ _DEFAULTS: dict[str, object] = {
|
|
|
27
27
|
"api_key": "",
|
|
28
28
|
"default_output": "json", # json | table | raw
|
|
29
29
|
"install_method": "", # brew | github | git | pip
|
|
30
|
+
"device_id": "", # random name for this machine's login key (see login.new_device_id)
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
# Fields that are stored encrypted on disk
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Sign in from the terminal and get a key for this machine (OAuth device authorization).
|
|
2
|
+
|
|
3
|
+
``datalegion-cli login`` asks Data Legion's sign-in server (WorkOS AuthKit) for a
|
|
4
|
+
device code, shows the person a link and a short code to approve, polls until they
|
|
5
|
+
do, then trades the access token at ``POST /agent/key`` for an API key that belongs
|
|
6
|
+
to this machine. The key is stored encrypted like one set with ``config set``.
|
|
7
|
+
Signing in again on the same machine replaces its key; the person can disconnect it
|
|
8
|
+
from the Connected Apps page in the dashboard.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
# The sign-in server and the CLI's OAuth app ("Data Legion CLI", a public PKCE app;
|
|
25
|
+
# the id is not a secret). Overridable for testing against another environment.
|
|
26
|
+
SIGNIN_URL = os.environ.get("LEGION_SIGNIN_URL", "https://signin.datalegion.ai")
|
|
27
|
+
CLI_CLIENT_ID = os.environ.get("LEGION_CLI_CLIENT_ID", "client_01M4ABTVGMNFRS52XMF8YDC1H5")
|
|
28
|
+
|
|
29
|
+
_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
30
|
+
# What the API accepts as a device name: words separated by single spaces.
|
|
31
|
+
_DEVICE_OK = re.compile(r"^[\w.@:-]+( [\w.@:-]+)*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LoginError(Exception):
|
|
35
|
+
"""Sign-in failed; the message is fit to show the person."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, *, code: str = "login_failed", details: Any = None):
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.code = code
|
|
40
|
+
self.details = details
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def new_device_id() -> str:
|
|
44
|
+
"""A random name for this machine's key: ``cli-`` plus 8 hex characters.
|
|
45
|
+
|
|
46
|
+
Random rather than the hostname (or a hash of it): hostnames often carry a
|
|
47
|
+
person's name and are guessable, so nothing about the machine leaves it. Saved
|
|
48
|
+
in the config on first login and reused, so signing in again replaces this
|
|
49
|
+
machine's key instead of adding another.
|
|
50
|
+
"""
|
|
51
|
+
return f"cli-{secrets.token_hex(4)}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_device_name(name: str) -> str:
|
|
55
|
+
"""Return the name if the API will accept it, else raise LoginError."""
|
|
56
|
+
if not (1 <= len(name) <= 100) or not _DEVICE_OK.match(name):
|
|
57
|
+
raise LoginError(
|
|
58
|
+
"Device names use letters, digits and . _ @ : - (single spaces between words), "
|
|
59
|
+
"up to 100 characters.",
|
|
60
|
+
code="invalid_device",
|
|
61
|
+
)
|
|
62
|
+
return name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def start_device_authorization(client: httpx.AsyncClient) -> dict[str, Any]:
|
|
66
|
+
"""Ask the sign-in server for a device code and the link the person approves at."""
|
|
67
|
+
resp = await client.post(
|
|
68
|
+
f"{SIGNIN_URL}/oauth2/device_authorization",
|
|
69
|
+
data={"client_id": CLI_CLIENT_ID, "scope": "openid"},
|
|
70
|
+
)
|
|
71
|
+
if resp.status_code != 200:
|
|
72
|
+
raise LoginError(f"Sign-in could not start ({resp.status_code}): {_oauth_error(resp)}")
|
|
73
|
+
return resp.json()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def poll_for_token(
|
|
77
|
+
client: httpx.AsyncClient,
|
|
78
|
+
device: dict[str, Any],
|
|
79
|
+
*,
|
|
80
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Poll until the person approves (RFC 8628), returning the access token."""
|
|
83
|
+
interval = float(device.get("interval") or 5)
|
|
84
|
+
deadline = asyncio.get_running_loop().time() + float(device.get("expires_in") or 300)
|
|
85
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
86
|
+
await sleep(interval)
|
|
87
|
+
resp = await client.post(
|
|
88
|
+
f"{SIGNIN_URL}/oauth2/token",
|
|
89
|
+
data={
|
|
90
|
+
"grant_type": _DEVICE_GRANT,
|
|
91
|
+
"device_code": device["device_code"],
|
|
92
|
+
"client_id": CLI_CLIENT_ID,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
if resp.status_code == 200:
|
|
96
|
+
token = resp.json().get("access_token")
|
|
97
|
+
if token:
|
|
98
|
+
return str(token)
|
|
99
|
+
raise LoginError("Sign-in returned no access token.")
|
|
100
|
+
error = _oauth_error(resp)
|
|
101
|
+
if error == "authorization_pending":
|
|
102
|
+
continue
|
|
103
|
+
if error == "slow_down":
|
|
104
|
+
interval += 5
|
|
105
|
+
continue
|
|
106
|
+
if error == "access_denied":
|
|
107
|
+
raise LoginError("Sign-in was declined.", code="access_denied")
|
|
108
|
+
if error == "expired_token":
|
|
109
|
+
break
|
|
110
|
+
raise LoginError(f"Sign-in failed: {error}")
|
|
111
|
+
raise LoginError("The sign-in code expired. Run `datalegion-cli login` again.", code="expired")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def exchange_for_key(
|
|
115
|
+
client: httpx.AsyncClient,
|
|
116
|
+
api_base_url: str,
|
|
117
|
+
access_token: str,
|
|
118
|
+
device_name: str,
|
|
119
|
+
team_id: int | None,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Trade the access token for this machine's API key (POST /agent/key)."""
|
|
122
|
+
body: dict[str, Any] = {"device": device_name}
|
|
123
|
+
if team_id is not None:
|
|
124
|
+
body["team_id"] = team_id
|
|
125
|
+
resp = await client.post(
|
|
126
|
+
f"{api_base_url.rstrip('/')}/agent/key",
|
|
127
|
+
json=body,
|
|
128
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
payload = resp.json()
|
|
132
|
+
except ValueError:
|
|
133
|
+
payload = {}
|
|
134
|
+
if resp.status_code == 200 and payload.get("api_key"):
|
|
135
|
+
return payload
|
|
136
|
+
message = payload.get("message") or f"Getting a key failed ({resp.status_code})."
|
|
137
|
+
details = payload.get("details")
|
|
138
|
+
if resp.status_code == 409:
|
|
139
|
+
message = "This account has several teams. Run again with --team-id <id> to choose one."
|
|
140
|
+
raise LoginError(message, code="choose_team", details=details)
|
|
141
|
+
raise LoginError(
|
|
142
|
+
message, code=str(payload.get("error") or "key_exchange_failed"), details=details
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _oauth_error(resp: httpx.Response) -> str:
|
|
147
|
+
try:
|
|
148
|
+
body = resp.json()
|
|
149
|
+
except ValueError:
|
|
150
|
+
return resp.text[:200] or str(resp.status_code)
|
|
151
|
+
return str(body.get("error") or body.get("message") or resp.status_code)
|
|
@@ -10,9 +10,9 @@ from loguru import logger
|
|
|
10
10
|
from datalegion_cli import __version__
|
|
11
11
|
from datalegion_cli.async_typer import async_command
|
|
12
12
|
from datalegion_cli.commands import company, credits, person, utility
|
|
13
|
-
from datalegion_cli.config import load_config, save_config
|
|
13
|
+
from datalegion_cli.config import get_base_url, load_config, save_config
|
|
14
14
|
from datalegion_cli.logging import setup_logging
|
|
15
|
-
from datalegion_cli.output import emit_json
|
|
15
|
+
from datalegion_cli.output import console, emit_error, emit_json
|
|
16
16
|
from datalegion_cli.update import check_for_update, self_update
|
|
17
17
|
|
|
18
18
|
app = typer.Typer(
|
|
@@ -79,8 +79,7 @@ async def config(
|
|
|
79
79
|
# Mask the API key for display
|
|
80
80
|
display = dict(cfg)
|
|
81
81
|
if display.get("api_key"):
|
|
82
|
-
|
|
83
|
-
display["api_key"] = k[:10] + "..." + k[-4:] if len(k) > 14 else "***"
|
|
82
|
+
display["api_key"] = _mask(str(display["api_key"]))
|
|
84
83
|
emit_json(display)
|
|
85
84
|
return
|
|
86
85
|
|
|
@@ -104,13 +103,86 @@ async def config(
|
|
|
104
103
|
else:
|
|
105
104
|
cfg[key] = value
|
|
106
105
|
save_config(cfg)
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
shown = _mask(str(cfg[key])) if key in _SECRET_KEYS else cfg[key]
|
|
107
|
+
logger.info("Set {} = {}", key, shown)
|
|
108
|
+
emit_json({"ok": True, "key": key, "value": shown})
|
|
109
109
|
return
|
|
110
110
|
|
|
111
111
|
raise typer.BadParameter(f"Unknown action: {action}. Use get, set, list, or path.")
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
_SECRET_KEYS = {"api_key"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _mask(value: str) -> str:
|
|
118
|
+
"""Show enough of a secret to recognize it, never the whole value."""
|
|
119
|
+
return value[:10] + "..." + value[-4:] if len(value) > 14 else "***"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
@async_command
|
|
124
|
+
async def login(
|
|
125
|
+
team_id: Annotated[
|
|
126
|
+
int | None, typer.Option("--team-id", help="Team the key bills, when you have several.")
|
|
127
|
+
] = None,
|
|
128
|
+
device: Annotated[
|
|
129
|
+
str | None,
|
|
130
|
+
typer.Option(
|
|
131
|
+
"--device", help="Name for this machine (default: a random id saved locally)."
|
|
132
|
+
),
|
|
133
|
+
] = None,
|
|
134
|
+
no_browser: Annotated[
|
|
135
|
+
bool, typer.Option("--no-browser", help="Print the sign-in link without opening it.")
|
|
136
|
+
] = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Sign in with your Data Legion account and store a key for this machine.
|
|
139
|
+
|
|
140
|
+
Opens a sign-in link (approve the code it shows), then saves an API key that
|
|
141
|
+
belongs to this machine. Signing in again replaces it. Disconnect it any time
|
|
142
|
+
from Connected Apps in the dashboard.
|
|
143
|
+
"""
|
|
144
|
+
import webbrowser
|
|
145
|
+
|
|
146
|
+
import httpx
|
|
147
|
+
|
|
148
|
+
from datalegion_cli import login as flow
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
cfg = load_config()
|
|
152
|
+
if not device and not cfg.get("device_id"):
|
|
153
|
+
cfg["device_id"] = flow.new_device_id()
|
|
154
|
+
save_config(cfg)
|
|
155
|
+
device_name = flow.check_device_name(device or str(cfg["device_id"]))
|
|
156
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
157
|
+
started = await flow.start_device_authorization(client)
|
|
158
|
+
link = started.get("verification_uri_complete") or started["verification_uri"]
|
|
159
|
+
console.print(
|
|
160
|
+
f"Open [bold]{link}[/bold] and confirm the code [bold]{started['user_code']}[/bold]."
|
|
161
|
+
)
|
|
162
|
+
if not no_browser:
|
|
163
|
+
webbrowser.open(link)
|
|
164
|
+
token = await flow.poll_for_token(client, started)
|
|
165
|
+
issued = await flow.exchange_for_key(
|
|
166
|
+
client, get_base_url(), token, device_name, team_id
|
|
167
|
+
)
|
|
168
|
+
except flow.LoginError as exc:
|
|
169
|
+
emit_error(str(exc), code=exc.code, details=exc.details, as_json=True)
|
|
170
|
+
raise typer.Exit(1) from exc
|
|
171
|
+
|
|
172
|
+
cfg = load_config()
|
|
173
|
+
cfg["api_key"] = issued["api_key"]
|
|
174
|
+
save_config(cfg)
|
|
175
|
+
emit_json(
|
|
176
|
+
{
|
|
177
|
+
"ok": True,
|
|
178
|
+
"device": device_name,
|
|
179
|
+
"team_id": issued["team_id"],
|
|
180
|
+
"api_key": _mask(issued["api_key"]),
|
|
181
|
+
"replaced": issued.get("replaced", 0),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
114
186
|
@app.command()
|
|
115
187
|
@async_command
|
|
116
188
|
async def update(
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""`datalegion-cli login`: device authorization, polling, the key exchange, and what is stored."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from unittest.mock import AsyncMock, patch
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
from typer.testing import CliRunner
|
|
12
|
+
|
|
13
|
+
from datalegion_cli import login as flow
|
|
14
|
+
from datalegion_cli.main import app
|
|
15
|
+
|
|
16
|
+
runner = CliRunner()
|
|
17
|
+
KEY = "legion_" + "c0ffee" * 10 + "abcd"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client(handler) -> httpx.AsyncClient:
|
|
21
|
+
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _no_sleep(_seconds: float) -> None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def tmp_config(tmp_config_dir, monkeypatch):
|
|
30
|
+
"""A throwaway config dir with no LEGION_API_KEY override."""
|
|
31
|
+
monkeypatch.delenv("LEGION_API_KEY", raising=False)
|
|
32
|
+
return tmp_config_dir
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestDeviceName:
|
|
36
|
+
def test_new_ids_are_random_and_api_safe(self):
|
|
37
|
+
ids = {flow.new_device_id() for _ in range(50)}
|
|
38
|
+
assert len(ids) == 50
|
|
39
|
+
for device_id in ids:
|
|
40
|
+
assert re.fullmatch(r"cli-[0-9a-f]{8}", device_id)
|
|
41
|
+
assert flow.check_device_name(device_id) == device_id
|
|
42
|
+
|
|
43
|
+
def test_the_hostname_is_never_read(self):
|
|
44
|
+
assert not hasattr(flow, "platform")
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize("name", ["", " lead", "a\nb", "x" * 101, "semi;colon", "two spaces"])
|
|
47
|
+
def test_rejects_what_the_api_rejects(self, name):
|
|
48
|
+
with pytest.raises(flow.LoginError):
|
|
49
|
+
flow.check_device_name(name)
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("name", ["laptop", "build-box.internal", "ci runner 3", "me@host:1"])
|
|
52
|
+
def test_accepts_ordinary_names(self, name):
|
|
53
|
+
assert flow.check_device_name(name) == name
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestFlow:
|
|
57
|
+
async def test_start_sends_the_cli_client(self):
|
|
58
|
+
seen = {}
|
|
59
|
+
|
|
60
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
61
|
+
seen["url"] = str(req.url)
|
|
62
|
+
seen["body"] = req.content.decode()
|
|
63
|
+
return httpx.Response(200, json={"device_code": "d", "user_code": "BCDF-GHJK"})
|
|
64
|
+
|
|
65
|
+
async with _client(handler) as c:
|
|
66
|
+
out = await flow.start_device_authorization(c)
|
|
67
|
+
assert out["user_code"] == "BCDF-GHJK"
|
|
68
|
+
assert seen["url"] == "https://signin.datalegion.ai/oauth2/device_authorization"
|
|
69
|
+
assert f"client_id={flow.CLI_CLIENT_ID}" in seen["body"]
|
|
70
|
+
|
|
71
|
+
async def test_start_failure_is_reported(self):
|
|
72
|
+
async with _client(
|
|
73
|
+
lambda r: httpx.Response(400, json={"error": "unauthorized_client"})
|
|
74
|
+
) as c:
|
|
75
|
+
with pytest.raises(flow.LoginError, match="unauthorized_client"):
|
|
76
|
+
await flow.start_device_authorization(c)
|
|
77
|
+
|
|
78
|
+
async def test_poll_waits_through_pending_and_slow_down(self):
|
|
79
|
+
answers = iter(
|
|
80
|
+
[
|
|
81
|
+
httpx.Response(400, json={"error": "authorization_pending"}),
|
|
82
|
+
httpx.Response(400, json={"error": "slow_down"}),
|
|
83
|
+
httpx.Response(200, json={"access_token": "tok"}),
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
slept: list[float] = []
|
|
87
|
+
|
|
88
|
+
async def sleep(s: float) -> None:
|
|
89
|
+
slept.append(s)
|
|
90
|
+
|
|
91
|
+
async with _client(lambda r: next(answers)) as c:
|
|
92
|
+
token = await flow.poll_for_token(
|
|
93
|
+
c, {"device_code": "d", "interval": 5, "expires_in": 300}, sleep=sleep
|
|
94
|
+
)
|
|
95
|
+
assert token == "tok"
|
|
96
|
+
assert slept == [5, 5, 10] # slow_down adds 5 seconds
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize(
|
|
99
|
+
("error", "code"), [("access_denied", "access_denied"), ("expired_token", "expired")]
|
|
100
|
+
)
|
|
101
|
+
async def test_poll_terminal_answers(self, error, code):
|
|
102
|
+
async with _client(lambda r: httpx.Response(400, json={"error": error})) as c:
|
|
103
|
+
with pytest.raises(flow.LoginError) as exc:
|
|
104
|
+
await flow.poll_for_token(c, {"device_code": "d", "interval": 1}, sleep=_no_sleep)
|
|
105
|
+
assert exc.value.code == code
|
|
106
|
+
|
|
107
|
+
async def test_exchange_sends_the_bearer_and_device(self):
|
|
108
|
+
seen = {}
|
|
109
|
+
|
|
110
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
111
|
+
seen["auth"] = req.headers["authorization"]
|
|
112
|
+
seen["url"] = str(req.url)
|
|
113
|
+
seen["body"] = json.loads(req.content)
|
|
114
|
+
return httpx.Response(200, json={"api_key": KEY, "team_id": 7, "replaced": 0})
|
|
115
|
+
|
|
116
|
+
async with _client(handler) as c:
|
|
117
|
+
out = await flow.exchange_for_key(c, "https://api.datalegion.ai/", "tok", "laptop", 7)
|
|
118
|
+
assert out["api_key"] == KEY
|
|
119
|
+
assert seen == {
|
|
120
|
+
"auth": "Bearer tok",
|
|
121
|
+
"url": "https://api.datalegion.ai/agent/key",
|
|
122
|
+
"body": {"device": "laptop", "team_id": 7},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async def test_exchange_several_teams(self):
|
|
126
|
+
teams = [{"team_id": 7, "team_name": "Sales"}]
|
|
127
|
+
reply = httpx.Response(
|
|
128
|
+
409, json={"error": "conflict", "message": "x", "details": {"teams": teams}}
|
|
129
|
+
)
|
|
130
|
+
async with _client(lambda r: reply) as c:
|
|
131
|
+
with pytest.raises(flow.LoginError) as exc:
|
|
132
|
+
await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
|
|
133
|
+
assert exc.value.code == "choose_team" and "--team-id" in str(exc.value)
|
|
134
|
+
assert exc.value.details == {"teams": teams}
|
|
135
|
+
|
|
136
|
+
async def test_exchange_refusal_relays_the_message(self):
|
|
137
|
+
reply = httpx.Response(
|
|
138
|
+
403,
|
|
139
|
+
json={
|
|
140
|
+
"error": "forbidden",
|
|
141
|
+
"message": "Your Data Legion account is waiting for approval.",
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
async with _client(lambda r: reply) as c:
|
|
145
|
+
with pytest.raises(flow.LoginError, match="waiting for approval"):
|
|
146
|
+
await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestLoginCommand:
|
|
150
|
+
@pytest.fixture
|
|
151
|
+
def steps(self):
|
|
152
|
+
with (
|
|
153
|
+
patch.object(
|
|
154
|
+
flow,
|
|
155
|
+
"start_device_authorization",
|
|
156
|
+
new=AsyncMock(
|
|
157
|
+
return_value={
|
|
158
|
+
"verification_uri": "https://signin.datalegion.ai/device",
|
|
159
|
+
"user_code": "BCDF-GHJK",
|
|
160
|
+
"device_code": "d",
|
|
161
|
+
}
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
patch.object(flow, "poll_for_token", new=AsyncMock(return_value="tok")),
|
|
165
|
+
patch.object(
|
|
166
|
+
flow,
|
|
167
|
+
"exchange_for_key",
|
|
168
|
+
new=AsyncMock(return_value={"api_key": KEY, "team_id": 7, "replaced": 1}),
|
|
169
|
+
) as exchange,
|
|
170
|
+
patch("webbrowser.open") as browser,
|
|
171
|
+
):
|
|
172
|
+
yield exchange, browser
|
|
173
|
+
|
|
174
|
+
def test_stores_the_key_and_never_prints_it(self, steps, tmp_config):
|
|
175
|
+
exchange, browser = steps
|
|
176
|
+
result = runner.invoke(app, ["login", "--device", "laptop", "--team-id", "7"])
|
|
177
|
+
assert result.exit_code == 0, result.output
|
|
178
|
+
out = json.loads(result.stdout)
|
|
179
|
+
assert out["ok"] is True and out["team_id"] == 7 and out["device"] == "laptop"
|
|
180
|
+
assert KEY not in result.output and out["api_key"].endswith(KEY[-4:])
|
|
181
|
+
assert exchange.await_args.args[3:] == ("laptop", 7)
|
|
182
|
+
browser.assert_called_once()
|
|
183
|
+
from datalegion_cli.config import load_config
|
|
184
|
+
|
|
185
|
+
assert load_config()["api_key"] == KEY
|
|
186
|
+
|
|
187
|
+
def test_default_device_is_a_saved_random_id_reused_next_time(self, steps, tmp_config):
|
|
188
|
+
exchange, _ = steps
|
|
189
|
+
assert runner.invoke(app, ["login"]).exit_code == 0
|
|
190
|
+
first = exchange.await_args.args[3]
|
|
191
|
+
assert re.fullmatch(r"cli-[0-9a-f]{8}", first)
|
|
192
|
+
from datalegion_cli.config import load_config
|
|
193
|
+
|
|
194
|
+
assert load_config()["device_id"] == first
|
|
195
|
+
assert runner.invoke(app, ["login"]).exit_code == 0
|
|
196
|
+
assert exchange.await_args.args[3] == first # same machine, same key slot
|
|
197
|
+
|
|
198
|
+
def test_device_flag_wins_and_leaves_the_saved_id(self, steps, tmp_config):
|
|
199
|
+
exchange, _ = steps
|
|
200
|
+
assert runner.invoke(app, ["login"]).exit_code == 0
|
|
201
|
+
from datalegion_cli.config import load_config
|
|
202
|
+
|
|
203
|
+
saved = load_config()["device_id"]
|
|
204
|
+
assert runner.invoke(app, ["login", "--device", "laptop"]).exit_code == 0
|
|
205
|
+
assert exchange.await_args.args[3] == "laptop"
|
|
206
|
+
assert load_config()["device_id"] == saved
|
|
207
|
+
|
|
208
|
+
def test_no_browser(self, steps, tmp_config):
|
|
209
|
+
_, browser = steps
|
|
210
|
+
assert runner.invoke(app, ["login", "--device", "laptop", "--no-browser"]).exit_code == 0
|
|
211
|
+
browser.assert_not_called()
|
|
212
|
+
|
|
213
|
+
def test_a_refusal_is_json_and_exit_1(self, steps, tmp_config):
|
|
214
|
+
exchange, _ = steps
|
|
215
|
+
exchange.side_effect = flow.LoginError(
|
|
216
|
+
"Pick a team.", code="choose_team", details={"teams": []}
|
|
217
|
+
)
|
|
218
|
+
result = runner.invoke(app, ["login", "--device", "laptop"])
|
|
219
|
+
assert result.exit_code == 1
|
|
220
|
+
assert json.loads(result.stdout) == {
|
|
221
|
+
"ok": False,
|
|
222
|
+
"error": "choose_team",
|
|
223
|
+
"message": "Pick a team.",
|
|
224
|
+
"details": {"teams": []},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def test_a_bad_device_name_stops_before_sign_in(self, steps, tmp_config):
|
|
228
|
+
result = runner.invoke(app, ["login", "--device", "semi;colon"])
|
|
229
|
+
assert result.exit_code == 1
|
|
230
|
+
assert json.loads(result.stdout)["error"] == "invalid_device"
|
|
231
|
+
flow.start_device_authorization.assert_not_awaited()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_config_set_never_echoes_the_key(tmp_config):
|
|
235
|
+
result = runner.invoke(app, ["config", "set", "api_key", KEY])
|
|
236
|
+
assert result.exit_code == 0
|
|
237
|
+
assert KEY not in result.output
|
|
238
|
+
assert json.loads(result.stdout)["value"].endswith(KEY[-4:])
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|