doushi-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doushi/__init__.py +4 -0
- doushi/client.py +255 -0
- doushi/commands/__init__.py +1 -0
- doushi/commands/auth.py +166 -0
- doushi/commands/demo.py +79 -0
- doushi/commands/export.py +76 -0
- doushi/commands/logs.py +85 -0
- doushi/commands/predict.py +226 -0
- doushi/commands/projects.py +125 -0
- doushi/commands/train.py +198 -0
- doushi/config.py +111 -0
- doushi/main.py +79 -0
- doushi/templates.py +109 -0
- doushi/ui.py +145 -0
- doushi_cli-0.1.0.dist-info/METADATA +236 -0
- doushi_cli-0.1.0.dist-info/RECORD +20 -0
- doushi_cli-0.1.0.dist-info/WHEEL +5 -0
- doushi_cli-0.1.0.dist-info/entry_points.txt +3 -0
- doushi_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- doushi_cli-0.1.0.dist-info/top_level.txt +1 -0
doushi/__init__.py
ADDED
doushi/client.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""HTTP API Client for interacting with Doushi.ai backend."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
import httpx
|
|
8
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn
|
|
9
|
+
|
|
10
|
+
from doushi.config import get_api_key, get_api_url
|
|
11
|
+
from doushi.ui import print_error_panel, print_tier_limit_error
|
|
12
|
+
|
|
13
|
+
# Platform tier dataset upload limits (Bytes, Label, MB)
|
|
14
|
+
TIER_LIMITS: Dict[str, Tuple[int, str, float]] = {
|
|
15
|
+
"free": (25 * 1024 * 1024, "25 MB", 25.0),
|
|
16
|
+
"starter": (150 * 1024 * 1024, "150 MB", 150.0),
|
|
17
|
+
"pro": (2 * 1024 * 1024 * 1024, "2 GB", 2048.0),
|
|
18
|
+
"growth": (5 * 1024 * 1024 * 1024, "5 GB", 5120.0),
|
|
19
|
+
"team": (5 * 1024 * 1024 * 1024, "5 GB", 5120.0),
|
|
20
|
+
"scale": (5 * 1024 * 1024 * 1024, "5 GB", 5120.0),
|
|
21
|
+
"enterprise": (20 * 1024 * 1024 * 1024, "20 GB", 20480.0),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DoushiAPIError(Exception):
|
|
26
|
+
"""Custom exception for Doushi API errors with status and details."""
|
|
27
|
+
def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict[str, Any]] = None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.details = details or {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ALLOWED_DATASET_EXTENSIONS = {".csv", ".parquet", ".xlsx", ".xls", ".json", ".tsv"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DoushiClient:
|
|
38
|
+
"""Synchronous HTTP client for Doushi backend."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60.0):
|
|
41
|
+
self.api_key = api_key or get_api_key()
|
|
42
|
+
self.base_url = (base_url or get_api_url()).rstrip("/")
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
|
|
45
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
46
|
+
headers = {
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
"User-Agent": "doushi-cli/0.1.0",
|
|
49
|
+
}
|
|
50
|
+
if self.api_key:
|
|
51
|
+
headers["x-api-key"] = self.api_key
|
|
52
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
53
|
+
return headers
|
|
54
|
+
|
|
55
|
+
def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
|
|
56
|
+
"""Validate response status and return parsed JSON."""
|
|
57
|
+
try:
|
|
58
|
+
data = response.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
data = {"detail": response.text}
|
|
61
|
+
|
|
62
|
+
if response.is_success:
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
detail = data.get("detail") if isinstance(data, dict) else str(data)
|
|
66
|
+
if not detail:
|
|
67
|
+
detail = response.reason_phrase
|
|
68
|
+
|
|
69
|
+
if response.status_code == 401:
|
|
70
|
+
raise DoushiAPIError(
|
|
71
|
+
message="Authentication failed. Please configure a valid API key with `doushi configure` or `doushi login`.",
|
|
72
|
+
status_code=401,
|
|
73
|
+
details=data
|
|
74
|
+
)
|
|
75
|
+
elif response.status_code == 403:
|
|
76
|
+
raise DoushiAPIError(
|
|
77
|
+
message=f"Access denied: {detail}",
|
|
78
|
+
status_code=403,
|
|
79
|
+
details=data
|
|
80
|
+
)
|
|
81
|
+
elif response.status_code == 413:
|
|
82
|
+
raise DoushiAPIError(
|
|
83
|
+
message=f"Payload limit exceeded: {detail}",
|
|
84
|
+
status_code=413,
|
|
85
|
+
details=data
|
|
86
|
+
)
|
|
87
|
+
elif response.status_code == 404:
|
|
88
|
+
raise DoushiAPIError(
|
|
89
|
+
message=f"Resource not found: {detail}",
|
|
90
|
+
status_code=404,
|
|
91
|
+
details=data
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
raise DoushiAPIError(
|
|
95
|
+
message=f"API Error ({response.status_code}): {detail}",
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
details=data
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def check_auth_or_exit(self) -> str:
|
|
101
|
+
"""Ensure API key exists, or display helpful error and exit."""
|
|
102
|
+
if not self.api_key:
|
|
103
|
+
print_error_panel(
|
|
104
|
+
title="Authentication Required",
|
|
105
|
+
message="No Doushi API Key found. You must configure your credentials before running this command.",
|
|
106
|
+
remedy="Run `doushi configure` or `doushi login` to set up your API Key,\nor export DOUSHI_API_KEY='dsh_live_...'"
|
|
107
|
+
)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
return self.api_key
|
|
110
|
+
|
|
111
|
+
def get_whoami(self) -> Dict[str, Any]:
|
|
112
|
+
"""Fetch current user and organization details."""
|
|
113
|
+
self.check_auth_or_exit()
|
|
114
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
115
|
+
resp = client.get(f"{self.base_url}/api/users/sync", headers=self._get_headers())
|
|
116
|
+
return self._handle_response(resp)
|
|
117
|
+
|
|
118
|
+
def list_projects(self) -> List[Dict[str, Any]]:
|
|
119
|
+
"""List all projects for current organization."""
|
|
120
|
+
self.check_auth_or_exit()
|
|
121
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
122
|
+
resp = client.get(f"{self.base_url}/api/projects", headers=self._get_headers())
|
|
123
|
+
data = self._handle_response(resp)
|
|
124
|
+
if isinstance(data, list):
|
|
125
|
+
return data
|
|
126
|
+
return data.get("projects", [])
|
|
127
|
+
|
|
128
|
+
def get_project(self, project_id: str) -> Dict[str, Any]:
|
|
129
|
+
"""Fetch details of a single project."""
|
|
130
|
+
self.check_auth_or_exit()
|
|
131
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
132
|
+
resp = client.get(f"{self.base_url}/api/projects/{project_id}", headers=self._get_headers())
|
|
133
|
+
return self._handle_response(resp)
|
|
134
|
+
|
|
135
|
+
def create_project(self, name: str, provider: str = "gemini", api_key_override: Optional[str] = None) -> Dict[str, Any]:
|
|
136
|
+
"""Create a new project record in database."""
|
|
137
|
+
self.check_auth_or_exit()
|
|
138
|
+
payload = {
|
|
139
|
+
"name": name,
|
|
140
|
+
"llm_provider": provider,
|
|
141
|
+
"api_key": api_key_override or "default"
|
|
142
|
+
}
|
|
143
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
144
|
+
resp = client.post(f"{self.base_url}/api/projects", json=payload, headers=self._get_headers())
|
|
145
|
+
return self._handle_response(resp)
|
|
146
|
+
|
|
147
|
+
def delete_project(self, project_id: str) -> Dict[str, Any]:
|
|
148
|
+
"""Delete a project."""
|
|
149
|
+
self.check_auth_or_exit()
|
|
150
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
151
|
+
resp = client.delete(f"{self.base_url}/api/projects/{project_id}", headers=self._get_headers())
|
|
152
|
+
return self._handle_response(resp)
|
|
153
|
+
|
|
154
|
+
def check_file_limits(self, file_path: Path, tier: str = "free") -> None:
|
|
155
|
+
"""Pre-flight check on dataset size and format against active tier limit."""
|
|
156
|
+
if not file_path.exists():
|
|
157
|
+
raise FileNotFoundError(f"Dataset file '{file_path}' does not exist.")
|
|
158
|
+
|
|
159
|
+
file_size_bytes = file_path.stat().st_size
|
|
160
|
+
if file_size_bytes == 0:
|
|
161
|
+
raise ValueError(f"Dataset file '{file_path.name}' is empty (0 bytes).")
|
|
162
|
+
|
|
163
|
+
ext = file_path.suffix.lower()
|
|
164
|
+
if ext not in ALLOWED_DATASET_EXTENSIONS:
|
|
165
|
+
allowed_list = ", ".join(sorted(ALLOWED_DATASET_EXTENSIONS))
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"Unsupported file format '{ext}'. Supported dataset formats: {allowed_list}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
normalized_tier = tier.lower()
|
|
171
|
+
max_bytes, limit_label, max_mb = TIER_LIMITS.get(normalized_tier, TIER_LIMITS["free"])
|
|
172
|
+
|
|
173
|
+
file_size_mb = file_size_bytes / (1024 * 1024)
|
|
174
|
+
if file_size_bytes > max_bytes:
|
|
175
|
+
print_tier_limit_error(file_path.name, file_size_mb, normalized_tier, max_mb)
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
def request_upload_url(self, project_id: str, filename: str, file_size: int) -> Dict[str, Any]:
|
|
179
|
+
"""Request S3 presigned upload URL from backend."""
|
|
180
|
+
self.check_auth_or_exit()
|
|
181
|
+
payload = {
|
|
182
|
+
"project_id": project_id,
|
|
183
|
+
"filename": filename,
|
|
184
|
+
"file_size": file_size
|
|
185
|
+
}
|
|
186
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
187
|
+
resp = client.post(f"{self.base_url}/api/upload-url", json=payload, headers=self._get_headers())
|
|
188
|
+
return self._handle_response(resp)
|
|
189
|
+
|
|
190
|
+
def upload_file_to_s3(self, presigned_url: str, file_path: Path) -> None:
|
|
191
|
+
"""Upload dataset directly to S3 with live transfer progress bar."""
|
|
192
|
+
file_size = file_path.stat().st_size
|
|
193
|
+
with Progress(
|
|
194
|
+
SpinnerColumn(),
|
|
195
|
+
TextColumn("[bold cyan]Uploading dataset[/bold cyan] {task.fields[filename]}"),
|
|
196
|
+
BarColumn(),
|
|
197
|
+
DownloadColumn(),
|
|
198
|
+
TransferSpeedColumn(),
|
|
199
|
+
TimeRemainingColumn(),
|
|
200
|
+
) as progress:
|
|
201
|
+
task_id = progress.add_task("upload", filename=file_path.name, total=file_size)
|
|
202
|
+
|
|
203
|
+
with open(file_path, "rb") as f:
|
|
204
|
+
content = f.read()
|
|
205
|
+
|
|
206
|
+
headers = {"Content-Type": "application/octet-stream"}
|
|
207
|
+
with httpx.Client(timeout=120.0) as client:
|
|
208
|
+
resp = client.put(presigned_url, content=content, headers=headers)
|
|
209
|
+
if not resp.is_success:
|
|
210
|
+
raise DoushiAPIError(
|
|
211
|
+
f"Failed to upload dataset to storage: {resp.status_code} {resp.text}",
|
|
212
|
+
status_code=resp.status_code
|
|
213
|
+
)
|
|
214
|
+
progress.update(task_id, completed=file_size)
|
|
215
|
+
|
|
216
|
+
def start_pipeline(self, project_id: str, prompt: str) -> Dict[str, Any]:
|
|
217
|
+
"""Dispatch autonomous agent training pipeline."""
|
|
218
|
+
self.check_auth_or_exit()
|
|
219
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
220
|
+
resp = client.post(
|
|
221
|
+
f"{self.base_url}/api/predict-goal",
|
|
222
|
+
data={"project_id": project_id, "prompt": prompt},
|
|
223
|
+
headers=self._get_headers()
|
|
224
|
+
)
|
|
225
|
+
return self._handle_response(resp)
|
|
226
|
+
|
|
227
|
+
def predict(self, project_id: str, data: Any) -> Dict[str, Any]:
|
|
228
|
+
"""Send inference request to project model."""
|
|
229
|
+
self.check_auth_or_exit()
|
|
230
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
231
|
+
if isinstance(data, list):
|
|
232
|
+
# Batch prediction
|
|
233
|
+
resp = client.post(
|
|
234
|
+
f"{self.base_url}/api/projects/{project_id}/predict-batch",
|
|
235
|
+
json={"data": data},
|
|
236
|
+
headers=self._get_headers()
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
resp = client.post(
|
|
240
|
+
f"{self.base_url}/api/projects/{project_id}/predict",
|
|
241
|
+
json={"data": data},
|
|
242
|
+
headers=self._get_headers()
|
|
243
|
+
)
|
|
244
|
+
return self._handle_response(resp)
|
|
245
|
+
|
|
246
|
+
def chat(self, project_id: str, message: str) -> Dict[str, Any]:
|
|
247
|
+
"""Send chat message to project agent."""
|
|
248
|
+
self.check_auth_or_exit()
|
|
249
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
250
|
+
resp = client.post(
|
|
251
|
+
f"{self.base_url}/api/projects/{project_id}/chat",
|
|
252
|
+
json={"message": message},
|
|
253
|
+
headers=self._get_headers()
|
|
254
|
+
)
|
|
255
|
+
return self._handle_response(resp)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Doushi CLI Subcommands."""
|
doushi/commands/auth.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Authentication commands for Doushi CLI (configure, login, whoami, logout)."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import webbrowser
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import typer
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from doushi.config import (
|
|
12
|
+
DEFAULT_DASHBOARD_URL,
|
|
13
|
+
delete_credentials,
|
|
14
|
+
get_api_key,
|
|
15
|
+
load_credentials,
|
|
16
|
+
save_credentials,
|
|
17
|
+
)
|
|
18
|
+
from doushi.client import DoushiClient, DoushiAPIError
|
|
19
|
+
from doushi.ui import (
|
|
20
|
+
console,
|
|
21
|
+
err_console,
|
|
22
|
+
print_banner,
|
|
23
|
+
print_error_panel,
|
|
24
|
+
print_info,
|
|
25
|
+
print_json,
|
|
26
|
+
print_success,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
auth_app = typer.Typer(help="Manage authentication and API credentials.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@auth_app.command(name="configure")
|
|
33
|
+
@auth_app.command(name="login")
|
|
34
|
+
def configure(
|
|
35
|
+
key: Optional[str] = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
"--key",
|
|
38
|
+
"-k",
|
|
39
|
+
help="Doushi API Key (dsh_live_...)",
|
|
40
|
+
),
|
|
41
|
+
no_browser: bool = typer.Option(
|
|
42
|
+
False,
|
|
43
|
+
"--no-browser",
|
|
44
|
+
help="Do not automatically open the browser to the API keys page.",
|
|
45
|
+
),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Configure your Doushi API Key to authenticate the CLI."""
|
|
48
|
+
print_banner()
|
|
49
|
+
console.print("\n[bold white]Authenticate with Doushi.ai[/bold white]\n")
|
|
50
|
+
|
|
51
|
+
api_keys_url = f"{DEFAULT_DASHBOARD_URL}/settings/api-keys"
|
|
52
|
+
|
|
53
|
+
if not key:
|
|
54
|
+
if not no_browser:
|
|
55
|
+
console.print(f"👉 Opening your browser to generate or copy an API Key:\n [bold cyan underline]{api_keys_url}[/bold cyan underline]\n")
|
|
56
|
+
try:
|
|
57
|
+
webbrowser.open(api_keys_url)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
else:
|
|
61
|
+
console.print(f"👉 Visit your dashboard to generate or copy an API Key:\n [bold cyan underline]{api_keys_url}[/bold cyan underline]\n")
|
|
62
|
+
|
|
63
|
+
key = Prompt.ask(
|
|
64
|
+
"[bold green]🔑 Paste your Doushi API Key[/bold green] (starts with 'dsh_live_')",
|
|
65
|
+
password=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
key = key.strip()
|
|
69
|
+
if not key:
|
|
70
|
+
print_error_panel(
|
|
71
|
+
title="Invalid Key",
|
|
72
|
+
message="No API Key provided. Authentication aborted."
|
|
73
|
+
)
|
|
74
|
+
raise typer.Exit(code=1)
|
|
75
|
+
|
|
76
|
+
# Validate against backend API
|
|
77
|
+
with console.status("[bold cyan]Verifying API Key with Doushi.ai...[/bold cyan]"):
|
|
78
|
+
client = DoushiClient(api_key=key)
|
|
79
|
+
try:
|
|
80
|
+
user_data = client.get_whoami()
|
|
81
|
+
except DoushiAPIError as e:
|
|
82
|
+
print_error_panel(
|
|
83
|
+
title="Authentication Failed",
|
|
84
|
+
message=f"The provided API Key could not be verified: {e.message}",
|
|
85
|
+
remedy="Double check the key in your dashboard at https://doushi.ai/settings/api-keys"
|
|
86
|
+
)
|
|
87
|
+
raise typer.Exit(code=1)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
print_error_panel(
|
|
90
|
+
title="Connection Error",
|
|
91
|
+
message=f"Could not reach Doushi backend: {str(e)}",
|
|
92
|
+
remedy="Check your internet connection or backend status."
|
|
93
|
+
)
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
user_email = user_data.get("email") or user_data.get("id", "Unknown User")
|
|
97
|
+
org = user_data.get("organization") or {}
|
|
98
|
+
org_name = org.get("name", "Personal Workspace")
|
|
99
|
+
tier = str(org.get("tier", "free")).capitalize()
|
|
100
|
+
|
|
101
|
+
save_credentials(
|
|
102
|
+
api_key=key,
|
|
103
|
+
user_email=user_email,
|
|
104
|
+
org_name=org_name,
|
|
105
|
+
tier=tier
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
console.print("\n")
|
|
109
|
+
print_success("Successfully authenticated with Doushi.ai!")
|
|
110
|
+
|
|
111
|
+
table = Table(show_header=False, border_style="green", box=None)
|
|
112
|
+
table.add_row("[bold white]User Account:[/bold white]", f"[cyan]{user_email}[/cyan]")
|
|
113
|
+
table.add_row("[bold white]Organization:[/bold white]", f"[white]{org_name}[/white]")
|
|
114
|
+
table.add_row("[bold white]Active Plan:[/bold white]", f"[bold yellow]{tier}[/bold yellow]")
|
|
115
|
+
table.add_row("[bold white]Credentials Saved:[/bold white]", "[dim]~/.doushi/credentials (mode 0600)[/dim]")
|
|
116
|
+
|
|
117
|
+
console.print(Panel(table, title="[bold green]✦ Authentication Active ✦[/bold green]", border_style="green"))
|
|
118
|
+
console.print("\n[dim]Ready! Run `doushi train <dataset.csv>` or `doushi demo` to start building models.[/dim]\n")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@auth_app.command(name="whoami")
|
|
122
|
+
def whoami(
|
|
123
|
+
json_output: bool = typer.Option(False, "--json", help="Output information as JSON"),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""View active authenticated user, organization, and tier quota."""
|
|
126
|
+
client = DoushiClient()
|
|
127
|
+
client.check_auth_or_exit()
|
|
128
|
+
|
|
129
|
+
with console.status("[bold cyan]Fetching account details...[/bold cyan]"):
|
|
130
|
+
try:
|
|
131
|
+
data = client.get_whoami()
|
|
132
|
+
except DoushiAPIError as e:
|
|
133
|
+
print_error_panel("Account Verification Error", e.message, status_code=e.status_code)
|
|
134
|
+
raise typer.Exit(code=1)
|
|
135
|
+
|
|
136
|
+
if json_output:
|
|
137
|
+
print_json(data)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
user_email = data.get("email") or data.get("id", "Unknown")
|
|
141
|
+
org = data.get("organization") or {}
|
|
142
|
+
org_name = org.get("name", "Personal")
|
|
143
|
+
tier = str(org.get("tier", "free")).capitalize()
|
|
144
|
+
|
|
145
|
+
projects_count = data.get("projects_count", "-")
|
|
146
|
+
max_projects = data.get("max_projects", 3 if tier.lower() == "free" else 15 if tier.lower() == "starter" else 100)
|
|
147
|
+
|
|
148
|
+
table = Table(title="Doushi.ai Account Information", border_style="cyan")
|
|
149
|
+
table.add_column("Property", style="bold white")
|
|
150
|
+
table.add_column("Value", style="cyan")
|
|
151
|
+
|
|
152
|
+
table.add_row("User Email", user_email)
|
|
153
|
+
table.add_row("User ID", str(data.get("id", "-")))
|
|
154
|
+
table.add_row("Organization", org_name)
|
|
155
|
+
table.add_row("Plan Tier", f"[bold yellow]{tier}[/bold yellow]")
|
|
156
|
+
table.add_row("Projects", f"{projects_count} / {max_projects}")
|
|
157
|
+
table.add_row("API Key Preview", f"...{client.api_key[-8:]}" if client.api_key else "None")
|
|
158
|
+
|
|
159
|
+
console.print(table)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@auth_app.command(name="logout")
|
|
163
|
+
def logout() -> None:
|
|
164
|
+
"""Log out and remove local credentials."""
|
|
165
|
+
delete_credentials()
|
|
166
|
+
print_success("Logged out. Local credentials removed from ~/.doushi/credentials.")
|
doushi/commands/demo.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Demo command providing sample datasets for instant onboarding."""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import typer
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
|
|
9
|
+
from doushi.commands.train import train
|
|
10
|
+
from doushi.ui import console, print_banner, print_info
|
|
11
|
+
|
|
12
|
+
demo_app = typer.Typer(help="Spin up instant demo models with bundled datasets.")
|
|
13
|
+
|
|
14
|
+
SAMPLE_CHURN_CSV = """customer_id,credit_score,country,gender,age,tenure,balance,num_of_products,has_cr_card,is_active_member,estimated_salary,churn
|
|
15
|
+
15634602,619,France,Female,42,2,0.0,1,1,1,101348.88,1
|
|
16
|
+
15647311,608,Spain,Female,41,1,83807.86,1,0,1,112542.58,0
|
|
17
|
+
15619304,502,France,Female,42,8,159660.8,3,1,0,113931.57,1
|
|
18
|
+
15701354,699,France,Female,39,1,0.0,2,0,0,93826.63,0
|
|
19
|
+
15737888,850,Spain,Female,43,2,125510.82,1,1,1,79084.1,0
|
|
20
|
+
15574012,645,Spain,Male,44,8,113755.78,2,1,0,149756.71,1
|
|
21
|
+
15592531,822,France,Male,50,7,0.0,2,1,1,10062.8,0
|
|
22
|
+
15656148,376,Germany,Female,29,4,115046.74,4,1,0,119346.88,1
|
|
23
|
+
15792365,501,France,Male,44,4,142051.07,2,0,1,74940.5,0
|
|
24
|
+
15592389,684,France,Male,27,2,134603.88,1,1,1,71725.73,0
|
|
25
|
+
15767821,528,France,Male,31,6,102016.72,2,0,0,80181.12,0
|
|
26
|
+
15737173,497,Spain,Male,24,3,0.0,2,1,0,76390.01,0
|
|
27
|
+
15632264,476,France,Female,34,10,0.0,2,1,0,26260.98,0
|
|
28
|
+
15691483,549,France,Female,25,5,0.0,0,0,0,190857.79,0
|
|
29
|
+
15600882,635,Spain,Female,35,7,0.0,2,1,1,65951.65,0
|
|
30
|
+
15643966,616,Germany,Male,45,3,143129.41,2,0,1,6432.82,0
|
|
31
|
+
15738191,653,Germany,Male,58,1,132602.88,1,1,0,5097.67,1
|
|
32
|
+
15788295,549,Spain,Female,24,9,0.0,2,1,1,14408.85,0
|
|
33
|
+
15661507,587,Spain,Male,45,6,0.0,1,0,0,158684.81,0
|
|
34
|
+
15594720,678,France,Female,60,10,0.0,2,0,1,180749.43,0
|
|
35
|
+
15577657,732,France,Male,41,8,0.0,2,1,1,170886.17,0
|
|
36
|
+
15597945,636,Spain,Female,32,8,0.0,2,1,0,138555.46,0
|
|
37
|
+
15699309,510,Spain,Female,38,4,0.0,1,1,0,118913.53,1
|
|
38
|
+
15579769,669,France,Male,46,3,0.0,2,0,1,8487.75,0
|
|
39
|
+
15625047,846,France,Female,38,5,0.0,1,1,1,187616.16,0
|
|
40
|
+
15738198,577,France,Female,25,3,0.0,2,0,1,124508.29,0
|
|
41
|
+
15736816,756,Germany,Male,36,2,136815.6,1,1,0,170041.95,0
|
|
42
|
+
15700772,570,France,Female,44,9,0.0,1,1,1,40410.42,0
|
|
43
|
+
15728693,574,Germany,Female,43,3,141349.43,1,0,1,100187.43,0
|
|
44
|
+
15733883,411,France,Male,29,0,59697.17,2,1,1,53483.21,0
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@demo_app.command(name="demo")
|
|
49
|
+
def run_demo(
|
|
50
|
+
save_local: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--save-local",
|
|
53
|
+
help="Save demo CSV to current working directory as 'sample_customer_churn.csv'",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Run an instant autonomous ML training demo using a sample customer churn dataset."""
|
|
57
|
+
print_banner()
|
|
58
|
+
console.print("\n[bold white]🚀 Doushi Instant 30-Second Demo[/bold white]\n")
|
|
59
|
+
print_info("Creating temporary sample dataset: Customer Churn (30 records, 12 features)...")
|
|
60
|
+
|
|
61
|
+
if save_local:
|
|
62
|
+
demo_file = Path("./sample_customer_churn.csv")
|
|
63
|
+
else:
|
|
64
|
+
temp_dir = tempfile.mkdtemp()
|
|
65
|
+
demo_file = Path(temp_dir) / "sample_customer_churn.csv"
|
|
66
|
+
|
|
67
|
+
with open(demo_file, "w", encoding="utf-8") as f:
|
|
68
|
+
f.write(SAMPLE_CHURN_CSV.strip())
|
|
69
|
+
|
|
70
|
+
console.print(f"[dim]Dataset written to {demo_file}[/dim]\n")
|
|
71
|
+
|
|
72
|
+
# Delegate to train command
|
|
73
|
+
train(
|
|
74
|
+
dataset_file=demo_file,
|
|
75
|
+
goal="Predict if a banking customer will churn based on credit score, age, and balance",
|
|
76
|
+
name="Demo Customer Churn Predictor",
|
|
77
|
+
follow=True,
|
|
78
|
+
json_output=False,
|
|
79
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Export command for Doushi CLI (export)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from doushi.client import DoushiClient, DoushiAPIError
|
|
8
|
+
from doushi.templates import (
|
|
9
|
+
DOCKERFILE_TEMPLATE,
|
|
10
|
+
FASTAPI_SERVE_TEMPLATE,
|
|
11
|
+
REQUIREMENTS_TXT_TEMPLATE,
|
|
12
|
+
generate_export_readme,
|
|
13
|
+
)
|
|
14
|
+
from doushi.ui import (
|
|
15
|
+
console,
|
|
16
|
+
print_error_panel,
|
|
17
|
+
print_success,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export_app = typer.Typer(help="Export model artifacts and standalone Python serving code.")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@export_app.command(name="export")
|
|
24
|
+
def export_model(
|
|
25
|
+
project_id: str = typer.Argument(..., help="Unique ID of the project to export"),
|
|
26
|
+
out_dir: Path = typer.Option(
|
|
27
|
+
Path("./doushi-model-bundle"),
|
|
28
|
+
"--out",
|
|
29
|
+
"-o",
|
|
30
|
+
help="Directory where export artifacts will be saved",
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Download project code, metadata, Dockerfile, and FastAPI microservice."""
|
|
34
|
+
client = DoushiClient()
|
|
35
|
+
client.check_auth_or_exit()
|
|
36
|
+
|
|
37
|
+
with console.status(f"[bold cyan]Exporting project {project_id}...[/bold cyan]"):
|
|
38
|
+
try:
|
|
39
|
+
p = client.get_project(project_id)
|
|
40
|
+
except DoushiAPIError as e:
|
|
41
|
+
print_error_panel("Export Failed", e.message, status_code=e.status_code)
|
|
42
|
+
raise typer.Exit(code=1)
|
|
43
|
+
|
|
44
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
project_name = p.get("name", "Doushi Project")
|
|
46
|
+
|
|
47
|
+
# 1. Metadata and training pipeline
|
|
48
|
+
with open(out_dir / "metadata.json", "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(p, f, indent=2, default=str)
|
|
50
|
+
|
|
51
|
+
code_content = p.get("generated_code") or p.get("code") or "# Training pipeline code generated by Doushi\n"
|
|
52
|
+
with open(out_dir / "pipeline.py", "w", encoding="utf-8") as f:
|
|
53
|
+
f.write(code_content)
|
|
54
|
+
|
|
55
|
+
# 2. Serving microservice & deployment files
|
|
56
|
+
with open(out_dir / "serve.py", "w", encoding="utf-8") as f:
|
|
57
|
+
f.write(FASTAPI_SERVE_TEMPLATE)
|
|
58
|
+
|
|
59
|
+
with open(out_dir / "Dockerfile", "w", encoding="utf-8") as f:
|
|
60
|
+
f.write(DOCKERFILE_TEMPLATE)
|
|
61
|
+
|
|
62
|
+
with open(out_dir / "requirements.txt", "w", encoding="utf-8") as f:
|
|
63
|
+
f.write(REQUIREMENTS_TXT_TEMPLATE)
|
|
64
|
+
|
|
65
|
+
# 3. Local deployment README
|
|
66
|
+
with open(out_dir / "README.md", "w", encoding="utf-8") as f:
|
|
67
|
+
f.write(generate_export_readme(project_id=project_id, project_name=project_name))
|
|
68
|
+
|
|
69
|
+
console.print("\n")
|
|
70
|
+
print_success(f"Project exported successfully to [bold cyan]{out_dir}[/bold cyan]!")
|
|
71
|
+
console.print("\n[dim]Files created:[/dim]")
|
|
72
|
+
console.print(f" ├── {out_dir}/pipeline.py")
|
|
73
|
+
console.print(f" ├── {out_dir}/serve.py")
|
|
74
|
+
console.print(f" ├── {out_dir}/Dockerfile")
|
|
75
|
+
console.print(f" ├── {out_dir}/requirements.txt")
|
|
76
|
+
console.print(f" └── {out_dir}/metadata.json\n")
|
doushi/commands/logs.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Log streaming and inspection commands for Doushi CLI (logs)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import typer
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
|
|
8
|
+
from doushi.client import DoushiClient, DoushiAPIError
|
|
9
|
+
from doushi.ui import (
|
|
10
|
+
console,
|
|
11
|
+
err_console,
|
|
12
|
+
print_error_panel,
|
|
13
|
+
print_info,
|
|
14
|
+
print_json,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logs_app = typer.Typer(help="Stream and inspect agent execution and self-healing logs.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@logs_app.command(name="logs")
|
|
21
|
+
def logs(
|
|
22
|
+
project_id: str = typer.Argument(..., help="Unique ID of the project"),
|
|
23
|
+
follow: bool = typer.Option(
|
|
24
|
+
False,
|
|
25
|
+
"--follow",
|
|
26
|
+
"-f",
|
|
27
|
+
help="Continuously stream new logs until execution finishes",
|
|
28
|
+
),
|
|
29
|
+
raw: bool = typer.Option(
|
|
30
|
+
False,
|
|
31
|
+
"--raw",
|
|
32
|
+
help="Print raw unformatted logs",
|
|
33
|
+
),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""View training agent logs, sandbox execution, and self-healing tracebacks."""
|
|
36
|
+
client = DoushiClient()
|
|
37
|
+
client.check_auth_or_exit()
|
|
38
|
+
|
|
39
|
+
last_logs = ""
|
|
40
|
+
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
p = client.get_project(project_id)
|
|
44
|
+
except DoushiAPIError as e:
|
|
45
|
+
print_error_panel("Failed to Fetch Logs", e.message, status_code=e.status_code)
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
|
|
48
|
+
project_logs = p.get("logs") or ""
|
|
49
|
+
error = p.get("error")
|
|
50
|
+
status = str(p.get("status", "pending")).lower()
|
|
51
|
+
|
|
52
|
+
if project_logs and project_logs != last_logs:
|
|
53
|
+
# Print only new log lines if following
|
|
54
|
+
if last_logs and project_logs.startswith(last_logs):
|
|
55
|
+
new_part = project_logs[len(last_logs):]
|
|
56
|
+
else:
|
|
57
|
+
new_part = project_logs
|
|
58
|
+
|
|
59
|
+
if raw:
|
|
60
|
+
print(new_part, end="")
|
|
61
|
+
else:
|
|
62
|
+
for line in new_part.splitlines():
|
|
63
|
+
if "ERROR" in line or "Traceback" in line or "Exception" in line:
|
|
64
|
+
console.print(f"[bold red]{line}[/bold red]")
|
|
65
|
+
elif "WARNING" in line:
|
|
66
|
+
console.print(f"[bold yellow]{line}[/bold yellow]")
|
|
67
|
+
elif "INFO" in line or "Success" in line:
|
|
68
|
+
console.print(f"[bold green]{line}[/bold green]")
|
|
69
|
+
else:
|
|
70
|
+
console.print(f"[dim]{line}[/dim]")
|
|
71
|
+
|
|
72
|
+
last_logs = project_logs
|
|
73
|
+
|
|
74
|
+
if not follow:
|
|
75
|
+
if not project_logs:
|
|
76
|
+
console.print(f"[dim]No logs available yet for project {project_id}. Status: {status}[/dim]")
|
|
77
|
+
if error:
|
|
78
|
+
print_error_panel("Project Error", str(error))
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
if status in ["success", "failed"]:
|
|
82
|
+
console.print(f"\n[bold]Execution finished with status: [/bold]{status.upper()}")
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
time.sleep(3)
|