shipml-coldev 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.
@@ -0,0 +1,3 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
@@ -0,0 +1,12 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="PyUnresolvedReferencesInspection" enabled="true" level="WARNING" enabled_by_default="true">
5
+ <option name="ignoredIdentifiers">
6
+ <list>
7
+ <option value="dict.*" />
8
+ </list>
9
+ </option>
10
+ </inspection_tool>
11
+ </profile>
12
+ </component>
@@ -0,0 +1,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.13 (mlx)" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
6
+ </content>
7
+ <orderEntry type="jdk" jdkName="Python 3.13 (mlx)" jdkType="Python SDK" />
8
+ <orderEntry type="sourceFolder" forTests="false" />
9
+ </component>
10
+ </module>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/mlx.iml" filepath="$PROJECT_DIR$/.idea/mlx.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
@@ -0,0 +1,57 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="1731cea3-59fb-4bd8-9ba4-e25d31b4e6e0" name="Changes" comment="" />
8
+ <option name="SHOW_DIALOG" value="false" />
9
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
10
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
11
+ <option name="LAST_RESOLUTION" value="IGNORE" />
12
+ </component>
13
+ <component name="FileTemplateManagerImpl">
14
+ <option name="RECENT_TEMPLATES">
15
+ <list>
16
+ <option value="Python Script" />
17
+ </list>
18
+ </option>
19
+ </component>
20
+ <component name="ProjectColorInfo"><![CDATA[{
21
+ "associatedIndex": 1
22
+ }]]></component>
23
+ <component name="ProjectId" id="3BW1phkgJpNp54kDbesp8rnBNLg" />
24
+ <component name="ProjectViewState">
25
+ <option name="hideEmptyMiddlePackages" value="true" />
26
+ <option name="showLibraryContents" value="true" />
27
+ </component>
28
+ <component name="PropertiesComponent"><![CDATA[{
29
+ "keyToString": {
30
+ "RunOnceActivity.ShowReadmeOnStart": "true"
31
+ }
32
+ }]]></component>
33
+ <component name="RecentsManager">
34
+ <key name="MoveFile.RECENT_KEYS">
35
+ <recent name="C:\Users\arpa1\PycharmProjects\mlx" />
36
+ <recent name="C:\Users\arpa1\PycharmProjects\mlx\mlx_deploy" />
37
+ </key>
38
+ </component>
39
+ <component name="SharedIndexes">
40
+ <attachedChunks>
41
+ <set>
42
+ <option value="bundled-python-sdk-495700d161d3-aa17d162503b-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-243.22562.220" />
43
+ </set>
44
+ </attachedChunks>
45
+ </component>
46
+ <component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
47
+ <component name="TaskManager">
48
+ <task active="true" id="Default" summary="Default task">
49
+ <changelist id="1731cea3-59fb-4bd8-9ba4-e25d31b4e6e0" name="Changes" comment="" />
50
+ <created>1774590197283</created>
51
+ <option name="number" value="Default" />
52
+ <option name="presentableId" value="Default" />
53
+ <updated>1774590197283</updated>
54
+ </task>
55
+ <servers />
56
+ </component>
57
+ </project>
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: shipml-coldev
3
+ Version: 0.1.0
4
+ Summary: Deploy ML models to production with one command
5
+ Project-URL: Homepage, https://github.com/tuusuario/mlx-deploy
6
+ Project-URL: Documentation, https://github.com/tuusuario/mlx-deploy#readme
7
+ Project-URL: Issues, https://github.com/tuusuario/mlx-deploy/issues
8
+ License: MIT
9
+ Keywords: cli,deployment,fastapi,machine-learning,mlops
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: fastapi>=0.111.0
20
+ Requires-Dist: numpy>=1.26.0
21
+ Requires-Dist: pydantic>=2.7.0
22
+ Requires-Dist: rich>=13.0.0
23
+ Requires-Dist: scikit-learn>=1.4.0
24
+ Requires-Dist: typer[all]>=0.12.0
25
+ Requires-Dist: uvicorn[standard]>=0.29.0
File without changes
@@ -0,0 +1,56 @@
1
+ """
2
+ Genera un modelo sklearn de prueba para validar el PoC de MLX.
3
+ Crea dos modelos: clasificador (iris) y regresor (california housing).
4
+
5
+ Uso: python create_test_model.py
6
+ """
7
+
8
+ import pickle
9
+ from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
10
+ from sklearn.datasets import load_iris, load_diabetes
11
+ from sklearn.model_selection import train_test_split
12
+ from sklearn.pipeline import Pipeline
13
+ from sklearn.preprocessing import StandardScaler
14
+
15
+ print("Generando modelos de prueba...\n")
16
+
17
+ # ── Clasificador ──
18
+ iris = load_iris()
19
+ X_train, X_test, y_train, y_test = train_test_split(
20
+ iris.data, iris.target, test_size=0.2, random_state=42
21
+ )
22
+ clf = Pipeline([
23
+ ("scaler", StandardScaler()),
24
+ ("clf", RandomForestClassifier(n_estimators=50, random_state=42)),
25
+ ])
26
+ clf.fit(X_train, y_train)
27
+ score = clf.score(X_test, y_test)
28
+
29
+ with open("model_classifier.pkl", "wb") as f:
30
+ pickle.dump(clf, f)
31
+
32
+ print(f"✅ model_classifier.pkl — RandomForest Iris (accuracy: {score:.3f})")
33
+ print(f" Features: {iris.data.shape[1]} ({', '.join(iris.feature_names)})")
34
+ print(f" Clases: {list(iris.target_names)}\n")
35
+
36
+ # ── Regresor ──
37
+ diabetes = load_diabetes()
38
+ X_train2, X_test2, y_train2, y_test2 = train_test_split(
39
+ diabetes.data, diabetes.target, test_size=0.2, random_state=42
40
+ )
41
+ reg = Pipeline([
42
+ ("scaler", StandardScaler()),
43
+ ("reg", RandomForestRegressor(n_estimators=50, random_state=42)),
44
+ ])
45
+ reg.fit(X_train2, y_train2)
46
+ r2 = reg.score(X_test2, y_test2)
47
+
48
+ with open("model_regressor.pkl", "wb") as f:
49
+ pickle.dump(reg, f)
50
+
51
+ print(f"✅ model_regressor.pkl — RandomForest Diabetes (R²: {r2:.3f})")
52
+ print(f" Features: {diabetes.data.shape[1]}")
53
+ print()
54
+ print("Ahora puedes ejecutar:")
55
+ print(" python mlx_deploy.py --model ./model_classifier.pkl --app-name iris-classifier --dry-run")
56
+ print(" python mlx_deploy.py --model ./model_regressor.pkl --app-name diabetes-regressor --dry-run")
Binary file
Binary file
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shipml-coldev"
7
+ version = "0.1.0"
8
+ description = "Deploy ML models to production with one command"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ keywords = ["machine-learning", "deployment", "mlops", "fastapi", "cli"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Intended Audience :: Science/Research",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+ dependencies = [
24
+ "typer[all]>=0.12.0",
25
+ "rich>=13.0.0",
26
+ "fastapi>=0.111.0",
27
+ "uvicorn[standard]>=0.29.0",
28
+ "scikit-learn>=1.4.0",
29
+ "numpy>=1.26.0",
30
+ "pydantic>=2.7.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ mlx = "shipml.cli:app"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/tuusuario/mlx-deploy"
38
+ Documentation = "https://github.com/tuusuario/mlx-deploy#readme"
39
+ Issues = "https://github.com/tuusuario/mlx-deploy/issues"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["shipml"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,421 @@
1
+ """
2
+ MLX CLI - Vercel para modelos de ML
3
+ ====================================
4
+ Uso:
5
+ python mlx.py deploy ./model.pkl --name iris-demo
6
+ python mlx.py list
7
+ python mlx.py logs iris-demo
8
+ python mlx.py delete iris-demo
9
+ """
10
+
11
+ import json
12
+ import pickle
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ import typer
21
+ from rich.console import Console
22
+ from rich.table import Table
23
+ from rich.progress import Progress, SpinnerColumn, TextColumn
24
+ from rich import print as rprint
25
+
26
+ # ─────────────────────────────────────────────
27
+ # SETUP
28
+ # ─────────────────────────────────────────────
29
+
30
+ app = typer.Typer(
31
+ name="mlx",
32
+ help="MLX — despliega modelos de ML en producción con un comando.",
33
+ add_completion=False,
34
+ rich_markup_mode="rich",
35
+ )
36
+ console = Console()
37
+
38
+ # Base de datos local de deploys (JSON simple por ahora)
39
+ MLX_HOME = Path.home() / ".mlx"
40
+ DEPLOYS_FILE = MLX_HOME / "deploys.json"
41
+
42
+ RAILWAY_CMD = (
43
+ shutil.which("railway")
44
+ or shutil.which("railway.cmd")
45
+ or str(Path.home() / "AppData/Roaming/npm/railway.cmd")
46
+ )
47
+
48
+
49
+ def load_deploys() -> dict:
50
+ MLX_HOME.mkdir(exist_ok=True)
51
+ if not DEPLOYS_FILE.exists():
52
+ return {}
53
+ return json.loads(DEPLOYS_FILE.read_text(encoding="utf-8"))
54
+
55
+
56
+ def save_deploys(deploys: dict) -> None:
57
+ MLX_HOME.mkdir(exist_ok=True)
58
+ DEPLOYS_FILE.write_text(json.dumps(deploys, indent=2), encoding="utf-8")
59
+
60
+
61
+ # ─────────────────────────────────────────────
62
+ # LÓGICA CORE (del PoC, refactorizada)
63
+ # ─────────────────────────────────────────────
64
+
65
+ def _detect_model(model_path: Path) -> dict:
66
+ with open(model_path, "rb") as f:
67
+ model = pickle.load(f)
68
+
69
+ module = type(model).__module__
70
+ if module.startswith("sklearn"):
71
+ framework = "scikit-learn"
72
+ elif module.startswith("xgboost"):
73
+ framework = "xgboost"
74
+ elif module.startswith("lightgbm"):
75
+ framework = "lightgbm"
76
+ else:
77
+ framework = f"unknown ({module})"
78
+
79
+ n_features = None
80
+ for attr in ("n_features_in_", "n_features_", "coef_"):
81
+ if hasattr(model, attr):
82
+ val = getattr(model, attr)
83
+ n_features = val.shape[-1] if attr == "coef_" else int(val)
84
+ break
85
+
86
+ return {
87
+ "framework": framework,
88
+ "model_type": type(model).__name__,
89
+ "n_features": n_features,
90
+ "has_proba": hasattr(model, "predict_proba"),
91
+ }
92
+
93
+
94
+ def _generate_server(meta: dict, output_dir: Path) -> None:
95
+ n_features = meta["n_features"]
96
+ has_proba = meta["has_proba"]
97
+ model_type = meta["model_type"]
98
+
99
+ if n_features:
100
+ fields = "\n".join(
101
+ f' x{i}: float = Field(..., description="Feature {i}")'
102
+ for i in range(n_features)
103
+ )
104
+ input_body = f"\nclass PredictInput(BaseModel):\n{fields}\n\n def to_list(self):\n return [{', '.join(f'self.x{i}' for i in range(n_features))}]\n"
105
+ input_param = "body: PredictInput"
106
+ features_expr = "[body.to_list()]"
107
+ else:
108
+ input_body = "\nclass PredictInput(BaseModel):\n features: list[float] = Field(...)\n"
109
+ input_param = "body: PredictInput"
110
+ features_expr = "[body.features]"
111
+
112
+ proba_endpoint = ""
113
+ if has_proba:
114
+ proba_endpoint = f"\n@app.post('/predict/proba')\ndef predict_proba({input_param}):\n return {{\"probabilities\": MODEL.predict_proba({features_expr}).tolist()}}\n"
115
+
116
+ server_code = f'''"""Auto-generado por MLX - {model_type}"""
117
+ import pickle, os, time
118
+ import numpy as np
119
+ from fastapi import FastAPI, HTTPException
120
+ from fastapi.middleware.cors import CORSMiddleware
121
+ from pydantic import BaseModel, Field
122
+ import uvicorn
123
+
124
+ app = FastAPI(title="MLX - {model_type}", version="1.0.0")
125
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
126
+
127
+ with open("model.pkl", "rb") as f:
128
+ MODEL = pickle.load(f)
129
+
130
+ START_TIME = time.time()
131
+ {input_body}
132
+ @app.get("/health")
133
+ def health():
134
+ return {{"status": "ok", "model": "{model_type}", "uptime_seconds": round(time.time() - START_TIME, 2)}}
135
+
136
+ @app.post("/predict")
137
+ def predict({input_param}):
138
+ try:
139
+ return {{"prediction": MODEL.predict({features_expr}).tolist()}}
140
+ except Exception as e:
141
+ raise HTTPException(status_code=422, detail=str(e))
142
+ {proba_endpoint}
143
+ if __name__ == "__main__":
144
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
145
+ '''
146
+ (output_dir / "server.py").write_text(server_code, encoding="utf-8")
147
+
148
+
149
+ def _generate_dockerfile(output_dir: Path) -> None:
150
+ (output_dir / "Dockerfile").write_text(
151
+ "FROM python:3.11-slim\nWORKDIR /app\nENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\n"
152
+ "COPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n"
153
+ "COPY model.pkl .\nCOPY server.py .\n"
154
+ "CMD uvicorn server:app --host 0.0.0.0 --port $PORT\n",
155
+ encoding="utf-8",
156
+ )
157
+
158
+
159
+ def _generate_requirements(output_dir: Path) -> None:
160
+ (output_dir / "requirements.txt").write_text(
161
+ "fastapi==0.111.0\nuvicorn[standard]==0.29.0\nscikit-learn==1.4.2\nnumpy==1.26.4\npydantic==2.7.1\n",
162
+ encoding="utf-8",
163
+ )
164
+
165
+
166
+ def _generate_railway_config(output_dir: Path) -> None:
167
+ config = {
168
+ "$schema": "https://railway.app/railway.schema.json",
169
+ "build": {"builder": "DOCKERFILE", "dockerfilePath": "Dockerfile"},
170
+ "deploy": {"restartPolicyType": "ON_FAILURE", "restartPolicyMaxRetries": 3},
171
+ }
172
+ (output_dir / "railway.json").write_text(json.dumps(config, indent=2), encoding="utf-8")
173
+
174
+
175
+ def _build_docker(app_name: str, output_dir: Path) -> None:
176
+ result = subprocess.run(
177
+ ["docker", "build", "-t", f"mlx-{app_name}:latest", "."],
178
+ cwd=output_dir,
179
+ capture_output=True,
180
+ text=True,
181
+ encoding="utf-8",
182
+ errors="replace",
183
+ )
184
+ if result.returncode != 0:
185
+ console.print(result.stderr[-1000:], style="red")
186
+ raise typer.Exit(1)
187
+
188
+
189
+ def _deploy_railway(app_name: str, output_dir: Path) -> str:
190
+ subprocess.run(
191
+ [RAILWAY_CMD, "init", "--name", app_name],
192
+ cwd=output_dir,
193
+ text=True,
194
+ )
195
+ result = subprocess.run(
196
+ [RAILWAY_CMD, "up", "--detach"],
197
+ cwd=output_dir,
198
+ text=True,
199
+ )
200
+ if result.returncode != 0:
201
+ raise typer.Exit(1)
202
+
203
+ # Obtener URL
204
+ subprocess.run([RAILWAY_CMD, "domain"], cwd=output_dir, capture_output=True, text=True)
205
+ url_result = subprocess.run(
206
+ [RAILWAY_CMD, "domain"],
207
+ cwd=output_dir,
208
+ capture_output=True,
209
+ text=True,
210
+ encoding="utf-8",
211
+ errors="replace",
212
+ )
213
+ url = url_result.stdout.strip().splitlines()[-1] if url_result.stdout.strip() else ""
214
+ if not url.startswith("http"):
215
+ url = f"https://{url}" if url else f"https://{app_name}.up.railway.app"
216
+ return url
217
+
218
+
219
+ # ─────────────────────────────────────────────
220
+ # COMANDOS
221
+ # ─────────────────────────────────────────────
222
+
223
+ @app.command()
224
+ def deploy(
225
+ model: Path = typer.Argument(..., help="Ruta al modelo .pkl"),
226
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Nombre del deploy"),
227
+ dry_run: bool = typer.Option(False, "--dry-run", help="Genera archivos sin desplegar"),
228
+ ):
229
+ """
230
+ Despliega un modelo de ML en producción.
231
+
232
+ Ejemplo: mlx deploy ./model.pkl --name iris-demo
233
+ """
234
+ # Validaciones
235
+ if not model.exists():
236
+ console.print(f"[red]Error:[/red] Archivo no encontrado: {model}")
237
+ raise typer.Exit(1)
238
+ if model.suffix not in (".pkl", ".pickle"):
239
+ console.print(f"[red]Error:[/red] Formato no soportado: {model.suffix} (usa .pkl)")
240
+ raise typer.Exit(1)
241
+
242
+ app_name = (name or model.stem).lower().replace("_", "-").replace(" ", "-")
243
+
244
+ console.print(f"\n[bold cyan]MLX Deploy[/bold cyan] — {model.name}")
245
+ console.rule()
246
+
247
+ build_dir = Path(tempfile.mkdtemp(prefix="mlx_build_"))
248
+
249
+ try:
250
+ # Detectar
251
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as p:
252
+ task = p.add_task("Detectando modelo...", total=None)
253
+ meta = _detect_model(model)
254
+ p.update(task, description=f"[green]✓[/green] {meta['framework']} — {meta['model_type']} ({meta['n_features']} features)")
255
+ p.stop()
256
+
257
+ shutil.copy(model, build_dir / "model.pkl")
258
+
259
+ # Generar archivos
260
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as p:
261
+ task = p.add_task("Generando servidor FastAPI...", total=None)
262
+ _generate_server(meta, build_dir)
263
+ _generate_requirements(build_dir)
264
+ _generate_dockerfile(build_dir)
265
+ _generate_railway_config(build_dir)
266
+ p.update(task, description="[green]✓[/green] Archivos generados (server.py, Dockerfile, railway.json)")
267
+ p.stop()
268
+
269
+ if dry_run:
270
+ console.print(f"\n[yellow]Dry run completo.[/yellow] Archivos en: {build_dir}")
271
+ for f in sorted(build_dir.iterdir()):
272
+ console.print(f" {f.name:25s} [dim]{f.stat().st_size} bytes[/dim]")
273
+ return
274
+
275
+ # Docker
276
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as p:
277
+ task = p.add_task(f"Construyendo imagen Docker...", total=None)
278
+ _build_docker(app_name, build_dir)
279
+ p.update(task, description=f"[green]✓[/green] Imagen Docker: mlx-{app_name}:latest")
280
+ p.stop()
281
+
282
+ # Railway
283
+ console.print("\n[bold]Desplegando en Railway...[/bold]")
284
+ url = _deploy_railway(app_name, build_dir)
285
+
286
+ # Guardar deploy localmente
287
+ deploys = load_deploys()
288
+ deploys[app_name] = {
289
+ "name": app_name,
290
+ "url": url,
291
+ "model": str(model.resolve()),
292
+ "framework": meta["framework"],
293
+ "model_type": meta["model_type"],
294
+ "n_features": meta["n_features"],
295
+ }
296
+ save_deploys(deploys)
297
+
298
+ # Output final
299
+ console.rule()
300
+ console.print(f"\n[bold green]Deploy exitoso![/bold green]")
301
+ console.print(f" [bold]URL[/bold] : {url}")
302
+ console.print(f" [bold]Health[/bold] : {url}/health")
303
+ console.print(f" [bold]Predict[/bold] : POST {url}/predict")
304
+ console.print(f" [bold]Docs[/bold] : {url}/docs\n")
305
+
306
+ if meta["n_features"]:
307
+ sample = {f"x{i}": 0.0 for i in range(min(meta["n_features"], 4))}
308
+ console.print("[dim]Ejemplo:[/dim]")
309
+ console.print(f'[dim] curl -X POST {url}/predict -H "Content-Type: application/json" -d \'{json.dumps(sample)}\'[/dim]')
310
+
311
+ except Exception as e:
312
+ console.print(f"\n[red]Error:[/red] {e}")
313
+ raise typer.Exit(1)
314
+ finally:
315
+ if not dry_run:
316
+ shutil.rmtree(build_dir, ignore_errors=True)
317
+
318
+
319
+ @app.command(name="list")
320
+ def list_deploys():
321
+ """
322
+ Lista todos los modelos desplegados.
323
+
324
+ Ejemplo: mlx list
325
+ """
326
+ deploys = load_deploys()
327
+
328
+ if not deploys:
329
+ console.print("[yellow]No hay deploys registrados.[/yellow]")
330
+ console.print("Ejecuta [bold]mlx deploy ./model.pkl[/bold] para desplegar tu primer modelo.")
331
+ return
332
+
333
+ table = Table(title="MLX — Modelos en producción", show_lines=True)
334
+ table.add_column("Nombre", style="cyan bold")
335
+ table.add_column("Framework", style="green")
336
+ table.add_column("Tipo")
337
+ table.add_column("Features", justify="right")
338
+ table.add_column("URL", style="blue")
339
+
340
+ for name, d in deploys.items():
341
+ table.add_row(
342
+ d["name"],
343
+ d.get("framework", "—"),
344
+ d.get("model_type", "—"),
345
+ str(d.get("n_features", "—")),
346
+ d.get("url", "—"),
347
+ )
348
+
349
+ console.print(table)
350
+
351
+
352
+ @app.command()
353
+ def logs(
354
+ name: str = typer.Argument(..., help="Nombre del deploy"),
355
+ ):
356
+ """
357
+ Muestra los logs de un modelo desplegado.
358
+
359
+ Ejemplo: mlx logs iris-demo
360
+ """
361
+ deploys = load_deploys()
362
+
363
+ if name not in deploys:
364
+ console.print(f"[red]Error:[/red] Deploy '{name}' no encontrado.")
365
+ console.print("Ejecuta [bold]mlx list[/bold] para ver los deploys disponibles.")
366
+ raise typer.Exit(1)
367
+
368
+ console.print(f"\n[bold cyan]Logs de {name}[/bold cyan]")
369
+ console.rule()
370
+
371
+ subprocess.run([RAILWAY_CMD, "logs", "--tail", "100"], text=True)
372
+
373
+
374
+ @app.command()
375
+ def delete(
376
+ name: str = typer.Argument(..., help="Nombre del deploy a eliminar"),
377
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirmar sin preguntar"),
378
+ ):
379
+ """
380
+ Elimina un modelo desplegado.
381
+
382
+ Ejemplo: mlx delete iris-demo
383
+ """
384
+ deploys = load_deploys()
385
+
386
+ if name not in deploys:
387
+ console.print(f"[red]Error:[/red] Deploy '{name}' no encontrado.")
388
+ console.print("Ejecuta [bold]mlx list[/bold] para ver los deploys disponibles.")
389
+ raise typer.Exit(1)
390
+
391
+ if not yes:
392
+ confirmed = typer.confirm(f"Eliminar '{name}'? Esta accion no se puede deshacer")
393
+ if not confirmed:
394
+ console.print("Cancelado.")
395
+ raise typer.Exit(0)
396
+
397
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as p:
398
+ task = p.add_task(f"Eliminando {name}...", total=None)
399
+ result = subprocess.run(
400
+ [RAILWAY_CMD, "down", "--yes"],
401
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
402
+ )
403
+ if result.returncode != 0:
404
+ p.stop()
405
+ console.print(f"[yellow]Aviso:[/yellow] No se pudo eliminar de Railway. Elimina manualmente en railway.com")
406
+ else:
407
+ p.update(task, description=f"[green]✓[/green] {name} eliminado de Railway")
408
+ p.stop()
409
+
410
+ # Eliminar del registro local
411
+ del deploys[name]
412
+ save_deploys(deploys)
413
+ console.print(f"[green]✓[/green] '{name}' eliminado del registro local.")
414
+
415
+
416
+ # ─────────────────────────────────────────────
417
+ # ENTRY POINT
418
+ # ─────────────────────────────────────────────
419
+
420
+ if __name__ == "__main__":
421
+ app()