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 +21 -0
- race_ai-0.1.0/LICENSE:Zone.Identifier +0 -0
- race_ai-0.1.0/PKG-INFO +100 -0
- race_ai-0.1.0/README.md +73 -0
- race_ai-0.1.0/pyproject.toml +36 -0
- race_ai-0.1.0/race_ai.egg-info/PKG-INFO +100 -0
- race_ai-0.1.0/race_ai.egg-info/SOURCES.txt +15 -0
- race_ai-0.1.0/race_ai.egg-info/dependency_links.txt +1 -0
- race_ai-0.1.0/race_ai.egg-info/entry_points.txt +2 -0
- race_ai-0.1.0/race_ai.egg-info/requires.txt +4 -0
- race_ai-0.1.0/race_ai.egg-info/top_level.txt +1 -0
- race_ai-0.1.0/race_cli/__init__.py +0 -0
- race_ai-0.1.0/race_cli/__main__.py +4 -0
- race_ai-0.1.0/race_cli/api_client.py +180 -0
- race_ai-0.1.0/race_cli/cli.py +445 -0
- race_ai-0.1.0/race_cli/config.py +70 -0
- race_ai-0.1.0/setup.cfg +4 -0
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).
|
race_ai-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
race_cli
|
|
File without changes
|
|
@@ -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
|
race_ai-0.1.0/setup.cfg
ADDED