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.
@@ -0,0 +1,7 @@
1
+ """Trossen CLI - A Python CLI for Trossen Cloud."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .cli import app
6
+
7
+ __all__ = ["app", "__version__"]
@@ -0,0 +1,6 @@
1
+ """Entry point for running as a module: python -m trossen_cloud_cli"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)
@@ -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")