datalegion-cli 1.2.1__tar.gz → 1.3.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.
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/PKG-INFO +12 -2
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/README.md +11 -1
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/pyproject.toml +5 -1
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/client.py +1 -1
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/config.py +2 -1
- datalegion_cli-1.3.0/src/datalegion_cli/login.py +146 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/main.py +71 -6
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/test_config.py +16 -0
- datalegion_cli-1.3.0/tests/test_login.py +214 -0
- datalegion_cli-1.3.0/tests/test_package_contents.py +56 -0
- datalegion_cli-1.2.1/.github/actionlint.yaml +0 -5
- datalegion_cli-1.2.1/.github/dependabot.yml +0 -42
- datalegion_cli-1.2.1/.github/workflows/ci.yml +0 -77
- datalegion_cli-1.2.1/.github/workflows/release.yml +0 -236
- datalegion_cli-1.2.1/.python-version +0 -1
- datalegion_cli-1.2.1/uv.lock +0 -658
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/.gitignore +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/__init__.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/async_typer.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/__init__.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/company.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/credits.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/person.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/commands/utility.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/logging.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/output.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/src/datalegion_cli/update.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/__init__.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/conftest.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/test_client.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/test_commands.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/test_logging.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/tests/test_output.py +0 -0
- {datalegion_cli-1.2.1 → datalegion_cli-1.3.0}/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.0
|
|
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 after its hostname, or `--device <name>`). 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 after its hostname, or `--device <name>`). 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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "datalegion-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0"
|
|
4
4
|
description = "CLI for the Data Legion API — agent-friendly, fully async"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -57,6 +57,10 @@ build-backend = "hatchling.build"
|
|
|
57
57
|
[tool.hatch.build.targets.wheel]
|
|
58
58
|
packages = ["src/datalegion_cli"]
|
|
59
59
|
|
|
60
|
+
# The sdist carries the source and tests, never repo tooling (.github/, .claude/, lefthook.yml).
|
|
61
|
+
[tool.hatch.build.targets.sdist]
|
|
62
|
+
only-include = ["src/datalegion_cli", "tests"]
|
|
63
|
+
|
|
60
64
|
[dependency-groups]
|
|
61
65
|
dev = [
|
|
62
66
|
"pytest>=8.3.0",
|
|
@@ -43,7 +43,7 @@ class LegionClient:
|
|
|
43
43
|
timeout: float = 60.0,
|
|
44
44
|
):
|
|
45
45
|
self._base_url = base_url or get_base_url()
|
|
46
|
-
self._api_key = api_key or get_api_key()
|
|
46
|
+
self._api_key = (api_key or "").strip() or get_api_key()
|
|
47
47
|
self._timeout = timeout
|
|
48
48
|
self._client: httpx.AsyncClient | None = None
|
|
49
49
|
self.last_credits_used: str | None = None
|
|
@@ -114,7 +114,8 @@ def save_config(cfg: dict[str, object]) -> None:
|
|
|
114
114
|
|
|
115
115
|
def get_api_key() -> str:
|
|
116
116
|
"""Return the configured API key or raise."""
|
|
117
|
-
key
|
|
117
|
+
# Trimmed: a key from a file or a paste with a trailing newline is otherwise refused by httpx.
|
|
118
|
+
key = str(load_config().get("api_key", "")).strip()
|
|
118
119
|
if not key:
|
|
119
120
|
raise SystemExit(
|
|
120
121
|
"No API key configured. Run `datalegion-cli config set api_key <your-key>` "
|
|
@@ -0,0 +1,146 @@
|
|
|
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 platform
|
|
16
|
+
import re
|
|
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 default_device_name() -> str:
|
|
44
|
+
"""This machine's name in the form the API accepts, or 'cli' when it has none."""
|
|
45
|
+
name = re.sub(r"[^\w.@:-]+", "-", platform.node()).strip("-")[:100]
|
|
46
|
+
return name or "cli"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_device_name(name: str) -> str:
|
|
50
|
+
"""Return the name if the API will accept it, else raise LoginError."""
|
|
51
|
+
if not (1 <= len(name) <= 100) or not _DEVICE_OK.match(name):
|
|
52
|
+
raise LoginError(
|
|
53
|
+
"Device names use letters, digits and . _ @ : - (single spaces between words), "
|
|
54
|
+
"up to 100 characters.",
|
|
55
|
+
code="invalid_device",
|
|
56
|
+
)
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def start_device_authorization(client: httpx.AsyncClient) -> dict[str, Any]:
|
|
61
|
+
"""Ask the sign-in server for a device code and the link the person approves at."""
|
|
62
|
+
resp = await client.post(
|
|
63
|
+
f"{SIGNIN_URL}/oauth2/device_authorization",
|
|
64
|
+
data={"client_id": CLI_CLIENT_ID, "scope": "openid"},
|
|
65
|
+
)
|
|
66
|
+
if resp.status_code != 200:
|
|
67
|
+
raise LoginError(f"Sign-in could not start ({resp.status_code}): {_oauth_error(resp)}")
|
|
68
|
+
return resp.json()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def poll_for_token(
|
|
72
|
+
client: httpx.AsyncClient,
|
|
73
|
+
device: dict[str, Any],
|
|
74
|
+
*,
|
|
75
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Poll until the person approves (RFC 8628), returning the access token."""
|
|
78
|
+
interval = float(device.get("interval") or 5)
|
|
79
|
+
deadline = asyncio.get_running_loop().time() + float(device.get("expires_in") or 300)
|
|
80
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
81
|
+
await sleep(interval)
|
|
82
|
+
resp = await client.post(
|
|
83
|
+
f"{SIGNIN_URL}/oauth2/token",
|
|
84
|
+
data={
|
|
85
|
+
"grant_type": _DEVICE_GRANT,
|
|
86
|
+
"device_code": device["device_code"],
|
|
87
|
+
"client_id": CLI_CLIENT_ID,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
if resp.status_code == 200:
|
|
91
|
+
token = resp.json().get("access_token")
|
|
92
|
+
if token:
|
|
93
|
+
return str(token)
|
|
94
|
+
raise LoginError("Sign-in returned no access token.")
|
|
95
|
+
error = _oauth_error(resp)
|
|
96
|
+
if error == "authorization_pending":
|
|
97
|
+
continue
|
|
98
|
+
if error == "slow_down":
|
|
99
|
+
interval += 5
|
|
100
|
+
continue
|
|
101
|
+
if error == "access_denied":
|
|
102
|
+
raise LoginError("Sign-in was declined.", code="access_denied")
|
|
103
|
+
if error == "expired_token":
|
|
104
|
+
break
|
|
105
|
+
raise LoginError(f"Sign-in failed: {error}")
|
|
106
|
+
raise LoginError("The sign-in code expired. Run `datalegion-cli login` again.", code="expired")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def exchange_for_key(
|
|
110
|
+
client: httpx.AsyncClient,
|
|
111
|
+
api_base_url: str,
|
|
112
|
+
access_token: str,
|
|
113
|
+
device_name: str,
|
|
114
|
+
team_id: int | None,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Trade the access token for this machine's API key (POST /agent/key)."""
|
|
117
|
+
body: dict[str, Any] = {"device": device_name}
|
|
118
|
+
if team_id is not None:
|
|
119
|
+
body["team_id"] = team_id
|
|
120
|
+
resp = await client.post(
|
|
121
|
+
f"{api_base_url.rstrip('/')}/agent/key",
|
|
122
|
+
json=body,
|
|
123
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
payload = resp.json()
|
|
127
|
+
except ValueError:
|
|
128
|
+
payload = {}
|
|
129
|
+
if resp.status_code == 200 and payload.get("api_key"):
|
|
130
|
+
return payload
|
|
131
|
+
message = payload.get("message") or f"Getting a key failed ({resp.status_code})."
|
|
132
|
+
details = payload.get("details")
|
|
133
|
+
if resp.status_code == 409:
|
|
134
|
+
message = "This account has several teams. Run again with --team-id <id> to choose one."
|
|
135
|
+
raise LoginError(message, code="choose_team", details=details)
|
|
136
|
+
raise LoginError(
|
|
137
|
+
message, code=str(payload.get("error") or "key_exchange_failed"), details=details
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _oauth_error(resp: httpx.Response) -> str:
|
|
142
|
+
try:
|
|
143
|
+
body = resp.json()
|
|
144
|
+
except ValueError:
|
|
145
|
+
return resp.text[:200] or str(resp.status_code)
|
|
146
|
+
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,79 @@ 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, typer.Option("--device", help="Name for this machine (default: hostname).")
|
|
130
|
+
] = None,
|
|
131
|
+
no_browser: Annotated[
|
|
132
|
+
bool, typer.Option("--no-browser", help="Print the sign-in link without opening it.")
|
|
133
|
+
] = False,
|
|
134
|
+
) -> None:
|
|
135
|
+
"""Sign in with your Data Legion account and store a key for this machine.
|
|
136
|
+
|
|
137
|
+
Opens a sign-in link (approve the code it shows), then saves an API key that
|
|
138
|
+
belongs to this machine. Signing in again replaces it. Disconnect it any time
|
|
139
|
+
from Connected Apps in the dashboard.
|
|
140
|
+
"""
|
|
141
|
+
import webbrowser
|
|
142
|
+
|
|
143
|
+
import httpx
|
|
144
|
+
|
|
145
|
+
from datalegion_cli import login as flow
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
device_name = flow.check_device_name(device or flow.default_device_name())
|
|
149
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
150
|
+
started = await flow.start_device_authorization(client)
|
|
151
|
+
link = started.get("verification_uri_complete") or started["verification_uri"]
|
|
152
|
+
console.print(
|
|
153
|
+
f"Open [bold]{link}[/bold] and confirm the code [bold]{started['user_code']}[/bold]."
|
|
154
|
+
)
|
|
155
|
+
if not no_browser:
|
|
156
|
+
webbrowser.open(link)
|
|
157
|
+
token = await flow.poll_for_token(client, started)
|
|
158
|
+
issued = await flow.exchange_for_key(
|
|
159
|
+
client, get_base_url(), token, device_name, team_id
|
|
160
|
+
)
|
|
161
|
+
except flow.LoginError as exc:
|
|
162
|
+
emit_error(str(exc), code=exc.code, details=exc.details, as_json=True)
|
|
163
|
+
raise typer.Exit(1) from exc
|
|
164
|
+
|
|
165
|
+
cfg = load_config()
|
|
166
|
+
cfg["api_key"] = issued["api_key"]
|
|
167
|
+
save_config(cfg)
|
|
168
|
+
emit_json(
|
|
169
|
+
{
|
|
170
|
+
"ok": True,
|
|
171
|
+
"device": device_name,
|
|
172
|
+
"team_id": issued["team_id"],
|
|
173
|
+
"api_key": _mask(issued["api_key"]),
|
|
174
|
+
"replaced": issued.get("replaced", 0),
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
114
179
|
@app.command()
|
|
115
180
|
@async_command
|
|
116
181
|
async def update(
|
|
@@ -49,6 +49,22 @@ class TestSaveAndLoad:
|
|
|
49
49
|
cfg = load_config()
|
|
50
50
|
assert cfg["api_key"] == "legion_roundtrip"
|
|
51
51
|
|
|
52
|
+
def test_api_key_is_trimmed(self, tmp_config_dir, monkeypatch):
|
|
53
|
+
from datalegion_cli.config import get_api_key, save_config
|
|
54
|
+
|
|
55
|
+
monkeypatch.setenv("LEGION_API_KEY", "legion_fromenv\n")
|
|
56
|
+
assert get_api_key() == "legion_fromenv"
|
|
57
|
+
monkeypatch.delenv("LEGION_API_KEY")
|
|
58
|
+
save_config({"api_key": " legion_fromfile "})
|
|
59
|
+
assert get_api_key() == "legion_fromfile"
|
|
60
|
+
|
|
61
|
+
def test_whitespace_only_api_key_is_missing(self, tmp_config_dir, monkeypatch):
|
|
62
|
+
from datalegion_cli.config import get_api_key
|
|
63
|
+
|
|
64
|
+
monkeypatch.setenv("LEGION_API_KEY", " \n")
|
|
65
|
+
with pytest.raises(SystemExit):
|
|
66
|
+
get_api_key()
|
|
67
|
+
|
|
52
68
|
def test_save_sets_file_permissions_600(self, tmp_config_dir):
|
|
53
69
|
from datalegion_cli.config import CONFIG_FILE, save_config
|
|
54
70
|
|
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
from unittest.mock import AsyncMock, patch
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from typer.testing import CliRunner
|
|
11
|
+
|
|
12
|
+
from datalegion_cli import login as flow
|
|
13
|
+
from datalegion_cli.main import app
|
|
14
|
+
|
|
15
|
+
runner = CliRunner()
|
|
16
|
+
KEY = "legion_" + "c0ffee" * 10 + "abcd"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _client(handler) -> httpx.AsyncClient:
|
|
20
|
+
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _no_sleep(_seconds: float) -> None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def tmp_config(tmp_config_dir, monkeypatch):
|
|
29
|
+
"""A throwaway config dir with no LEGION_API_KEY override."""
|
|
30
|
+
monkeypatch.delenv("LEGION_API_KEY", raising=False)
|
|
31
|
+
return tmp_config_dir
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestDeviceName:
|
|
35
|
+
def test_default_is_the_hostname_made_safe(self, monkeypatch):
|
|
36
|
+
monkeypatch.setattr(flow.platform, "node", lambda: "Jack's MacBook Pro.local")
|
|
37
|
+
assert flow.default_device_name() == "Jack-s-MacBook-Pro.local"
|
|
38
|
+
|
|
39
|
+
def test_default_without_a_hostname(self, monkeypatch):
|
|
40
|
+
monkeypatch.setattr(flow.platform, "node", lambda: "")
|
|
41
|
+
assert flow.default_device_name() == "cli"
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("name", ["", " lead", "a\nb", "x" * 101, "semi;colon", "two spaces"])
|
|
44
|
+
def test_rejects_what_the_api_rejects(self, name):
|
|
45
|
+
with pytest.raises(flow.LoginError):
|
|
46
|
+
flow.check_device_name(name)
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("name", ["laptop", "build-box.internal", "ci runner 3", "me@host:1"])
|
|
49
|
+
def test_accepts_ordinary_names(self, name):
|
|
50
|
+
assert flow.check_device_name(name) == name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestFlow:
|
|
54
|
+
async def test_start_sends_the_cli_client(self):
|
|
55
|
+
seen = {}
|
|
56
|
+
|
|
57
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
58
|
+
seen["url"] = str(req.url)
|
|
59
|
+
seen["body"] = req.content.decode()
|
|
60
|
+
return httpx.Response(200, json={"device_code": "d", "user_code": "BCDF-GHJK"})
|
|
61
|
+
|
|
62
|
+
async with _client(handler) as c:
|
|
63
|
+
out = await flow.start_device_authorization(c)
|
|
64
|
+
assert out["user_code"] == "BCDF-GHJK"
|
|
65
|
+
assert seen["url"] == "https://signin.datalegion.ai/oauth2/device_authorization"
|
|
66
|
+
assert f"client_id={flow.CLI_CLIENT_ID}" in seen["body"]
|
|
67
|
+
|
|
68
|
+
async def test_start_failure_is_reported(self):
|
|
69
|
+
async with _client(
|
|
70
|
+
lambda r: httpx.Response(400, json={"error": "unauthorized_client"})
|
|
71
|
+
) as c:
|
|
72
|
+
with pytest.raises(flow.LoginError, match="unauthorized_client"):
|
|
73
|
+
await flow.start_device_authorization(c)
|
|
74
|
+
|
|
75
|
+
async def test_poll_waits_through_pending_and_slow_down(self):
|
|
76
|
+
answers = iter(
|
|
77
|
+
[
|
|
78
|
+
httpx.Response(400, json={"error": "authorization_pending"}),
|
|
79
|
+
httpx.Response(400, json={"error": "slow_down"}),
|
|
80
|
+
httpx.Response(200, json={"access_token": "tok"}),
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
slept: list[float] = []
|
|
84
|
+
|
|
85
|
+
async def sleep(s: float) -> None:
|
|
86
|
+
slept.append(s)
|
|
87
|
+
|
|
88
|
+
async with _client(lambda r: next(answers)) as c:
|
|
89
|
+
token = await flow.poll_for_token(
|
|
90
|
+
c, {"device_code": "d", "interval": 5, "expires_in": 300}, sleep=sleep
|
|
91
|
+
)
|
|
92
|
+
assert token == "tok"
|
|
93
|
+
assert slept == [5, 5, 10] # slow_down adds 5 seconds
|
|
94
|
+
|
|
95
|
+
@pytest.mark.parametrize(
|
|
96
|
+
("error", "code"), [("access_denied", "access_denied"), ("expired_token", "expired")]
|
|
97
|
+
)
|
|
98
|
+
async def test_poll_terminal_answers(self, error, code):
|
|
99
|
+
async with _client(lambda r: httpx.Response(400, json={"error": error})) as c:
|
|
100
|
+
with pytest.raises(flow.LoginError) as exc:
|
|
101
|
+
await flow.poll_for_token(c, {"device_code": "d", "interval": 1}, sleep=_no_sleep)
|
|
102
|
+
assert exc.value.code == code
|
|
103
|
+
|
|
104
|
+
async def test_exchange_sends_the_bearer_and_device(self):
|
|
105
|
+
seen = {}
|
|
106
|
+
|
|
107
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
108
|
+
seen["auth"] = req.headers["authorization"]
|
|
109
|
+
seen["url"] = str(req.url)
|
|
110
|
+
seen["body"] = json.loads(req.content)
|
|
111
|
+
return httpx.Response(200, json={"api_key": KEY, "team_id": 7, "replaced": 0})
|
|
112
|
+
|
|
113
|
+
async with _client(handler) as c:
|
|
114
|
+
out = await flow.exchange_for_key(c, "https://api.datalegion.ai/", "tok", "laptop", 7)
|
|
115
|
+
assert out["api_key"] == KEY
|
|
116
|
+
assert seen == {
|
|
117
|
+
"auth": "Bearer tok",
|
|
118
|
+
"url": "https://api.datalegion.ai/agent/key",
|
|
119
|
+
"body": {"device": "laptop", "team_id": 7},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async def test_exchange_several_teams(self):
|
|
123
|
+
teams = [{"team_id": 7, "team_name": "Sales"}]
|
|
124
|
+
reply = httpx.Response(
|
|
125
|
+
409, json={"error": "conflict", "message": "x", "details": {"teams": teams}}
|
|
126
|
+
)
|
|
127
|
+
async with _client(lambda r: reply) as c:
|
|
128
|
+
with pytest.raises(flow.LoginError) as exc:
|
|
129
|
+
await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
|
|
130
|
+
assert exc.value.code == "choose_team" and "--team-id" in str(exc.value)
|
|
131
|
+
assert exc.value.details == {"teams": teams}
|
|
132
|
+
|
|
133
|
+
async def test_exchange_refusal_relays_the_message(self):
|
|
134
|
+
reply = httpx.Response(
|
|
135
|
+
403,
|
|
136
|
+
json={
|
|
137
|
+
"error": "forbidden",
|
|
138
|
+
"message": "Your Data Legion account is waiting for approval.",
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
async with _client(lambda r: reply) as c:
|
|
142
|
+
with pytest.raises(flow.LoginError, match="waiting for approval"):
|
|
143
|
+
await flow.exchange_for_key(c, "https://api.datalegion.ai", "tok", "laptop", None)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestLoginCommand:
|
|
147
|
+
@pytest.fixture
|
|
148
|
+
def steps(self):
|
|
149
|
+
with (
|
|
150
|
+
patch.object(
|
|
151
|
+
flow,
|
|
152
|
+
"start_device_authorization",
|
|
153
|
+
new=AsyncMock(
|
|
154
|
+
return_value={
|
|
155
|
+
"verification_uri": "https://signin.datalegion.ai/device",
|
|
156
|
+
"user_code": "BCDF-GHJK",
|
|
157
|
+
"device_code": "d",
|
|
158
|
+
}
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
patch.object(flow, "poll_for_token", new=AsyncMock(return_value="tok")),
|
|
162
|
+
patch.object(
|
|
163
|
+
flow,
|
|
164
|
+
"exchange_for_key",
|
|
165
|
+
new=AsyncMock(return_value={"api_key": KEY, "team_id": 7, "replaced": 1}),
|
|
166
|
+
) as exchange,
|
|
167
|
+
patch("webbrowser.open") as browser,
|
|
168
|
+
):
|
|
169
|
+
yield exchange, browser
|
|
170
|
+
|
|
171
|
+
def test_stores_the_key_and_never_prints_it(self, steps, tmp_config):
|
|
172
|
+
exchange, browser = steps
|
|
173
|
+
result = runner.invoke(app, ["login", "--device", "laptop", "--team-id", "7"])
|
|
174
|
+
assert result.exit_code == 0, result.output
|
|
175
|
+
out = json.loads(result.stdout)
|
|
176
|
+
assert out["ok"] is True and out["team_id"] == 7 and out["device"] == "laptop"
|
|
177
|
+
assert KEY not in result.output and out["api_key"].endswith(KEY[-4:])
|
|
178
|
+
assert exchange.await_args.args[3:] == ("laptop", 7)
|
|
179
|
+
browser.assert_called_once()
|
|
180
|
+
from datalegion_cli.config import load_config
|
|
181
|
+
|
|
182
|
+
assert load_config()["api_key"] == KEY
|
|
183
|
+
|
|
184
|
+
def test_no_browser(self, steps, tmp_config):
|
|
185
|
+
_, browser = steps
|
|
186
|
+
assert runner.invoke(app, ["login", "--device", "laptop", "--no-browser"]).exit_code == 0
|
|
187
|
+
browser.assert_not_called()
|
|
188
|
+
|
|
189
|
+
def test_a_refusal_is_json_and_exit_1(self, steps, tmp_config):
|
|
190
|
+
exchange, _ = steps
|
|
191
|
+
exchange.side_effect = flow.LoginError(
|
|
192
|
+
"Pick a team.", code="choose_team", details={"teams": []}
|
|
193
|
+
)
|
|
194
|
+
result = runner.invoke(app, ["login", "--device", "laptop"])
|
|
195
|
+
assert result.exit_code == 1
|
|
196
|
+
assert json.loads(result.stdout) == {
|
|
197
|
+
"ok": False,
|
|
198
|
+
"error": "choose_team",
|
|
199
|
+
"message": "Pick a team.",
|
|
200
|
+
"details": {"teams": []},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
def test_a_bad_device_name_stops_before_sign_in(self, steps, tmp_config):
|
|
204
|
+
result = runner.invoke(app, ["login", "--device", "semi;colon"])
|
|
205
|
+
assert result.exit_code == 1
|
|
206
|
+
assert json.loads(result.stdout)["error"] == "invalid_device"
|
|
207
|
+
flow.start_device_authorization.assert_not_awaited()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_config_set_never_echoes_the_key(tmp_config):
|
|
211
|
+
result = runner.invoke(app, ["config", "set", "api_key", KEY])
|
|
212
|
+
assert result.exit_code == 0
|
|
213
|
+
assert KEY not in result.output
|
|
214
|
+
assert json.loads(result.stdout)["value"].endswith(KEY[-4:])
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""What the published wheel and sdist contain.
|
|
2
|
+
|
|
3
|
+
Customers install the wheel: it must hold only the ``datalegion_cli`` package, and its only
|
|
4
|
+
command is ``datalegion-cli``. The sdist carries the source and tests, never repo tooling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import tarfile
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope="module")
|
|
18
|
+
def dist(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
19
|
+
out = tmp_path_factory.mktemp("dist")
|
|
20
|
+
# Each straight from the source tree: a default build makes the wheel from the sdist, which
|
|
21
|
+
# would hide a wheel misconfiguration behind the sdist's own include list.
|
|
22
|
+
for target in ("--sdist", "--wheel"):
|
|
23
|
+
subprocess.run(
|
|
24
|
+
["uv", "build", target, "--quiet", "--out-dir", str(out)], cwd=ROOT, check=True
|
|
25
|
+
)
|
|
26
|
+
return out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_wheel_ships_only_the_cli(dist: Path) -> None:
|
|
30
|
+
(wheel,) = dist.glob("*.whl")
|
|
31
|
+
with zipfile.ZipFile(wheel) as archive:
|
|
32
|
+
names = archive.namelist()
|
|
33
|
+
commands = next(
|
|
34
|
+
(archive.read(n).decode() for n in names if n.endswith("entry_points.txt")), ""
|
|
35
|
+
)
|
|
36
|
+
top_level = {n.split("/")[0] for n in names if not n.split("/")[0].endswith(".dist-info")}
|
|
37
|
+
assert top_level == {"datalegion_cli"}
|
|
38
|
+
assert commands.split() == [
|
|
39
|
+
"[console_scripts]",
|
|
40
|
+
"datalegion-cli",
|
|
41
|
+
"=",
|
|
42
|
+
"datalegion_cli.main:run",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_sdist_carries_no_repo_tooling(dist: Path) -> None:
|
|
47
|
+
(sdist,) = dist.glob("*.tar.gz")
|
|
48
|
+
with tarfile.open(sdist) as archive:
|
|
49
|
+
names = archive.getnames()
|
|
50
|
+
leaked = [
|
|
51
|
+
n
|
|
52
|
+
for n in names
|
|
53
|
+
if any(part in n.split("/") for part in (".claude", ".github", "scripts", "lefthook.yml"))
|
|
54
|
+
]
|
|
55
|
+
assert leaked == []
|
|
56
|
+
assert any(n.endswith("src/datalegion_cli/main.py") for n in names)
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
version: 2
|
|
2
|
-
updates:
|
|
3
|
-
- package-ecosystem: "uv"
|
|
4
|
-
directory: "/"
|
|
5
|
-
schedule:
|
|
6
|
-
interval: "weekly"
|
|
7
|
-
day: "sunday"
|
|
8
|
-
time: "09:00"
|
|
9
|
-
timezone: "America/Los_Angeles"
|
|
10
|
-
cooldown:
|
|
11
|
-
default-days: 1
|
|
12
|
-
labels:
|
|
13
|
-
- "dependencies"
|
|
14
|
-
- "python"
|
|
15
|
-
commit-message:
|
|
16
|
-
prefix: "chore(deps)"
|
|
17
|
-
groups:
|
|
18
|
-
minor-patch:
|
|
19
|
-
patterns:
|
|
20
|
-
- "*"
|
|
21
|
-
update-types:
|
|
22
|
-
- "minor"
|
|
23
|
-
- "patch"
|
|
24
|
-
|
|
25
|
-
- package-ecosystem: "github-actions"
|
|
26
|
-
directory: "/"
|
|
27
|
-
schedule:
|
|
28
|
-
interval: "weekly"
|
|
29
|
-
day: "sunday"
|
|
30
|
-
time: "09:00"
|
|
31
|
-
timezone: "America/Los_Angeles"
|
|
32
|
-
cooldown:
|
|
33
|
-
default-days: 1
|
|
34
|
-
labels:
|
|
35
|
-
- "dependencies"
|
|
36
|
-
- "github-actions"
|
|
37
|
-
commit-message:
|
|
38
|
-
prefix: "chore(deps)"
|
|
39
|
-
groups:
|
|
40
|
-
actions:
|
|
41
|
-
patterns:
|
|
42
|
-
- "*"
|