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.
- shipml_coldev-0.1.0/.idea/.gitignore +3 -0
- shipml_coldev-0.1.0/.idea/inspectionProfiles/Project_Default.xml +12 -0
- shipml_coldev-0.1.0/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- shipml_coldev-0.1.0/.idea/misc.xml +6 -0
- shipml_coldev-0.1.0/.idea/mlx.iml +10 -0
- shipml_coldev-0.1.0/.idea/modules.xml +8 -0
- shipml_coldev-0.1.0/.idea/workspace.xml +57 -0
- shipml_coldev-0.1.0/PKG-INFO +25 -0
- shipml_coldev-0.1.0/README.md +0 -0
- shipml_coldev-0.1.0/create_test_model.py +56 -0
- shipml_coldev-0.1.0/model_classifier.pkl +0 -0
- shipml_coldev-0.1.0/model_regressor.pkl +0 -0
- shipml_coldev-0.1.0/pyproject.toml +42 -0
- shipml_coldev-0.1.0/shipml/__init__.py +1 -0
- shipml_coldev-0.1.0/shipml/cli.py +421 -0
|
@@ -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,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,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()
|