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 ADDED
@@ -0,0 +1,4 @@
1
+ """Doushi CLI - Instant Autonomous ML Modeling from your Terminal."""
2
+
3
+ __version__ = "1.0.0"
4
+ __app_name__ = "doushi"
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."""
@@ -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.")
@@ -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")
@@ -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)