race-ai 0.1.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.
race_ai-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Race Engineering
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Binary file
race_ai-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: race-ai
3
+ Version: 0.1.0
4
+ Summary: Race Engineering GPU cloud CLI
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://raceengineering.ai
7
+ Project-URL: Documentation, https://raceengineering.ai/docs/cli
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ License-File: LICENSE:Zone.Identifier
22
+ Requires-Dist: typer<1.0,>=0.9
23
+ Requires-Dist: requests<3.0,>=2.31
24
+ Requires-Dist: keyring<26.0,>=24.0
25
+ Requires-Dist: rich<15.0,>=13.0
26
+ Dynamic: license-file
27
+
28
+ # race-cli
29
+
30
+ Command-line interface for the Race Engineering GPU cloud platform.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install race-cli
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ # Generate an API key first, via the Race Engineering web dashboard
42
+ # (Account Settings -> API Keys -> Create Key)
43
+
44
+ race login --key rk_xxxxxxxxxxxxxxxxxxxxxxxx
45
+ race deploy --gpu <slice_id> --template <template_id>
46
+ race status <pod_id>
47
+ ```
48
+
49
+ ## Commands
50
+
51
+ | Command | Description |
52
+ |---|---|
53
+ | `race login --key <key>` | Store an API key locally (OS keychain — never a plaintext file) |
54
+ | `race logout` | Remove the locally stored key |
55
+ | `race deploy --gpu <slice_id> --template <template_id>` | Deploy a new GPU pod |
56
+ | `race list` | List your pods |
57
+ | `race status <pod_id>` | Check a pod's deploy status |
58
+ | `race pause <pod_id>` | Pause a running pod |
59
+ | `race resume <pod_id>` | Resume a paused pod |
60
+ | `race terminate <pod_id> [--yes]` | Terminate a pod (asks for confirmation unless `--yes`) |
61
+
62
+ Run `race --help` or `race <command> --help` for full option details at any time.
63
+
64
+ ## Configuration
65
+
66
+ By default the CLI talks to the production API. To point it elsewhere (e.g. a
67
+ self-hosted or staging instance):
68
+
69
+ ```bash
70
+ race login --key <key> --base-url https://your-instance.example.com
71
+ ```
72
+
73
+ The base URL is remembered for future commands — you don't need to pass it every time.
74
+
75
+ ## Authentication & security
76
+
77
+ - API keys are generated through the web dashboard, not this CLI — `race login` only
78
+ *stores* a key you already have.
79
+ - Keys are stored using your operating system's credential store (macOS Keychain,
80
+ Windows Credential Manager, Linux Secret Service) via the `keyring` package — never
81
+ written to a plaintext file on disk.
82
+ - `race login` validates the key against the API before storing it, so a mistyped or
83
+ invalid key is never silently saved.
84
+
85
+ ## Retries and idempotency
86
+
87
+ `race deploy` automatically retries once on a network-layer failure (timeout, dropped
88
+ connection) using the same idempotency key for every retry of that attempt — this
89
+ guarantees a retried deploy can never accidentally create a second pod. A definitive
90
+ error response from the server (e.g. insufficient balance, invalid template) is not
91
+ retried.
92
+
93
+ ## Requirements
94
+
95
+ - Python 3.9+
96
+ - A Race Engineering account and API key
97
+
98
+ ## License
99
+
100
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,73 @@
1
+ # race-cli
2
+
3
+ Command-line interface for the Race Engineering GPU cloud platform.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install race-cli
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ # Generate an API key first, via the Race Engineering web dashboard
15
+ # (Account Settings -> API Keys -> Create Key)
16
+
17
+ race login --key rk_xxxxxxxxxxxxxxxxxxxxxxxx
18
+ race deploy --gpu <slice_id> --template <template_id>
19
+ race status <pod_id>
20
+ ```
21
+
22
+ ## Commands
23
+
24
+ | Command | Description |
25
+ |---|---|
26
+ | `race login --key <key>` | Store an API key locally (OS keychain — never a plaintext file) |
27
+ | `race logout` | Remove the locally stored key |
28
+ | `race deploy --gpu <slice_id> --template <template_id>` | Deploy a new GPU pod |
29
+ | `race list` | List your pods |
30
+ | `race status <pod_id>` | Check a pod's deploy status |
31
+ | `race pause <pod_id>` | Pause a running pod |
32
+ | `race resume <pod_id>` | Resume a paused pod |
33
+ | `race terminate <pod_id> [--yes]` | Terminate a pod (asks for confirmation unless `--yes`) |
34
+
35
+ Run `race --help` or `race <command> --help` for full option details at any time.
36
+
37
+ ## Configuration
38
+
39
+ By default the CLI talks to the production API. To point it elsewhere (e.g. a
40
+ self-hosted or staging instance):
41
+
42
+ ```bash
43
+ race login --key <key> --base-url https://your-instance.example.com
44
+ ```
45
+
46
+ The base URL is remembered for future commands — you don't need to pass it every time.
47
+
48
+ ## Authentication & security
49
+
50
+ - API keys are generated through the web dashboard, not this CLI — `race login` only
51
+ *stores* a key you already have.
52
+ - Keys are stored using your operating system's credential store (macOS Keychain,
53
+ Windows Credential Manager, Linux Secret Service) via the `keyring` package — never
54
+ written to a plaintext file on disk.
55
+ - `race login` validates the key against the API before storing it, so a mistyped or
56
+ invalid key is never silently saved.
57
+
58
+ ## Retries and idempotency
59
+
60
+ `race deploy` automatically retries once on a network-layer failure (timeout, dropped
61
+ connection) using the same idempotency key for every retry of that attempt — this
62
+ guarantees a retried deploy can never accidentally create a second pod. A definitive
63
+ error response from the server (e.g. insufficient balance, invalid template) is not
64
+ retried.
65
+
66
+ ## Requirements
67
+
68
+ - Python 3.9+
69
+ - A Race Engineering account and API key
70
+
71
+ ## License
72
+
73
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "race-ai"
3
+ version = "0.1.0"
4
+ description = "Race Engineering GPU cloud CLI"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "typer>=0.9,<1.0",
10
+ "requests>=2.31,<3.0",
11
+ "keyring>=24.0,<26.0",
12
+ "rich>=13.0,<15.0",
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Utilities",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://raceengineering.ai"
29
+ Documentation = "https://raceengineering.ai/docs/cli"
30
+
31
+ [project.scripts]
32
+ race = "race_cli.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=61.0"]
36
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: race-ai
3
+ Version: 0.1.0
4
+ Summary: Race Engineering GPU cloud CLI
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://raceengineering.ai
7
+ Project-URL: Documentation, https://raceengineering.ai/docs/cli
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ License-File: LICENSE:Zone.Identifier
22
+ Requires-Dist: typer<1.0,>=0.9
23
+ Requires-Dist: requests<3.0,>=2.31
24
+ Requires-Dist: keyring<26.0,>=24.0
25
+ Requires-Dist: rich<15.0,>=13.0
26
+ Dynamic: license-file
27
+
28
+ # race-cli
29
+
30
+ Command-line interface for the Race Engineering GPU cloud platform.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install race-cli
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```bash
41
+ # Generate an API key first, via the Race Engineering web dashboard
42
+ # (Account Settings -> API Keys -> Create Key)
43
+
44
+ race login --key rk_xxxxxxxxxxxxxxxxxxxxxxxx
45
+ race deploy --gpu <slice_id> --template <template_id>
46
+ race status <pod_id>
47
+ ```
48
+
49
+ ## Commands
50
+
51
+ | Command | Description |
52
+ |---|---|
53
+ | `race login --key <key>` | Store an API key locally (OS keychain — never a plaintext file) |
54
+ | `race logout` | Remove the locally stored key |
55
+ | `race deploy --gpu <slice_id> --template <template_id>` | Deploy a new GPU pod |
56
+ | `race list` | List your pods |
57
+ | `race status <pod_id>` | Check a pod's deploy status |
58
+ | `race pause <pod_id>` | Pause a running pod |
59
+ | `race resume <pod_id>` | Resume a paused pod |
60
+ | `race terminate <pod_id> [--yes]` | Terminate a pod (asks for confirmation unless `--yes`) |
61
+
62
+ Run `race --help` or `race <command> --help` for full option details at any time.
63
+
64
+ ## Configuration
65
+
66
+ By default the CLI talks to the production API. To point it elsewhere (e.g. a
67
+ self-hosted or staging instance):
68
+
69
+ ```bash
70
+ race login --key <key> --base-url https://your-instance.example.com
71
+ ```
72
+
73
+ The base URL is remembered for future commands — you don't need to pass it every time.
74
+
75
+ ## Authentication & security
76
+
77
+ - API keys are generated through the web dashboard, not this CLI — `race login` only
78
+ *stores* a key you already have.
79
+ - Keys are stored using your operating system's credential store (macOS Keychain,
80
+ Windows Credential Manager, Linux Secret Service) via the `keyring` package — never
81
+ written to a plaintext file on disk.
82
+ - `race login` validates the key against the API before storing it, so a mistyped or
83
+ invalid key is never silently saved.
84
+
85
+ ## Retries and idempotency
86
+
87
+ `race deploy` automatically retries once on a network-layer failure (timeout, dropped
88
+ connection) using the same idempotency key for every retry of that attempt — this
89
+ guarantees a retried deploy can never accidentally create a second pod. A definitive
90
+ error response from the server (e.g. insufficient balance, invalid template) is not
91
+ retried.
92
+
93
+ ## Requirements
94
+
95
+ - Python 3.9+
96
+ - A Race Engineering account and API key
97
+
98
+ ## License
99
+
100
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ LICENSE:Zone.Identifier
3
+ README.md
4
+ pyproject.toml
5
+ race_ai.egg-info/PKG-INFO
6
+ race_ai.egg-info/SOURCES.txt
7
+ race_ai.egg-info/dependency_links.txt
8
+ race_ai.egg-info/entry_points.txt
9
+ race_ai.egg-info/requires.txt
10
+ race_ai.egg-info/top_level.txt
11
+ race_cli/__init__.py
12
+ race_cli/__main__.py
13
+ race_cli/api_client.py
14
+ race_cli/cli.py
15
+ race_cli/config.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ race = race_cli.cli:main
@@ -0,0 +1,4 @@
1
+ typer<1.0,>=0.9
2
+ requests<3.0,>=2.31
3
+ keyring<26.0,>=24.0
4
+ rich<15.0,>=13.0
@@ -0,0 +1 @@
1
+ race_cli
File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,180 @@
1
+ """
2
+ Thin wrapper over the race API. Every CLI command goes through here —
3
+ never makes a raw `requests` call inline in a command function. This is
4
+ the same services-vs-views separation from the backend, applied to the
5
+ CLI: command functions (cli.py) handle argument parsing and terminal
6
+ output; this module handles "how do I actually talk to the API."
7
+ """
8
+
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import requests
14
+
15
+ from . import config
16
+
17
+
18
+ class RaceAPIError(Exception):
19
+ """
20
+ Raised for any non-2xx response. Carries the parsed error message
21
+ (when the API returned one) so command functions can show the user
22
+ something meaningful instead of a raw traceback.
23
+ """
24
+
25
+ def __init__(self, status_code: int, message: str):
26
+ self.status_code = status_code
27
+ self.message = message
28
+ super().__init__(f"[{status_code}] {message}")
29
+
30
+
31
+ class NotLoggedInError(Exception):
32
+ """Raised when a command needs an API key but none is stored yet."""
33
+
34
+
35
+ @dataclass
36
+ class Client:
37
+ base_url: str
38
+ api_key: str
39
+ timeout_seconds: int = 30
40
+
41
+ def _headers(self, idempotency_key: str | None = None) -> dict:
42
+ headers = {
43
+ "Authorization": f"Bearer {self.api_key}",
44
+ "Content-Type": "application/json",
45
+ }
46
+ if idempotency_key:
47
+ headers["Idempotency-Key"] = idempotency_key
48
+ return headers
49
+
50
+ def _request(self, method: str, path: str, idempotency_key=None, **kwargs) -> Any:
51
+ url = f"{self.base_url.rstrip('/')}{path}"
52
+ try:
53
+ resp = requests.request(
54
+ method,
55
+ url,
56
+ headers=self._headers(idempotency_key),
57
+ timeout=self.timeout_seconds,
58
+ **kwargs,
59
+ )
60
+ except requests.ConnectionError as e:
61
+ raise RaceAPIError(0, f"Could not reach {self.base_url}: {e}") from e
62
+ except requests.Timeout as e:
63
+ raise RaceAPIError(0, f"Request timed out after {self.timeout_seconds}s") from e
64
+
65
+ if resp.status_code >= 400:
66
+ try:
67
+ body = resp.json()
68
+ message = (
69
+ body.get("message")
70
+ or body.get("detail")
71
+ or str(body)
72
+ )
73
+ except ValueError:
74
+ message = resp.text or f"HTTP {resp.status_code}"
75
+ raise RaceAPIError(resp.status_code, message)
76
+
77
+ if resp.status_code == 204 or not resp.content:
78
+ return None
79
+ return resp.json()
80
+
81
+ def whoami(self) -> dict:
82
+ return self._request("GET", "/v1/api-keys/whoami/")
83
+
84
+ def list_pods(self) -> list:
85
+ """
86
+ Real response shape: {"status": "success", "data": [...]}, not a
87
+ bare array — unwrap here so cli.py never has to know about this
88
+ envelope, matching the plan: cli.py works with clean Python
89
+ values, api_client.py absorbs the API's actual wire format.
90
+ """
91
+ result = self._request("GET", "/v1/gpu/pod-list/")
92
+ return result.get("data", []) if result else []
93
+
94
+ def list_gpus(self) -> list:
95
+ """
96
+ GPUListAPIView returns Response(serializer.data) directly -- a
97
+ bare array, NOT wrapped in {"status", "data"} like PodListAPIView.
98
+ Confirmed against the literal current return statement in
99
+ gpu_list_view.py, not assumed from field-name patterns (an
100
+ earlier version of this method wrongly assumed the wrapper and
101
+ crashed against a real server with AttributeError).
102
+ """
103
+ return self._request("GET", "/v1/gpu/gpu-list/") or []
104
+
105
+ def list_templates(self) -> list:
106
+ """
107
+ TemplateListAPIView returns Response(serializer.data) directly --
108
+ a bare array, confirmed against the literal current return
109
+ statement in template_view.py (same pattern as GPUListAPIView,
110
+ both differ from PodListAPIView's {"status","data"} wrapper).
111
+ """
112
+ return self._request("GET", "/v1/gpu/templates/") or []
113
+
114
+ def get_pod_status(self, pod_id: str) -> dict:
115
+ return self._request("GET", f"/v1/gpu/deploy-status/?pod_id={pod_id}")
116
+
117
+ def deploy_pod(
118
+ self,
119
+ slice_id: str,
120
+ template_id: str | None = None,
121
+ custom_template_id: str | None = None,
122
+ disk_size: int = 50,
123
+ pod_name: str | None = None,
124
+ idempotency_key: str | None = None,
125
+ ) -> dict:
126
+ """
127
+ idempotency_key: a client-generated UUID for THIS deploy attempt.
128
+ Callers should generate ONE key per logical deploy intent and
129
+ reuse it across retries of that same attempt (see cli.py's
130
+ `deploy` command) — a fresh key per retry would defeat the whole
131
+ point, since the server relies on seeing the SAME key again to
132
+ recognize "this is a retry of something I already handled," not
133
+ a brand new deploy.
134
+ """
135
+ payload = {
136
+ "slice_id": slice_id,
137
+ "disk_size": disk_size,
138
+ }
139
+ if template_id:
140
+ payload["template_id"] = template_id
141
+ if custom_template_id:
142
+ payload["custom_template_id"] = custom_template_id
143
+ if pod_name:
144
+ payload["pod_name"] = pod_name
145
+
146
+ return self._request(
147
+ "POST", "/v1/gpu/start-pod/", json=payload, idempotency_key=idempotency_key
148
+ )
149
+
150
+ def pause_pod(self, pod_id: str) -> dict:
151
+ return self._request("POST", "/v1/gpu/pause-pod/", json={"pod_id": pod_id})
152
+
153
+ def resume_pod(self, pod_id: str) -> dict:
154
+ return self._request("POST", "/v1/gpu/resume-pod/", json={"pod_id": pod_id})
155
+
156
+ def terminate_pod(self, pod_id: str) -> dict:
157
+ # TerminatePodAPIView only implements post(), not delete() --
158
+ # confirmed against the real view source. DELETE is the more
159
+ # conventional REST verb for this action, but this API doesn't
160
+ # follow that convention here.
161
+ return self._request("POST", "/v1/gpu/terminate-pod/", json={"pod_id": pod_id})
162
+
163
+
164
+ def get_client() -> Client:
165
+ """
166
+ Builds a Client from stored config/credentials. Raises
167
+ NotLoggedInError (never a bare KeyError/AttributeError) if no key is
168
+ stored — every command function calls this first and lets that
169
+ exception propagate to a single, consistent "please run race login"
170
+ message, rather than each command handling the missing-key case
171
+ separately in a slightly different way.
172
+ """
173
+ api_key = config.load_api_key()
174
+ if not api_key:
175
+ raise NotLoggedInError("Not logged in. Run 'race login' first.")
176
+ return Client(base_url=config.get_base_url(), api_key=api_key)
177
+
178
+
179
+ def new_idempotency_key() -> str:
180
+ return str(uuid.uuid4())
@@ -0,0 +1,445 @@
1
+ """
2
+ race CLI — thin command layer. Every command: parse args, call
3
+ api_client, print a clean result. No business logic here — that lives
4
+ entirely on the server. This mirrors the exact same view/service split
5
+ used on the backend: commands are the "view" (user-facing surface),
6
+ api_client.Client is the "service" (does the actual work).
7
+ """
8
+
9
+ import sys
10
+ import time
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from . import config
17
+ from .api_client import NotLoggedInError, RaceAPIError, get_client, new_idempotency_key
18
+
19
+ app = typer.Typer(help="Race Engineering GPU cloud CLI")
20
+ console = Console()
21
+
22
+
23
+ def _fail(message: str) -> None:
24
+ """Print an error and exit non-zero — every command's single error path."""
25
+ typer.secho(f"Error: {message}", fg=typer.colors.RED, err=True)
26
+ raise typer.Exit(code=1)
27
+
28
+
29
+ @app.command()
30
+ def login(
31
+ api_key: str = typer.Option(
32
+ None,
33
+ "--key",
34
+ help="Your API key (rk_...). If omitted, you'll be prompted (input hidden).",
35
+ ),
36
+ base_url: str = typer.Option(
37
+ None, "--base-url", help="Override the API base URL (advanced/internal use)."
38
+ ),
39
+ ):
40
+ """
41
+ Store an API key for future commands. Generate one first via the
42
+ web dashboard or `POST /v1/api-keys/` while logged in there — this
43
+ command does NOT create a key, only stores one you already have.
44
+ """
45
+ if base_url:
46
+ config.set_base_url(base_url)
47
+
48
+ if not api_key:
49
+ api_key = typer.prompt("API key", hide_input=True)
50
+
51
+ if not api_key.startswith("rk_"):
52
+ _fail("That doesn't look like a valid API key (should start with 'rk_').")
53
+
54
+ # Validate the key actually works BEFORE storing it — storing a
55
+ # typo'd/invalid key silently would mean every later command fails
56
+ # with a confusing 401, far from where the actual mistake happened.
57
+ config.save_api_key(api_key)
58
+ try:
59
+ client = get_client()
60
+ who = client.whoami()
61
+ except RaceAPIError as e:
62
+ config.delete_api_key() # don't leave a known-bad key stored
63
+ _fail(f"Key validation failed, not saved: {e.message}")
64
+ return
65
+
66
+ typer.secho(
67
+ f"✓ Logged in as {who.get('email') or who.get('user_id', 'unknown')} "
68
+ f"(key: {who.get('key_name', 'unnamed')})",
69
+ fg=typer.colors.GREEN,
70
+ )
71
+
72
+
73
+ @app.command()
74
+ def logout():
75
+ """Remove the locally stored API key. Does NOT revoke the key server-side."""
76
+ config.delete_api_key()
77
+ typer.secho("✓ Logged out (local key removed).", fg=typer.colors.GREEN)
78
+
79
+
80
+ @app.command()
81
+ def deploy(
82
+ slice_id: str = typer.Option(..., "--gpu", help="GPU slice id to deploy on."),
83
+ template_id: str = typer.Option(None, "--template", help="Template id."),
84
+ custom_template_id: str = typer.Option(None, "--custom-template"),
85
+ disk_size: int = typer.Option(50, "--disk", help="Disk size in GB."),
86
+ name: str = typer.Option(None, "--name", help="Optional pod name."),
87
+ ):
88
+ """Deploy a new GPU pod."""
89
+ if not template_id and not custom_template_id:
90
+ _fail("Provide either --template or --custom-template.")
91
+
92
+ try:
93
+ client = get_client()
94
+ except NotLoggedInError as e:
95
+ _fail(str(e))
96
+ return
97
+
98
+ # ONE idempotency key for this whole deploy intent, reused across
99
+ # retries below — not regenerated per attempt. See api_client.py's
100
+ # deploy_pod docstring for why that distinction matters.
101
+ idempotency_key = new_idempotency_key()
102
+
103
+ attempts = 3
104
+ last_error = None
105
+ for attempt in range(attempts):
106
+ try:
107
+ result = client.deploy_pod(
108
+ slice_id=slice_id,
109
+ template_id=template_id,
110
+ custom_template_id=custom_template_id,
111
+ disk_size=disk_size,
112
+ pod_name=name,
113
+ idempotency_key=idempotency_key,
114
+ )
115
+ # StartPodAPIView has two distinct success shapes, confirmed
116
+ # against its real source:
117
+ # async (custom template / spot): has pod_id + deploy_state,
118
+ # container not up yet — poll `race status <pod_id>` next.
119
+ # synchronous (built-in template): has container_id/web_url/
120
+ # ssh_command directly, no pod_id/deploy_state at all —
121
+ # the pod is already up, nothing to poll.
122
+ pod_id = result.get("pod_id")
123
+ if pod_id:
124
+ typer.secho(f"✓ Deploy started — pod_id={pod_id}", fg=typer.colors.GREEN)
125
+ typer.echo(f" status: {result.get('deploy_state', result.get('status'))}")
126
+ typer.echo(f" run 'race status {pod_id}' to check progress")
127
+ else:
128
+ typer.secho("✓ Pod deployed and ready", fg=typer.colors.GREEN)
129
+ if result.get("web_url"):
130
+ typer.echo(f" url: {result['web_url']}")
131
+ if result.get("ssh_command"):
132
+ typer.echo(f" ssh: {result['ssh_command']}")
133
+ return
134
+ except RaceAPIError as e:
135
+ last_error = e
136
+ # Only network-layer failures (status_code 0, from
137
+ # api_client's ConnectionError/Timeout handling) are safe to
138
+ # retry automatically — a real 4xx/5xx from the server is a
139
+ # definitive answer, not a transient blip, and retrying it
140
+ # would just get the same rejection again.
141
+ if e.status_code != 0:
142
+ break
143
+
144
+ _fail(f"Deploy failed: {last_error.message}")
145
+
146
+
147
+ @app.command(name="list")
148
+ def list_pods():
149
+ """List your GPU pods."""
150
+ try:
151
+ client = get_client()
152
+ pods = client.list_pods()
153
+ except NotLoggedInError as e:
154
+ _fail(str(e))
155
+ return
156
+ except RaceAPIError as e:
157
+ _fail(e.message)
158
+ return
159
+
160
+ if not pods:
161
+ typer.echo("No pods found.")
162
+ return
163
+
164
+ table = Table(show_header=True, header_style="bold cyan")
165
+ table.add_column("POD ID", style="dim")
166
+ table.add_column("NAME")
167
+ table.add_column("STATUS")
168
+ table.add_column("GPU")
169
+
170
+ for pod in pods:
171
+ status = pod.get("status", "")
172
+ deploy_progress = pod.get("deploy_progress")
173
+ if deploy_progress:
174
+ pct = deploy_progress.get("progress_percent")
175
+ status = f"{status} ({pct}%)" if pct is not None else status
176
+ table.add_row(str(pod.get("id", "")), pod.get("name", ""), status, pod.get("gpu", ""))
177
+
178
+ console.print(table)
179
+
180
+
181
+ @app.command()
182
+ def gpus():
183
+ """List available GPUs and their slice IDs (use a slice_id with 'race deploy --gpu')."""
184
+ try:
185
+ client = get_client()
186
+ gpu_list = client.list_gpus()
187
+ except NotLoggedInError as e:
188
+ _fail(str(e))
189
+ return
190
+ except RaceAPIError as e:
191
+ _fail(e.message)
192
+ return
193
+
194
+ if not gpu_list:
195
+ typer.echo("No GPUs found.")
196
+ return
197
+
198
+ table = Table(show_header=True, header_style="bold cyan")
199
+ table.add_column("SLICE ID", style="dim")
200
+ table.add_column("GPU")
201
+ table.add_column("VRAM")
202
+ table.add_column("RATE/HR")
203
+ table.add_column("REGION")
204
+
205
+ for gpu in gpu_list:
206
+ name = gpu.get("name", "")
207
+ slices = gpu.get("slices")
208
+ # slices may be a single dict, a list, or absent, depending on
209
+ # auth/context — normalize defensively rather than assume one shape.
210
+ if isinstance(slices, dict):
211
+ slices = [slices]
212
+ for s in slices or []:
213
+ slice_id = s.get("slice_id", s.get("id"))
214
+ table.add_row(
215
+ str(slice_id),
216
+ name,
217
+ f"{s.get('memory', '?')}GB",
218
+ f"₹{s.get('hourly_rate', '?')}",
219
+ s.get("region", ""),
220
+ )
221
+
222
+ if table.row_count == 0:
223
+ typer.echo("No available GPU slices found.")
224
+ return
225
+
226
+ console.print(table)
227
+
228
+
229
+ @app.command()
230
+ def templates():
231
+ """List built-in templates you can deploy (use a template_id with 'race deploy --template')."""
232
+ try:
233
+ client = get_client()
234
+ template_list = client.list_templates()
235
+ except NotLoggedInError as e:
236
+ _fail(str(e))
237
+ return
238
+ except RaceAPIError as e:
239
+ _fail(e.message)
240
+ return
241
+
242
+ # Only built-in templates can currently be deployed via API key/CLI
243
+ # (custom templates are rejected at deploy time with a 400) — showing
244
+ # them here would let a user pick an option that's guaranteed to fail.
245
+ builtin_only = [t for t in template_list if t.get("template_type") == "builtin"]
246
+
247
+ if not builtin_only:
248
+ typer.echo("No built-in templates found.")
249
+ return
250
+
251
+ table = Table(show_header=True, header_style="bold cyan")
252
+ table.add_column("TEMPLATE ID", style="dim")
253
+ table.add_column("NAME")
254
+ table.add_column("CATEGORY")
255
+
256
+ for t in builtin_only:
257
+ table.add_row(
258
+ str(t.get("id", "")),
259
+ t.get("name", ""),
260
+ t.get("category") or "—",
261
+ )
262
+
263
+ console.print(table)
264
+
265
+
266
+ @app.command()
267
+ def status(pod_id: str = typer.Argument(..., help="Pod id.")):
268
+ """Check a pod's deploy/status."""
269
+ try:
270
+ client = get_client()
271
+ result = client.get_pod_status(pod_id)
272
+ except NotLoggedInError as e:
273
+ _fail(str(e))
274
+ return
275
+ except RaceAPIError as e:
276
+ _fail(e.message)
277
+ return
278
+
279
+ deploy_state = result.get("deploy_state") or result.get("status")
280
+ typer.echo(f"status: {deploy_state}")
281
+
282
+ # web_url/ssh_command/container_id only ever appear when the server
283
+ # reports "ready" (two of the eleven real response shapes this
284
+ # endpoint can return) — every other shape omits them entirely, so
285
+ # .get(...) (never bare ["..."]) is required here, not optional.
286
+ if result.get("message"):
287
+ typer.echo(f"message: {result['message']}")
288
+ if result.get("web_url"):
289
+ typer.echo(f"url: {result['web_url']}")
290
+ if result.get("ssh_command"):
291
+ typer.echo(f"ssh: {result['ssh_command']}")
292
+ if result.get("progress_percent") is not None:
293
+ typer.echo(f"progress: {result['progress_percent']}%")
294
+ if result.get("error"):
295
+ typer.echo(f"error: {result['error']}")
296
+
297
+
298
+ @app.command()
299
+ def pause(pod_id: str = typer.Argument(...)):
300
+ """Pause a running pod."""
301
+ try:
302
+ client = get_client()
303
+ client.pause_pod(pod_id)
304
+ except NotLoggedInError as e:
305
+ _fail(str(e))
306
+ return
307
+ except RaceAPIError as e:
308
+ _fail(e.message)
309
+ return
310
+ typer.secho(f"✓ Pod {pod_id} paused.", fg=typer.colors.GREEN)
311
+
312
+
313
+ @app.command()
314
+ def resume(pod_id: str = typer.Argument(...)):
315
+ """Resume a paused pod."""
316
+ try:
317
+ client = get_client()
318
+ result = client.resume_pod(pod_id)
319
+ except NotLoggedInError as e:
320
+ _fail(str(e))
321
+ return
322
+ except RaceAPIError as e:
323
+ _fail(e.message)
324
+ return
325
+ typer.secho(f"✓ Pod {pod_id} resumed.", fg=typer.colors.GREEN)
326
+ if result.get("web_url"):
327
+ typer.echo(f" url: {result['web_url']}")
328
+ if result.get("ssh_command"):
329
+ typer.echo(f" ssh: {result['ssh_command']}")
330
+ resume_state = result.get("resume_state")
331
+ if resume_state and resume_state != "ready":
332
+ typer.echo(f" status: {resume_state} — run 'race status {pod_id}' to check progress")
333
+
334
+
335
+ @app.command()
336
+ def terminate(
337
+ pod_id: str = typer.Argument(...),
338
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
339
+ ):
340
+ """Terminate a pod. Irreversible — asks for confirmation unless --yes."""
341
+ if not yes:
342
+ confirmed = typer.confirm(f"Terminate pod {pod_id}? This cannot be undone.")
343
+ if not confirmed:
344
+ typer.echo("Cancelled.")
345
+ return
346
+
347
+ try:
348
+ client = get_client()
349
+ client.terminate_pod(pod_id)
350
+ except NotLoggedInError as e:
351
+ _fail(str(e))
352
+ return
353
+ except RaceAPIError as e:
354
+ _fail(e.message)
355
+ return
356
+ typer.secho(f"✓ Pod {pod_id} terminated.", fg=typer.colors.GREEN)
357
+
358
+
359
+ @app.command(hidden=True)
360
+ def himanshu():
361
+ """(hidden) You found it."""
362
+ scenes = [
363
+ """
364
+ ┌─ TODO ──────────────────┐
365
+ │ [x] Setup project │
366
+ │ [x] Fix dependency hell │
367
+ │ [ ] Write actual code │
368
+ │ [ ] Make it work │
369
+ │ [ ] Write tests (lol) │
370
+ │ [ ] Deploy │
371
+ └──────────────────────────┘
372
+ "I'll sleep early today."
373
+ Me at 2:37 AM: 👁️ 👁️
374
+ ▽
375
+ """,
376
+ """
377
+ ┌──────────── TERMINAL ────────────┐
378
+ │ $ python app.py │
379
+ │ ❌ Error: unexpected indent │
380
+ │ $ fix_indent.py │
381
+ │ ✅ No errors! │
382
+ │ $ python app.py │
383
+ │ ❌ Error: something else │
384
+ └───────────────────────────────────┘
385
+ 🦆 "Have you tried talking
386
+ to the duck?"
387
+ """,
388
+ """
389
+ ┌──────────── CHATGPT ─────────────┐
390
+ │ 🤖 Here's the fix: │
391
+ │ 1. Update version │
392
+ │ 2. Clear cache │
393
+ │ 3. Restart server │
394
+ │ 4. Pray 🙏 │
395
+ └────────────────────────────────────┘
396
+ 😐 "It works on my
397
+ /|\\ machine."
398
+ / \\
399
+ """,
400
+ """
401
+ ╔═══════════ PRODUCTION ════════════╗
402
+ ║ 🔥 SERVER DOWN 🔥 ║
403
+ ║ HTTP 500 💀 ║
404
+ ╚════════════════════════════════════╝
405
+ git push --force
406
+ Oops.
407
+ git reflog
408
+ """,
409
+ """
410
+ ┌──────── FINAL STATUS ────────┐
411
+ │ Code: ✅ │
412
+ │ Tests: ❌ │
413
+ │ Production: 💀 │
414
+ │ Sleep: ❌ │
415
+ │ Coffee: ☕☕☕ │
416
+ │ Sanity: -404 │
417
+ └────────────────────────────────┘
418
+ STILL ALIVE...
419
+ ( •_•)
420
+ <) )╯
421
+ / \\
422
+ """,
423
+ ]
424
+ for scene in scenes:
425
+ typer.echo(scene.strip("\n"))
426
+ time.sleep(1.1)
427
+
428
+ typer.secho(
429
+ "\n H I M A N S H U",
430
+ fg=typer.colors.BRIGHT_MAGENTA,
431
+ bold=True,
432
+ )
433
+ typer.echo(" Sev-1 incident report: engineer spent 6 hours building auth,")
434
+ typer.echo(" idempotency, and a CLI — then spent 45 minutes demanding")
435
+ typer.echo(" a talking GPU easter egg. Root cause: unlimited creativity,")
436
+ typer.echo(" zero adult supervision. Status: resolved, mostly by accident.")
437
+ typer.echo(" postmortem action item: give this man a raise. 🫡\n")
438
+
439
+
440
+ def main():
441
+ app()
442
+
443
+
444
+ if __name__ == "__main__":
445
+ main()
@@ -0,0 +1,70 @@
1
+ """
2
+ Local config + secure credential storage for the race CLI.
3
+
4
+ Design: the API key is NEVER written to a plaintext dotfile. It's stored
5
+ via the `keyring` package, which delegates to the OS's own credential
6
+ store (macOS Keychain, Windows Credential Manager, Linux Secret Service /
7
+ kwallet) — the same category of protection your browser or SSH agent
8
+ uses for saved credentials. A dotfile like ~/.race/credentials would be
9
+ plaintext, readable by any process running as that user, and a common
10
+ target for credential-stealing malware; the OS keychain is not.
11
+
12
+ The base URL (which server to talk to) is NOT a secret, so it's fine to
13
+ store in a plain config file.
14
+ """
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import keyring
20
+
21
+ _SERVICE_NAME = "race-cli"
22
+ _KEYRING_USERNAME = "api_key" # keyring stores secrets per (service, username)
23
+
24
+ _CONFIG_DIR = Path.home() / ".race"
25
+ _CONFIG_FILE = _CONFIG_DIR / "config.json"
26
+
27
+ DEFAULT_BASE_URL = "https://api.raceengineering.ai"
28
+
29
+
30
+ def get_base_url() -> str:
31
+ if _CONFIG_FILE.exists():
32
+ try:
33
+ data = json.loads(_CONFIG_FILE.read_text())
34
+ return data.get("base_url", DEFAULT_BASE_URL)
35
+ except (json.JSONDecodeError, OSError):
36
+ # Corrupt or unreadable config file — fall back to the
37
+ # default rather than crashing every single CLI invocation
38
+ # over a bad local file the user can just regenerate.
39
+ return DEFAULT_BASE_URL
40
+ return DEFAULT_BASE_URL
41
+
42
+
43
+ def set_base_url(url: str) -> None:
44
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
45
+ data = {}
46
+ if _CONFIG_FILE.exists():
47
+ try:
48
+ data = json.loads(_CONFIG_FILE.read_text())
49
+ except (json.JSONDecodeError, OSError):
50
+ data = {}
51
+ data["base_url"] = url
52
+ _CONFIG_FILE.write_text(json.dumps(data, indent=2))
53
+
54
+
55
+ def save_api_key(raw_key: str) -> None:
56
+ keyring.set_password(_SERVICE_NAME, _KEYRING_USERNAME, raw_key)
57
+
58
+
59
+ def load_api_key() -> str | None:
60
+ return keyring.get_password(_SERVICE_NAME, _KEYRING_USERNAME)
61
+
62
+
63
+ def delete_api_key() -> None:
64
+ try:
65
+ keyring.delete_password(_SERVICE_NAME, _KEYRING_USERNAME)
66
+ except keyring.errors.PasswordDeleteError:
67
+ # Nothing was stored — deleting a non-existent credential is not
68
+ # an error condition from the CLI user's perspective ("logout"
69
+ # when already logged out should just succeed quietly).
70
+ pass
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+