lql-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lql/__init__.py +1 -0
- lql/_opts.py +7 -0
- lql/api.py +69 -0
- lql/cli.py +93 -0
- lql/commands/__init__.py +0 -0
- lql/commands/annotations.py +69 -0
- lql/commands/auth.py +162 -0
- lql/commands/buckets.py +190 -0
- lql/commands/datasets.py +398 -0
- lql/commands/edits.py +95 -0
- lql/commands/evals.py +285 -0
- lql/commands/highlights.py +89 -0
- lql/commands/instructions.py +248 -0
- lql/commands/issues.py +56 -0
- lql/commands/reports.py +92 -0
- lql/commands/skills.py +116 -0
- lql/commands/spec.py +165 -0
- lql/commands/workspaces.py +147 -0
- lql/config.py +103 -0
- lql/output.py +29 -0
- lql/sessions.py +27 -0
- lql/util.py +11 -0
- lql_cli-0.2.0.dist-info/METADATA +320 -0
- lql_cli-0.2.0.dist-info/RECORD +26 -0
- lql_cli-0.2.0.dist-info/WHEEL +4 -0
- lql_cli-0.2.0.dist-info/entry_points.txt +2 -0
lql/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
lql/_opts.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from typing import Annotated, Optional
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
ProfileOpt = Annotated[Optional[str], typer.Option("--profile", help="Profile to use")]
|
|
6
|
+
ApiUrlOpt = Annotated[Optional[str], typer.Option("--api-url", help="Override API base URL")]
|
|
7
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Output raw JSON")]
|
lql/api.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from .config import get_api_url, get_token, validate_api_url
|
|
7
|
+
from .output import print_error
|
|
8
|
+
|
|
9
|
+
# Maps HTTP status -> process exit code (parity with the TS client).
|
|
10
|
+
_EXIT_MAP = {401: 2, 403: 2, 404: 3, 409: 4}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiClient:
|
|
14
|
+
def __init__(self, profile: Optional[str] = None, api_url: Optional[str] = None):
|
|
15
|
+
# Validate an explicitly-passed --api-url too; get_api_url() validates
|
|
16
|
+
# the env/profile path.
|
|
17
|
+
base = validate_api_url(api_url) if api_url else get_api_url(profile)
|
|
18
|
+
token = get_token(profile)
|
|
19
|
+
if not token:
|
|
20
|
+
print_error(
|
|
21
|
+
"Not authenticated. Run `lql login` or set LQL_API_KEY environment variable.",
|
|
22
|
+
"unauthenticated",
|
|
23
|
+
)
|
|
24
|
+
raise typer.Exit(2)
|
|
25
|
+
self._client = httpx.Client(
|
|
26
|
+
base_url=base.rstrip("/"),
|
|
27
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
28
|
+
timeout=60.0,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _request(self, method: str, path: str, *, params=None, json=None) -> httpx.Response:
|
|
32
|
+
try:
|
|
33
|
+
resp = self._client.request(method, path, params=params, json=json)
|
|
34
|
+
except httpx.RequestError as e:
|
|
35
|
+
print_error(str(e) or "Network error", "network_error")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
|
|
38
|
+
if resp.status_code >= 400:
|
|
39
|
+
body = None
|
|
40
|
+
try:
|
|
41
|
+
body = resp.json()
|
|
42
|
+
except Exception:
|
|
43
|
+
body = None
|
|
44
|
+
message = None
|
|
45
|
+
if isinstance(body, dict):
|
|
46
|
+
if isinstance(body.get("detail"), str):
|
|
47
|
+
message = body["detail"]
|
|
48
|
+
elif isinstance(body.get("message"), str):
|
|
49
|
+
message = body["message"]
|
|
50
|
+
message = message or f"Request failed (HTTP {resp.status_code})"
|
|
51
|
+
print_error(message, f"http_{resp.status_code}")
|
|
52
|
+
code = _EXIT_MAP.get(resp.status_code, 5 if resp.status_code >= 500 else 1)
|
|
53
|
+
raise typer.Exit(code)
|
|
54
|
+
return resp
|
|
55
|
+
|
|
56
|
+
def get(self, path, *, params=None):
|
|
57
|
+
return self._request("GET", path, params=params)
|
|
58
|
+
|
|
59
|
+
def post(self, path, *, params=None, json=None):
|
|
60
|
+
return self._request("POST", path, params=params, json=json)
|
|
61
|
+
|
|
62
|
+
def put(self, path, *, params=None, json=None):
|
|
63
|
+
return self._request("PUT", path, params=params, json=json)
|
|
64
|
+
|
|
65
|
+
def patch(self, path, *, params=None, json=None):
|
|
66
|
+
return self._request("PATCH", path, params=params, json=json)
|
|
67
|
+
|
|
68
|
+
def delete(self, path, *, params=None):
|
|
69
|
+
return self._request("DELETE", path, params=params)
|
lql/cli.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Annotated, Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .output import print_error
|
|
8
|
+
from .commands import (
|
|
9
|
+
annotations,
|
|
10
|
+
auth,
|
|
11
|
+
buckets,
|
|
12
|
+
datasets,
|
|
13
|
+
edits,
|
|
14
|
+
evals,
|
|
15
|
+
highlights,
|
|
16
|
+
instructions,
|
|
17
|
+
issues,
|
|
18
|
+
reports,
|
|
19
|
+
skills,
|
|
20
|
+
spec,
|
|
21
|
+
workspaces,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="lql",
|
|
26
|
+
help=(
|
|
27
|
+
"CLI for the Liquid DataViewer platform — scriptable access for agents and humans.\n\n"
|
|
28
|
+
"If you are an AI agent: run `lql instructions` to get a full reference with all "
|
|
29
|
+
"commands, flags, examples, and common workflows in a single read."
|
|
30
|
+
),
|
|
31
|
+
no_args_is_help=True,
|
|
32
|
+
add_completion=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version_callback(value: bool) -> None:
|
|
37
|
+
if value:
|
|
38
|
+
sys.stdout.write(f"{__version__}\n")
|
|
39
|
+
raise typer.Exit()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.callback()
|
|
43
|
+
def main_callback(
|
|
44
|
+
version: Annotated[
|
|
45
|
+
Optional[bool],
|
|
46
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit"),
|
|
47
|
+
] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Command groups
|
|
53
|
+
auth.register(app) # adds `auth` sub-app + top-level login/logout/whoami aliases
|
|
54
|
+
app.add_typer(workspaces.app, name="workspaces")
|
|
55
|
+
app.add_typer(datasets.app, name="datasets")
|
|
56
|
+
app.add_typer(evals.app, name="eval")
|
|
57
|
+
app.add_typer(edits.app, name="edits")
|
|
58
|
+
app.add_typer(spec.app, name="spec")
|
|
59
|
+
app.add_typer(annotations.app, name="annotations")
|
|
60
|
+
app.add_typer(highlights.app, name="highlights")
|
|
61
|
+
app.add_typer(issues.app, name="issues")
|
|
62
|
+
app.add_typer(reports.app, name="reports")
|
|
63
|
+
app.add_typer(buckets.app, name="buckets")
|
|
64
|
+
|
|
65
|
+
# Single commands
|
|
66
|
+
app.command("instructions", help="Print a full reference for agents and humans")(instructions.instructions)
|
|
67
|
+
app.add_typer(skills.app, name="skills")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("update")
|
|
71
|
+
def update() -> None:
|
|
72
|
+
"""Show how to update lql to the latest version."""
|
|
73
|
+
sys.stdout.write(
|
|
74
|
+
"To update lql, use the tool you installed it with:\n"
|
|
75
|
+
" uv tool upgrade lql # if installed via `uv tool install`\n"
|
|
76
|
+
" pipx upgrade lql # if installed via pipx\n"
|
|
77
|
+
" pip install --upgrade lql # if installed via pip\n"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main() -> None:
|
|
82
|
+
# Mirror the TS top-level catch: unexpected errors surface as the documented
|
|
83
|
+
# machine-readable contract, not a traceback. Typer/Click exits (SystemExit)
|
|
84
|
+
# and KeyboardInterrupt are BaseException, so they pass through untouched.
|
|
85
|
+
try:
|
|
86
|
+
app()
|
|
87
|
+
except Exception as e: # noqa: BLE001
|
|
88
|
+
print_error(str(e) or "Unexpected error", "unexpected_error")
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
lql/commands/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Annotated, Optional
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
|
|
7
|
+
from ..api import ApiClient
|
|
8
|
+
from ..output import print_json, print_table
|
|
9
|
+
from ..sessions import resolve_session_id
|
|
10
|
+
from ..util import q
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage annotations")
|
|
13
|
+
|
|
14
|
+
SessionOpt = Annotated[Optional[str], typer.Option("--session", help="Target a specific review session (advanced)")]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def list_annotations(
|
|
19
|
+
dataset_id: Annotated[str, typer.Argument(help="Dataset ID")],
|
|
20
|
+
session: SessionOpt = None,
|
|
21
|
+
json_out: JsonOpt = False,
|
|
22
|
+
profile: ProfileOpt = None,
|
|
23
|
+
api_url: ApiUrlOpt = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""List annotations for a dataset."""
|
|
26
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
27
|
+
session_id = resolve_session_id(client, dataset_id, session)
|
|
28
|
+
items = client.get(f"/v1/sessions/{q(session_id)}/annotations").json()
|
|
29
|
+
print_table(
|
|
30
|
+
["ID", "Row", "Rating", "Note"],
|
|
31
|
+
[
|
|
32
|
+
[
|
|
33
|
+
a.get("id") or "",
|
|
34
|
+
a.get("row_external_id") or "",
|
|
35
|
+
a.get("rating") if a.get("rating") is not None else "",
|
|
36
|
+
a.get("note") or "",
|
|
37
|
+
]
|
|
38
|
+
for a in items
|
|
39
|
+
],
|
|
40
|
+
json_out,
|
|
41
|
+
items,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("add")
|
|
46
|
+
def add(
|
|
47
|
+
dataset_id: Annotated[str, typer.Argument(help="Dataset ID")],
|
|
48
|
+
row: Annotated[str, typer.Option("--row", help="Row external ID")],
|
|
49
|
+
rating: Annotated[Optional[float], typer.Option("--rating", help="Rating value")] = None,
|
|
50
|
+
note: Annotated[Optional[str], typer.Option("--note", help="Note text")] = None,
|
|
51
|
+
session: SessionOpt = None,
|
|
52
|
+
json_out: JsonOpt = False,
|
|
53
|
+
profile: ProfileOpt = None,
|
|
54
|
+
api_url: ApiUrlOpt = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Add an annotation to a dataset row."""
|
|
57
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
58
|
+
session_id = resolve_session_id(client, dataset_id, session)
|
|
59
|
+
body: dict = {"row_external_id": row}
|
|
60
|
+
if rating is not None:
|
|
61
|
+
# Match JS Number(): send an int when the value is integral (rating 1, not 1.0).
|
|
62
|
+
body["rating"] = int(rating) if float(rating).is_integer() else rating
|
|
63
|
+
if note is not None:
|
|
64
|
+
body["note"] = note
|
|
65
|
+
data = client.post(f"/v1/sessions/{q(session_id)}/annotations", json=body).json()
|
|
66
|
+
if json_out:
|
|
67
|
+
print_json(data)
|
|
68
|
+
else:
|
|
69
|
+
sys.stdout.write(f"Created annotation: {data.get('id', 'ok')}\n")
|
lql/commands/auth.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
|
|
11
|
+
from ..api import ApiClient
|
|
12
|
+
from ..config import get_api_url, read_config, validate_api_url, write_config
|
|
13
|
+
from ..output import print_error, print_json, print_table
|
|
14
|
+
from ..util import q
|
|
15
|
+
|
|
16
|
+
BROWSER_BASE_URL = "https://dataviewer.liquid.ai"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _do_login(profile: Optional[str], api_url: Optional[str]) -> None:
|
|
20
|
+
api = validate_api_url(api_url) if api_url else get_api_url(profile)
|
|
21
|
+
profile_name = profile or "default"
|
|
22
|
+
|
|
23
|
+
env_token = os.environ.get("LQL_API_KEY")
|
|
24
|
+
if env_token:
|
|
25
|
+
config = read_config() or {"current_profile": "default", "profiles": {}}
|
|
26
|
+
existing = config["profiles"].get(profile_name, {})
|
|
27
|
+
config["profiles"][profile_name] = {**existing, "token": env_token, "api_url": api}
|
|
28
|
+
config["current_profile"] = profile_name
|
|
29
|
+
write_config(config)
|
|
30
|
+
sys.stdout.write(f'Stored LQL_API_KEY in profile "{profile_name}".\n')
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
# Create a CLI auth session
|
|
34
|
+
try:
|
|
35
|
+
res = httpx.post(f"{api}/v1/cli-auth/sessions", timeout=30.0)
|
|
36
|
+
res.raise_for_status()
|
|
37
|
+
session_data = res.json()
|
|
38
|
+
except Exception as err:
|
|
39
|
+
detail = None
|
|
40
|
+
if isinstance(err, httpx.HTTPStatusError):
|
|
41
|
+
try:
|
|
42
|
+
detail = err.response.json().get("detail")
|
|
43
|
+
except Exception:
|
|
44
|
+
detail = None
|
|
45
|
+
print_error(detail or str(err) or "Failed to start auth session", "auth_session_failed")
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
session_id = session_data["session_id"]
|
|
49
|
+
browser_code = session_data["browser_code"]
|
|
50
|
+
browser_url = f"{BROWSER_BASE_URL}/cli-auth?code={browser_code}&session={session_id}"
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
webbrowser.open(browser_url)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
sys.stdout.write(f"Opening browser... if it didn't open, visit:\n{browser_url}\n")
|
|
57
|
+
|
|
58
|
+
deadline = time.monotonic() + 120
|
|
59
|
+
delay = 3.0
|
|
60
|
+
while time.monotonic() < deadline:
|
|
61
|
+
time.sleep(delay)
|
|
62
|
+
delay = min(delay * 1.5, 15.0)
|
|
63
|
+
try:
|
|
64
|
+
poll = httpx.get(f"{api}/v1/cli-auth/sessions/{session_id}", timeout=30.0).json()
|
|
65
|
+
except Exception:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
status = poll.get("status")
|
|
69
|
+
if status == "authorized":
|
|
70
|
+
config = read_config() or {"current_profile": "default", "profiles": {}}
|
|
71
|
+
config["profiles"][profile_name] = {
|
|
72
|
+
"token": poll.get("token"),
|
|
73
|
+
"key_id": poll.get("key_id"),
|
|
74
|
+
"api_url": api,
|
|
75
|
+
}
|
|
76
|
+
config["current_profile"] = profile_name
|
|
77
|
+
write_config(config)
|
|
78
|
+
sys.stdout.write(f'Authenticated! Profile "{profile_name}" saved.\n')
|
|
79
|
+
return
|
|
80
|
+
if status == "denied":
|
|
81
|
+
print_error("Authentication was denied.", "auth_denied")
|
|
82
|
+
raise typer.Exit(2)
|
|
83
|
+
if status == "expired":
|
|
84
|
+
print_error("Authentication session expired.", "auth_expired")
|
|
85
|
+
raise typer.Exit(2)
|
|
86
|
+
|
|
87
|
+
print_error("Authentication timed out after 120 seconds.", "auth_timeout")
|
|
88
|
+
raise typer.Exit(2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def login(
|
|
92
|
+
profile: Annotated[str, typer.Option("--profile", help="Profile name to store credentials in")] = "default",
|
|
93
|
+
api_url: ApiUrlOpt = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Authenticate with the DataViewer platform."""
|
|
96
|
+
_do_login(profile, api_url)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def logout(
|
|
100
|
+
profile: ProfileOpt = None,
|
|
101
|
+
api_url: ApiUrlOpt = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Log out and revoke the current API key."""
|
|
104
|
+
config = read_config()
|
|
105
|
+
if not config:
|
|
106
|
+
print_error("No config found. Already logged out.", "no_config")
|
|
107
|
+
raise typer.Exit(2)
|
|
108
|
+
profile_name = profile or config.get("current_profile") or "default"
|
|
109
|
+
prof = config["profiles"].get(profile_name)
|
|
110
|
+
|
|
111
|
+
if prof and prof.get("key_id") and prof.get("token"):
|
|
112
|
+
try:
|
|
113
|
+
client = ApiClient(profile=profile_name, api_url=api_url)
|
|
114
|
+
client.delete(f"/v1/api-keys/{q(prof['key_id'])}")
|
|
115
|
+
except typer.Exit:
|
|
116
|
+
sys.stderr.write("Warning: could not revoke API key (already deleted or expired).\n")
|
|
117
|
+
else:
|
|
118
|
+
sys.stderr.write("Warning: no key_id in config — skipping remote key revocation.\n")
|
|
119
|
+
|
|
120
|
+
config["profiles"].pop(profile_name, None)
|
|
121
|
+
if config.get("current_profile") == profile_name:
|
|
122
|
+
remaining = list(config["profiles"].keys())
|
|
123
|
+
config["current_profile"] = remaining[0] if remaining else "default"
|
|
124
|
+
write_config(config)
|
|
125
|
+
sys.stdout.write(f'Logged out of profile "{profile_name}".\n')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def whoami(
|
|
129
|
+
json_out: JsonOpt = False,
|
|
130
|
+
profile: ProfileOpt = None,
|
|
131
|
+
api_url: ApiUrlOpt = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Show the currently authenticated user."""
|
|
134
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
135
|
+
me = client.get("/v1/me").json()
|
|
136
|
+
if json_out:
|
|
137
|
+
print_json(me)
|
|
138
|
+
else:
|
|
139
|
+
print_table(
|
|
140
|
+
["Field", "Value"],
|
|
141
|
+
[
|
|
142
|
+
["ID", me.get("id") or ""],
|
|
143
|
+
["Email", me.get("email") or ""],
|
|
144
|
+
["Name", me.get("name") or me.get("display_name") or ""],
|
|
145
|
+
],
|
|
146
|
+
False,
|
|
147
|
+
[me],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
auth_app = typer.Typer(help="Authentication commands")
|
|
152
|
+
auth_app.command("login")(login)
|
|
153
|
+
auth_app.command("logout")(logout)
|
|
154
|
+
auth_app.command("whoami")(whoami)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def register(root: typer.Typer) -> None:
|
|
158
|
+
root.add_typer(auth_app, name="auth")
|
|
159
|
+
# Top-level aliases: `lql login` / `lql logout` / `lql whoami`
|
|
160
|
+
root.command("login", help="Authenticate with the DataViewer platform")(login)
|
|
161
|
+
root.command("logout", help="Log out (alias for `lql auth logout`)")(logout)
|
|
162
|
+
root.command("whoami", help="Show the currently authenticated user")(whoami)
|
lql/commands/buckets.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Annotated, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .._opts import ApiUrlOpt, JsonOpt, ProfileOpt
|
|
8
|
+
from ..api import ApiClient
|
|
9
|
+
from ..output import print_json, print_table
|
|
10
|
+
from ..util import q
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage S3 and Hugging Face buckets")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_buckets(json_out: JsonOpt = False, profile: ProfileOpt = None, api_url: ApiUrlOpt = None) -> None:
|
|
17
|
+
"""List S3 buckets."""
|
|
18
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
19
|
+
items = client.get("/v1/s3-buckets").json()
|
|
20
|
+
print_table(
|
|
21
|
+
["ID", "Name", "Region"],
|
|
22
|
+
[[b.get("id") or "", b.get("name") or b.get("bucket_name") or "", b.get("region") or ""] for b in items],
|
|
23
|
+
json_out,
|
|
24
|
+
items,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("show")
|
|
29
|
+
def show(
|
|
30
|
+
id: Annotated[str, typer.Argument(help="Bucket ID")],
|
|
31
|
+
json_out: JsonOpt = False,
|
|
32
|
+
profile: ProfileOpt = None,
|
|
33
|
+
api_url: ApiUrlOpt = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Show S3 bucket details."""
|
|
36
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
37
|
+
b = client.get(f"/v1/s3-buckets/{q(id)}").json()
|
|
38
|
+
if json_out:
|
|
39
|
+
print_json(b)
|
|
40
|
+
else:
|
|
41
|
+
print_table(
|
|
42
|
+
["Field", "Value"],
|
|
43
|
+
[
|
|
44
|
+
["ID", b.get("id") or ""],
|
|
45
|
+
["Name", b.get("name") or b.get("bucket_name") or ""],
|
|
46
|
+
["Region", b.get("region") or ""],
|
|
47
|
+
["Endpoint", b.get("endpoint") or ""],
|
|
48
|
+
],
|
|
49
|
+
False,
|
|
50
|
+
[b],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("probe")
|
|
55
|
+
def probe(
|
|
56
|
+
id: Annotated[str, typer.Argument(help="Bucket ID")],
|
|
57
|
+
json_out: JsonOpt = False,
|
|
58
|
+
profile: ProfileOpt = None,
|
|
59
|
+
api_url: ApiUrlOpt = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Probe an S3 bucket to test connectivity."""
|
|
62
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
63
|
+
data = client.post(f"/v1/s3-buckets/{q(id)}/probe").json()
|
|
64
|
+
if json_out:
|
|
65
|
+
print_json(data)
|
|
66
|
+
else:
|
|
67
|
+
sys.stdout.write(f"Probe result: {data.get('status') or json.dumps(data)}\n")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("objects")
|
|
71
|
+
def objects(
|
|
72
|
+
id: Annotated[str, typer.Argument(help="Bucket ID")],
|
|
73
|
+
prefix: Annotated[Optional[str], typer.Option("--prefix", help="Key prefix filter")] = None,
|
|
74
|
+
json_out: JsonOpt = False,
|
|
75
|
+
profile: ProfileOpt = None,
|
|
76
|
+
api_url: ApiUrlOpt = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""List objects in an S3 bucket."""
|
|
79
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
80
|
+
params = {}
|
|
81
|
+
if prefix:
|
|
82
|
+
params["prefix"] = prefix
|
|
83
|
+
data = client.get(f"/v1/s3-buckets/{q(id)}/objects", params=params).json()
|
|
84
|
+
if json_out:
|
|
85
|
+
print_json(data)
|
|
86
|
+
else:
|
|
87
|
+
items = (data.get("objects") if isinstance(data, dict) else data) or []
|
|
88
|
+
print_table(
|
|
89
|
+
["Key", "Size", "Last Modified"],
|
|
90
|
+
[
|
|
91
|
+
[
|
|
92
|
+
o.get("key") or o.get("Key") or "",
|
|
93
|
+
o.get("size") or o.get("Size") or "",
|
|
94
|
+
o.get("last_modified") or o.get("LastModified") or "",
|
|
95
|
+
]
|
|
96
|
+
for o in items
|
|
97
|
+
],
|
|
98
|
+
False,
|
|
99
|
+
items,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("attach")
|
|
104
|
+
def attach(
|
|
105
|
+
bucket_id: Annotated[str, typer.Argument(help="Bucket ID")],
|
|
106
|
+
workspace: Annotated[str, typer.Option("--workspace", help="Workspace ID")],
|
|
107
|
+
json_out: JsonOpt = False,
|
|
108
|
+
profile: ProfileOpt = None,
|
|
109
|
+
api_url: ApiUrlOpt = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Attach a bucket to a workspace."""
|
|
112
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
113
|
+
data = client.post(f"/v1/s3-buckets/{q(bucket_id)}/attach", json={"workspace_id": workspace}).json()
|
|
114
|
+
if json_out:
|
|
115
|
+
print_json(data)
|
|
116
|
+
else:
|
|
117
|
+
sys.stdout.write(f"Attached bucket {bucket_id} to workspace {workspace}.\n")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@app.command("detach")
|
|
121
|
+
def detach(
|
|
122
|
+
bucket_id: Annotated[str, typer.Argument(help="Bucket ID")],
|
|
123
|
+
workspace: Annotated[str, typer.Option("--workspace", help="Workspace ID")],
|
|
124
|
+
profile: ProfileOpt = None,
|
|
125
|
+
api_url: ApiUrlOpt = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Detach a bucket from a workspace."""
|
|
128
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
129
|
+
client.post(f"/v1/s3-buckets/{q(bucket_id)}/detach", json={"workspace_id": workspace})
|
|
130
|
+
sys.stdout.write(f"Detached bucket {bucket_id} from workspace {workspace}.\n")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command("list-hf")
|
|
134
|
+
def list_hf(json_out: JsonOpt = False, profile: ProfileOpt = None, api_url: ApiUrlOpt = None) -> None:
|
|
135
|
+
"""List Hugging Face bucket connections."""
|
|
136
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
137
|
+
items = client.get("/v1/hf-buckets").json()
|
|
138
|
+
print_table(
|
|
139
|
+
["ID", "Bucket", "Label"],
|
|
140
|
+
[[b.get("id") or "", b.get("owner_bucket") or "", b.get("label") or ""] for b in items],
|
|
141
|
+
json_out,
|
|
142
|
+
items,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command("connect-hf")
|
|
147
|
+
def connect_hf(
|
|
148
|
+
bucket: Annotated[str, typer.Argument(metavar="owner/bucket", help="Hugging Face bucket")],
|
|
149
|
+
workspace: Annotated[str, typer.Option("--workspace", help="Workspace ID")],
|
|
150
|
+
label: Annotated[Optional[str], typer.Option("--label", help="Display label")] = None,
|
|
151
|
+
hf_key: Annotated[Optional[str], typer.Option("--hf-key", help="HF API key id (omit for public / server token)")] = None,
|
|
152
|
+
json_out: JsonOpt = False,
|
|
153
|
+
profile: ProfileOpt = None,
|
|
154
|
+
api_url: ApiUrlOpt = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Connect a Hugging Face bucket and attach it to a workspace."""
|
|
157
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
158
|
+
body: dict = {"workspace_id": workspace, "bucket": bucket}
|
|
159
|
+
if label:
|
|
160
|
+
body["label"] = label
|
|
161
|
+
if hf_key:
|
|
162
|
+
body["hf_api_key_id"] = hf_key
|
|
163
|
+
data = client.post("/v1/hf-buckets", json=body).json()
|
|
164
|
+
if json_out:
|
|
165
|
+
print_json(data)
|
|
166
|
+
else:
|
|
167
|
+
sys.stdout.write(f"Connected HF bucket: {data.get('id')}\n")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command("create-dataset")
|
|
171
|
+
def create_dataset(
|
|
172
|
+
bucket_id: Annotated[str, typer.Argument(help="HF bucket ID")],
|
|
173
|
+
workspace: Annotated[str, typer.Option("--workspace", help="Workspace ID")],
|
|
174
|
+
key: Annotated[str, typer.Option("--key", help="Path or glob within the bucket (e.g. data/*.parquet)")],
|
|
175
|
+
name: Annotated[Optional[str], typer.Option("--name", help="Display name")] = None,
|
|
176
|
+
json_out: JsonOpt = False,
|
|
177
|
+
profile: ProfileOpt = None,
|
|
178
|
+
api_url: ApiUrlOpt = None,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Create a dataset from a connected HF bucket."""
|
|
181
|
+
client = ApiClient(profile=profile, api_url=api_url)
|
|
182
|
+
body: dict = {"workspace_id": workspace, "hf_bucket_key": key}
|
|
183
|
+
if name:
|
|
184
|
+
body["display_name"] = name
|
|
185
|
+
data = client.post(f"/v1/hf-buckets/{q(bucket_id)}/datasets", json=body).json()
|
|
186
|
+
if json_out:
|
|
187
|
+
print_json(data)
|
|
188
|
+
else:
|
|
189
|
+
status = f" ({data.get('sync_status')})" if data.get("sync_status") else ""
|
|
190
|
+
sys.stdout.write(f"Created dataset: {data.get('id')}{status}\n")
|