trossen-cloud-cli 0.1.2__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.
- trossen_cloud_cli/__init__.py +7 -0
- trossen_cloud_cli/__main__.py +6 -0
- trossen_cloud_cli/api_client.py +269 -0
- trossen_cloud_cli/auth.py +172 -0
- trossen_cloud_cli/cli.py +109 -0
- trossen_cloud_cli/commands/__init__.py +1 -0
- trossen_cloud_cli/commands/auth.py +41 -0
- trossen_cloud_cli/commands/config.py +88 -0
- trossen_cloud_cli/commands/datasets.py +505 -0
- trossen_cloud_cli/commands/models.py +380 -0
- trossen_cloud_cli/commands/training_jobs.py +349 -0
- trossen_cloud_cli/config.py +125 -0
- trossen_cloud_cli/download.py +178 -0
- trossen_cloud_cli/output.py +58 -0
- trossen_cloud_cli/progress.py +270 -0
- trossen_cloud_cli/types.py +159 -0
- trossen_cloud_cli/upload.py +696 -0
- trossen_cloud_cli-0.1.2.dist-info/METADATA +131 -0
- trossen_cloud_cli-0.1.2.dist-info/RECORD +22 -0
- trossen_cloud_cli-0.1.2.dist-info/WHEEL +4 -0
- trossen_cloud_cli-0.1.2.dist-info/entry_points.txt +2 -0
- trossen_cloud_cli-0.1.2.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""HTTP client wrapper with authentication injection."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .auth import get_token
|
|
10
|
+
|
|
11
|
+
API_BASE_URL = os.environ.get("TROSSEN_API_URL", "https://cloud.trossen.com/api/v1")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(Exception):
|
|
15
|
+
"""
|
|
16
|
+
API error with status code and message.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status_code: int, message: str, details: dict | None = None):
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.message = message
|
|
22
|
+
self.details = details or {}
|
|
23
|
+
super().__init__(f"API Error {status_code}: {message}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiClient:
|
|
27
|
+
"""
|
|
28
|
+
HTTP client with authentication and retry logic.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: str | None = None):
|
|
32
|
+
"""
|
|
33
|
+
Initialize API client.
|
|
34
|
+
"""
|
|
35
|
+
self.base_url = base_url or API_BASE_URL
|
|
36
|
+
self._access_token: str | None = None
|
|
37
|
+
self._client: httpx.AsyncClient | None = None
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> "ApiClient":
|
|
40
|
+
"""
|
|
41
|
+
Enter async context.
|
|
42
|
+
"""
|
|
43
|
+
self._access_token = get_token()
|
|
44
|
+
self._client = httpx.AsyncClient(
|
|
45
|
+
base_url=self.base_url,
|
|
46
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
47
|
+
)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
51
|
+
"""
|
|
52
|
+
Exit async context.
|
|
53
|
+
"""
|
|
54
|
+
if self._client:
|
|
55
|
+
await self._client.aclose()
|
|
56
|
+
|
|
57
|
+
def _get_headers(self) -> dict[str, str]:
|
|
58
|
+
"""
|
|
59
|
+
Get request headers with auth token.
|
|
60
|
+
"""
|
|
61
|
+
headers = {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Accept": "application/json",
|
|
64
|
+
}
|
|
65
|
+
if self._access_token:
|
|
66
|
+
headers["X-API-Token"] = self._access_token
|
|
67
|
+
return headers
|
|
68
|
+
|
|
69
|
+
async def _handle_response(self, response: httpx.Response) -> Any:
|
|
70
|
+
"""
|
|
71
|
+
Handle API response and raise errors if needed.
|
|
72
|
+
"""
|
|
73
|
+
if response.status_code == 401:
|
|
74
|
+
raise ApiError(401, "Authentication failed. Check your token or run 'trc auth login'")
|
|
75
|
+
|
|
76
|
+
if response.status_code == 403:
|
|
77
|
+
detail = "Access denied"
|
|
78
|
+
try:
|
|
79
|
+
error_data = response.json()
|
|
80
|
+
if msg := error_data.get("message", error_data.get("detail")):
|
|
81
|
+
detail = msg
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
raise ApiError(403, detail)
|
|
85
|
+
|
|
86
|
+
if response.status_code == 404:
|
|
87
|
+
raise ApiError(404, "Resource not found")
|
|
88
|
+
|
|
89
|
+
if response.status_code >= 400:
|
|
90
|
+
try:
|
|
91
|
+
error_data = response.json()
|
|
92
|
+
message = error_data.get("message", error_data.get("detail", "Unknown error"))
|
|
93
|
+
raise ApiError(response.status_code, message, error_data)
|
|
94
|
+
except ValueError:
|
|
95
|
+
raise ApiError(response.status_code, response.text)
|
|
96
|
+
|
|
97
|
+
if response.status_code == 204:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
return response.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
return {"data": response.text}
|
|
104
|
+
|
|
105
|
+
async def _request_with_retry(
|
|
106
|
+
self,
|
|
107
|
+
method: str,
|
|
108
|
+
path: str,
|
|
109
|
+
max_retries: int = 5,
|
|
110
|
+
**kwargs,
|
|
111
|
+
) -> Any:
|
|
112
|
+
"""
|
|
113
|
+
Make request with exponential backoff retry.
|
|
114
|
+
"""
|
|
115
|
+
if not self._client:
|
|
116
|
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
117
|
+
|
|
118
|
+
last_error: Exception | None = None
|
|
119
|
+
|
|
120
|
+
for attempt in range(max_retries):
|
|
121
|
+
try:
|
|
122
|
+
response = await self._client.request(
|
|
123
|
+
method,
|
|
124
|
+
path,
|
|
125
|
+
headers=self._get_headers(),
|
|
126
|
+
**kwargs,
|
|
127
|
+
)
|
|
128
|
+
return await self._handle_response(response)
|
|
129
|
+
|
|
130
|
+
except httpx.TimeoutException:
|
|
131
|
+
last_error = ApiError(408, "Request timeout")
|
|
132
|
+
except httpx.ConnectError:
|
|
133
|
+
last_error = ApiError(503, "Could not connect to server")
|
|
134
|
+
except ApiError as e:
|
|
135
|
+
if e.status_code >= 500 or e.status_code == 429:
|
|
136
|
+
last_error = e
|
|
137
|
+
else:
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
# Exponential backoff
|
|
141
|
+
if attempt < max_retries - 1:
|
|
142
|
+
wait_time = (2**attempt) * 0.5
|
|
143
|
+
await asyncio.sleep(wait_time)
|
|
144
|
+
|
|
145
|
+
if last_error:
|
|
146
|
+
raise last_error
|
|
147
|
+
raise ApiError(500, "Unknown error occurred")
|
|
148
|
+
|
|
149
|
+
async def get(self, path: str, params: dict | None = None) -> Any:
|
|
150
|
+
"""
|
|
151
|
+
Make GET request.
|
|
152
|
+
"""
|
|
153
|
+
return await self._request_with_retry("GET", path, params=params)
|
|
154
|
+
|
|
155
|
+
async def post(self, path: str, json: dict | None = None) -> Any:
|
|
156
|
+
"""
|
|
157
|
+
Make POST request.
|
|
158
|
+
"""
|
|
159
|
+
return await self._request_with_retry("POST", path, json=json)
|
|
160
|
+
|
|
161
|
+
async def put(self, path: str, json: dict | None = None) -> Any:
|
|
162
|
+
"""
|
|
163
|
+
Make PUT request.
|
|
164
|
+
"""
|
|
165
|
+
return await self._request_with_retry("PUT", path, json=json)
|
|
166
|
+
|
|
167
|
+
async def delete(self, path: str) -> Any:
|
|
168
|
+
"""
|
|
169
|
+
Make DELETE request.
|
|
170
|
+
"""
|
|
171
|
+
return await self._request_with_retry("DELETE", path)
|
|
172
|
+
|
|
173
|
+
async def patch(self, path: str, json: dict | None = None) -> Any:
|
|
174
|
+
"""
|
|
175
|
+
Make PATCH request.
|
|
176
|
+
"""
|
|
177
|
+
return await self._request_with_retry("PATCH", path, json=json)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SyncApiClient:
|
|
181
|
+
"""
|
|
182
|
+
Synchronous wrapper around the async API client.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(self, base_url: str | None = None):
|
|
186
|
+
"""
|
|
187
|
+
Initialize sync API client.
|
|
188
|
+
"""
|
|
189
|
+
self.base_url = base_url
|
|
190
|
+
self._async_client: ApiClient | None = None
|
|
191
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
192
|
+
|
|
193
|
+
def __enter__(self) -> "SyncApiClient":
|
|
194
|
+
"""
|
|
195
|
+
Enter sync context.
|
|
196
|
+
"""
|
|
197
|
+
self._loop = asyncio.new_event_loop()
|
|
198
|
+
self._async_client = ApiClient(self.base_url)
|
|
199
|
+
try:
|
|
200
|
+
self._loop.run_until_complete(self._async_client.__aenter__())
|
|
201
|
+
except BaseException:
|
|
202
|
+
self._loop.close()
|
|
203
|
+
self._loop = None
|
|
204
|
+
raise
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
208
|
+
"""
|
|
209
|
+
Exit sync context.
|
|
210
|
+
"""
|
|
211
|
+
if self._async_client and self._loop:
|
|
212
|
+
self._loop.run_until_complete(self._async_client.__aexit__(exc_type, exc_val, exc_tb))
|
|
213
|
+
if self._loop:
|
|
214
|
+
self._loop.close()
|
|
215
|
+
|
|
216
|
+
def _run(self, coro):
|
|
217
|
+
"""
|
|
218
|
+
Run coroutine synchronously.
|
|
219
|
+
"""
|
|
220
|
+
if not self._loop:
|
|
221
|
+
raise RuntimeError("Client not initialized. Use context manager.")
|
|
222
|
+
return self._loop.run_until_complete(coro)
|
|
223
|
+
|
|
224
|
+
def get(self, path: str, params: dict | None = None) -> Any:
|
|
225
|
+
"""
|
|
226
|
+
Make GET request.
|
|
227
|
+
"""
|
|
228
|
+
if not self._async_client:
|
|
229
|
+
raise RuntimeError("Client not initialized. Use context manager.")
|
|
230
|
+
return self._run(self._async_client.get(path, params))
|
|
231
|
+
|
|
232
|
+
def post(self, path: str, json: dict | None = None) -> Any:
|
|
233
|
+
"""
|
|
234
|
+
Make POST request.
|
|
235
|
+
"""
|
|
236
|
+
if not self._async_client:
|
|
237
|
+
raise RuntimeError("Client not initialized. Use context manager.")
|
|
238
|
+
return self._run(self._async_client.post(path, json))
|
|
239
|
+
|
|
240
|
+
def put(self, path: str, json: dict | None = None) -> Any:
|
|
241
|
+
"""
|
|
242
|
+
Make PUT request.
|
|
243
|
+
"""
|
|
244
|
+
if not self._async_client:
|
|
245
|
+
raise RuntimeError("Client not initialized. Use context manager.")
|
|
246
|
+
return self._run(self._async_client.put(path, json))
|
|
247
|
+
|
|
248
|
+
def delete(self, path: str) -> Any:
|
|
249
|
+
"""
|
|
250
|
+
Make DELETE request.
|
|
251
|
+
"""
|
|
252
|
+
if not self._async_client:
|
|
253
|
+
raise RuntimeError("Client not initialized. Use context manager.")
|
|
254
|
+
return self._run(self._async_client.delete(path))
|
|
255
|
+
|
|
256
|
+
def patch(self, path: str, json: dict | None = None) -> Any:
|
|
257
|
+
"""
|
|
258
|
+
Make PATCH request.
|
|
259
|
+
"""
|
|
260
|
+
if not self._async_client:
|
|
261
|
+
raise RuntimeError("Client not initialized. Use context manager.")
|
|
262
|
+
return self._run(self._async_client.patch(path, json))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_api_client() -> ApiClient:
|
|
266
|
+
"""
|
|
267
|
+
Get a new API client instance.
|
|
268
|
+
"""
|
|
269
|
+
return ApiClient()
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Authentication commands and token management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
import typer
|
|
8
|
+
from rich.prompt import Prompt
|
|
9
|
+
|
|
10
|
+
from .config import get_token_file
|
|
11
|
+
from .output import console
|
|
12
|
+
from .types import StoredToken
|
|
13
|
+
|
|
14
|
+
KEYRING_SERVICE = "trossen-cloud-cli"
|
|
15
|
+
KEYRING_USERNAME = "tokens"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _store_token_keyring(token: str) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Store token in system keyring.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
data = StoredToken(token=token).model_dump_json()
|
|
24
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, data)
|
|
25
|
+
return True
|
|
26
|
+
except Exception:
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _store_token_file(token: str) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Store token in file with restricted permissions.
|
|
33
|
+
"""
|
|
34
|
+
token_file = get_token_file()
|
|
35
|
+
data = StoredToken(token=token).model_dump_json()
|
|
36
|
+
|
|
37
|
+
with open(token_file, "w") as f:
|
|
38
|
+
f.write(data)
|
|
39
|
+
|
|
40
|
+
# Set file permissions to owner-only read/write
|
|
41
|
+
os.chmod(token_file, stat.S_IRUSR | stat.S_IWUSR)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_token_keyring() -> str | None:
|
|
45
|
+
"""
|
|
46
|
+
Load token from system keyring.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
data = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
50
|
+
if data:
|
|
51
|
+
return StoredToken.model_validate_json(data).token
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_token_file() -> str | None:
|
|
58
|
+
"""
|
|
59
|
+
Load token from file.
|
|
60
|
+
"""
|
|
61
|
+
token_file = get_token_file()
|
|
62
|
+
if token_file.exists():
|
|
63
|
+
try:
|
|
64
|
+
with open(token_file) as f:
|
|
65
|
+
data = f.read()
|
|
66
|
+
return StoredToken.model_validate_json(data).token
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def store_token(token: str) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Store token securely (keyring with file fallback).
|
|
75
|
+
"""
|
|
76
|
+
if not _store_token_keyring(token):
|
|
77
|
+
_store_token_file(token)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_token() -> str | None:
|
|
81
|
+
"""
|
|
82
|
+
Load token from storage. Env var takes priority.
|
|
83
|
+
"""
|
|
84
|
+
if token := os.environ.get("TROSSEN_TOKEN"):
|
|
85
|
+
return token
|
|
86
|
+
|
|
87
|
+
# Try keyring first, then file
|
|
88
|
+
token = _load_token_keyring()
|
|
89
|
+
if token is None:
|
|
90
|
+
token = _load_token_file()
|
|
91
|
+
|
|
92
|
+
return token
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_token() -> str | None:
|
|
96
|
+
"""
|
|
97
|
+
Get the stored API token.
|
|
98
|
+
"""
|
|
99
|
+
return load_token()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def clear_token() -> None:
|
|
103
|
+
"""
|
|
104
|
+
Clear stored token.
|
|
105
|
+
"""
|
|
106
|
+
# Clear from keyring
|
|
107
|
+
try:
|
|
108
|
+
keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# Clear from file
|
|
113
|
+
token_file = get_token_file()
|
|
114
|
+
if token_file.exists():
|
|
115
|
+
token_file.unlink()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def require_auth() -> str:
|
|
119
|
+
"""
|
|
120
|
+
Require authentication and return API token.
|
|
121
|
+
"""
|
|
122
|
+
token = get_token()
|
|
123
|
+
if not token:
|
|
124
|
+
console.print("[error]Not authenticated. Please run 'trc auth login' first.[/error]")
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
return token
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# CLI Commands
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def login_command(token: str | None = None) -> None:
|
|
133
|
+
"""
|
|
134
|
+
Log in to Trossen Cloud by storing an API token.
|
|
135
|
+
"""
|
|
136
|
+
token = token or os.environ.get("TROSSEN_TOKEN")
|
|
137
|
+
|
|
138
|
+
if not token:
|
|
139
|
+
token = Prompt.ask("API token", password=True)
|
|
140
|
+
|
|
141
|
+
store_token(token)
|
|
142
|
+
prefix = token[:10] if len(token) >= 10 else token
|
|
143
|
+
console.print(f"[success]Token stored ({prefix}...)[/success]")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def logout_command() -> None:
|
|
147
|
+
"""
|
|
148
|
+
Log out and clear stored credentials.
|
|
149
|
+
"""
|
|
150
|
+
clear_token()
|
|
151
|
+
console.print("[success]Logged out[/success]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def status_command() -> None:
|
|
155
|
+
"""
|
|
156
|
+
Show authentication status.
|
|
157
|
+
"""
|
|
158
|
+
token = get_token()
|
|
159
|
+
if not token:
|
|
160
|
+
console.print("[warning]Not authenticated.[/warning]")
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
from .api_client import ApiError, SyncApiClient
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with SyncApiClient() as client:
|
|
167
|
+
data = client.get("/users/me")
|
|
168
|
+
username = data.get("username", "unknown")
|
|
169
|
+
console.print(f"[success]Authenticated[/success] as [bold]{username}[/bold]")
|
|
170
|
+
except ApiError as e:
|
|
171
|
+
console.print(f"[error]Authentication failed: {e.message}[/error]")
|
|
172
|
+
raise typer.Exit(1)
|
trossen_cloud_cli/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Main CLI app definition."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import typer.rich_utils
|
|
7
|
+
|
|
8
|
+
from .commands import auth, config, datasets, models, training_jobs
|
|
9
|
+
from .output import console
|
|
10
|
+
|
|
11
|
+
# Override Typer's default styling for better readability on light and dark terminals.
|
|
12
|
+
typer.rich_utils.STYLE_ERRORS_PANEL_BORDER = "red1"
|
|
13
|
+
typer.rich_utils.STYLE_ERRORS_SUGGESTION = "bold"
|
|
14
|
+
typer.rich_utils.RICH_HELP = "Try [bold dodger_blue2]'{command_path} {help_option}'[/] for help."
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Create main app
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="trc",
|
|
20
|
+
help="CLI for interacting with Trossen Cloud datasets and models APIs.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def main_callback(
|
|
27
|
+
quiet: Annotated[
|
|
28
|
+
bool,
|
|
29
|
+
typer.Option("--quiet", "-q", help="Suppress all output"),
|
|
30
|
+
] = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Trossen Cloud CLI.
|
|
34
|
+
"""
|
|
35
|
+
if quiet:
|
|
36
|
+
console.quiet = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Add subcommands
|
|
40
|
+
app.add_typer(auth.app, name="auth")
|
|
41
|
+
app.add_typer(config.app, name="config")
|
|
42
|
+
app.add_typer(datasets.app, name="dataset")
|
|
43
|
+
app.add_typer(models.app, name="model")
|
|
44
|
+
app.add_typer(training_jobs.app, name="training-job")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
USAGE_TEXT = """\
|
|
48
|
+
[bold]Getting started:[/bold]
|
|
49
|
+
|
|
50
|
+
trc auth login --token <your-api-token>
|
|
51
|
+
trc auth status
|
|
52
|
+
|
|
53
|
+
[bold]Datasets:[/bold]
|
|
54
|
+
|
|
55
|
+
trc dataset upload ./my-data --name my-dataset --type lerobot
|
|
56
|
+
trc dataset import-hf org/dataset-name --name my-dataset
|
|
57
|
+
trc dataset download <dataset-id> ./output
|
|
58
|
+
trc dataset list --mine
|
|
59
|
+
trc dataset info <dataset-id>
|
|
60
|
+
trc dataset view <user>/<name>
|
|
61
|
+
trc dataset update <dataset-id> --name new-name --privacy public
|
|
62
|
+
trc dataset delete <dataset-id>
|
|
63
|
+
|
|
64
|
+
[bold]Models:[/bold]
|
|
65
|
+
|
|
66
|
+
trc model upload ./my-model --name my-model
|
|
67
|
+
trc model download <model-id> ./output
|
|
68
|
+
trc model list --mine
|
|
69
|
+
trc model info <model-id>
|
|
70
|
+
|
|
71
|
+
[bold]Training jobs:[/bold]
|
|
72
|
+
|
|
73
|
+
trc training-job create --name my-job --base-model-id <id> --dataset-id <id>
|
|
74
|
+
trc training-job list
|
|
75
|
+
trc training-job info <job-id>
|
|
76
|
+
trc training-job cancel <job-id>
|
|
77
|
+
trc training-job models <job-id>
|
|
78
|
+
|
|
79
|
+
[bold]Configuration:[/bold]
|
|
80
|
+
|
|
81
|
+
trc config show
|
|
82
|
+
trc config set upload.chunk_size_mb 100
|
|
83
|
+
trc config reset
|
|
84
|
+
|
|
85
|
+
[bold]Options:[/bold]
|
|
86
|
+
|
|
87
|
+
-q, --quiet Suppress all output
|
|
88
|
+
TROSSEN_API_URL Override the API endpoint
|
|
89
|
+
TROSSEN_TOKEN Override the stored auth token
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def usage() -> None:
|
|
95
|
+
"""
|
|
96
|
+
Show usage examples.
|
|
97
|
+
"""
|
|
98
|
+
console.print(USAGE_TEXT)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main() -> None:
|
|
102
|
+
"""
|
|
103
|
+
Main entry point.
|
|
104
|
+
"""
|
|
105
|
+
app()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Commands package."""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Authentication commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .. import auth as auth_module
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Authentication commands")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("login")
|
|
13
|
+
def login(
|
|
14
|
+
token: Annotated[
|
|
15
|
+
str | None,
|
|
16
|
+
typer.Option("--token", "-t", help="API token"),
|
|
17
|
+
] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Log in to Trossen Cloud.
|
|
21
|
+
|
|
22
|
+
Token can also be provided via the TROSSEN_TOKEN environment variable.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
auth_module.login_command(token)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("logout")
|
|
29
|
+
def logout() -> None:
|
|
30
|
+
"""
|
|
31
|
+
Log out and clear stored credentials.
|
|
32
|
+
"""
|
|
33
|
+
auth_module.logout_command()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("status")
|
|
37
|
+
def status() -> None:
|
|
38
|
+
"""
|
|
39
|
+
Show authentication status.
|
|
40
|
+
"""
|
|
41
|
+
auth_module.status_command()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Configuration commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..config import Config, get_config, load_config, reset_config, save_config
|
|
8
|
+
from ..output import console, print_error, print_success
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="View and manage CLI configuration.")
|
|
11
|
+
|
|
12
|
+
# Flat map of settable keys to (section, field, type)
|
|
13
|
+
CONFIG_KEYS: dict[str, tuple[str, str, type]] = {
|
|
14
|
+
"upload.chunk_size_mb": ("upload", "chunk_size_mb", int),
|
|
15
|
+
"upload.parallel_parts": ("upload", "parallel_parts", int),
|
|
16
|
+
"upload.parallel_files": ("upload", "parallel_files", int),
|
|
17
|
+
"download.parallel_files": ("download", "parallel_files", int),
|
|
18
|
+
"download.stream_chunk_size": ("download", "stream_chunk_size", int),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("show")
|
|
23
|
+
def show_command() -> None:
|
|
24
|
+
"""
|
|
25
|
+
Show current configuration.
|
|
26
|
+
"""
|
|
27
|
+
config = get_config()
|
|
28
|
+
|
|
29
|
+
console.print("\n[heading]Upload[/heading]")
|
|
30
|
+
console.print(f" chunk_size_mb {config.upload.chunk_size_mb}")
|
|
31
|
+
console.print(f" parallel_parts {config.upload.parallel_parts}")
|
|
32
|
+
console.print(f" parallel_files {config.upload.parallel_files}")
|
|
33
|
+
|
|
34
|
+
console.print("\n[heading]Download[/heading]")
|
|
35
|
+
console.print(f" parallel_files {config.download.parallel_files}")
|
|
36
|
+
console.print(f" stream_chunk_size {config.download.stream_chunk_size}")
|
|
37
|
+
console.print()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command("set")
|
|
41
|
+
def set_command(
|
|
42
|
+
key: Annotated[str, typer.Argument(help="Config key (e.g. upload.chunk_size_mb)")],
|
|
43
|
+
value: Annotated[str, typer.Argument(help="Value to set")],
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Set a configuration value.
|
|
47
|
+
"""
|
|
48
|
+
if key not in CONFIG_KEYS:
|
|
49
|
+
print_error(f"Unknown key: {key}")
|
|
50
|
+
console.print(f"[muted]Valid keys: {', '.join(sorted(CONFIG_KEYS))}[/muted]")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
section, field, field_type = CONFIG_KEYS[key]
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
parsed = field_type(value)
|
|
57
|
+
except ValueError:
|
|
58
|
+
print_error(f"Invalid value for {key}: expected {field_type.__name__}")
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
if parsed <= 0:
|
|
62
|
+
print_error("Value must be positive")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
# Load fresh from disk, apply change, save
|
|
66
|
+
config = load_config()
|
|
67
|
+
setattr(getattr(config, section), field, parsed)
|
|
68
|
+
save_config(config)
|
|
69
|
+
reset_config()
|
|
70
|
+
|
|
71
|
+
print_success(f"{key} = {parsed}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("reset")
|
|
75
|
+
def reset_command(
|
|
76
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Reset configuration to defaults.
|
|
80
|
+
"""
|
|
81
|
+
if not force:
|
|
82
|
+
typer.confirm("Reset all settings to defaults?", abort=True)
|
|
83
|
+
|
|
84
|
+
config = Config()
|
|
85
|
+
save_config(config)
|
|
86
|
+
reset_config()
|
|
87
|
+
|
|
88
|
+
print_success("Configuration reset to defaults")
|