pipelab 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.
- pipelab/__init__.py +5 -0
- pipelab/api/__init__.py +3 -0
- pipelab/api/app.py +174 -0
- pipelab/api/routers/__init__.py +3 -0
- pipelab/api/routers/datasets.py +79 -0
- pipelab/api/routers/deployments.py +175 -0
- pipelab/api/routers/experiments.py +162 -0
- pipelab/api/routers/models.py +51 -0
- pipelab/api/routers/monitoring.py +43 -0
- pipelab/api/routers/pipelines.py +379 -0
- pipelab/api/routers/projects.py +103 -0
- pipelab/api/routers/settings.py +24 -0
- pipelab/api/schemas.py +215 -0
- pipelab/cli.py +116 -0
- pipelab/discovery.py +229 -0
- pipelab/entities/__init__.py +50 -0
- pipelab/entities/alert.py +16 -0
- pipelab/entities/data_context.py +33 -0
- pipelab/entities/data_split.py +16 -0
- pipelab/entities/dataset.py +17 -0
- pipelab/entities/dataset_version.py +20 -0
- pipelab/entities/deployment.py +20 -0
- pipelab/entities/discovered_service.py +16 -0
- pipelab/entities/experiment.py +16 -0
- pipelab/entities/metric.py +21 -0
- pipelab/entities/model.py +17 -0
- pipelab/entities/model_registry.py +20 -0
- pipelab/entities/monitoring.py +19 -0
- pipelab/entities/parameter.py +13 -0
- pipelab/entities/pipeline.py +31 -0
- pipelab/entities/pipeline_context.py +35 -0
- pipelab/entities/project.py +14 -0
- pipelab/entities/project_config.py +22 -0
- pipelab/entities/run.py +25 -0
- pipelab/entities/services.py +102 -0
- pipelab/infrastructure/__init__.py +3 -0
- pipelab/infrastructure/mlflow_client.py +33 -0
- pipelab/pipeline.py +175 -0
- pipelab/pipelines/__init__.py +7 -0
- pipelab/pipelines/engine.py +154 -0
- pipelab/plugins/__init__.py +7 -0
- pipelab/plugins/plugin.py +49 -0
- pipelab/services/__init__.py +3 -0
- pipelab/services/interfaces.py +214 -0
- pipelab/services/mlflow_dataset_service.py +143 -0
- pipelab/services/mlflow_deployment_service.py +87 -0
- pipelab/services/mlflow_experiment_service.py +119 -0
- pipelab/services/mlflow_model_registry_service.py +103 -0
- pipelab/services/mlflow_monitoring_service.py +83 -0
- pipelab/services/mlflow_project_service.py +63 -0
- pipelab/static/assets/index-D6fc7pTq.css +1 -0
- pipelab/static/assets/index-DTeahxud.js +134 -0
- pipelab/static/favicon.svg +23 -0
- pipelab/static/icons.svg +27 -0
- pipelab/static/index.html +18 -0
- pipelab-0.1.0.dist-info/METADATA +376 -0
- pipelab-0.1.0.dist-info/RECORD +61 -0
- pipelab-0.1.0.dist-info/WHEEL +5 -0
- pipelab-0.1.0.dist-info/entry_points.txt +2 -0
- pipelab-0.1.0.dist-info/licenses/LICENSE +202 -0
- pipelab-0.1.0.dist-info/top_level.txt +1 -0
pipelab/__init__.py
ADDED
pipelab/api/__init__.py
ADDED
pipelab/api/app.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
|
|
4
|
+
"""FastAPI application factory and service registry."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from starlette.exceptions import HTTPException
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SpaStaticFiles(StaticFiles):
|
|
19
|
+
"""StaticFiles que faz fallback para index.html em rotas do SPA.
|
|
20
|
+
|
|
21
|
+
Paths sem extensão (ex.: /projects, /dashboard) devolvem index.html para
|
|
22
|
+
que o React Router cuide do roteamento no cliente. Assets com extensão
|
|
23
|
+
ausentes continuam retornando 404 apropriado.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def get_response(self, path: str, scope):
|
|
27
|
+
try:
|
|
28
|
+
return await super().get_response(path, scope)
|
|
29
|
+
except HTTPException as exc:
|
|
30
|
+
if exc.status_code != 404:
|
|
31
|
+
raise
|
|
32
|
+
last_segment = path.rsplit("/", 1)[-1]
|
|
33
|
+
if "." in last_segment:
|
|
34
|
+
raise
|
|
35
|
+
return await super().get_response("index.html", scope)
|
|
36
|
+
|
|
37
|
+
from pipelab.infrastructure.mlflow_client import configure as configure_mlflow
|
|
38
|
+
from pipelab.plugins.plugin import PipelabPlugin
|
|
39
|
+
from pipelab.discovery import discover_projects, discover_services, ProjectConfig, DiscoveredService
|
|
40
|
+
|
|
41
|
+
# Service singletons
|
|
42
|
+
from pipelab.services.mlflow_project_service import MlflowProjectService
|
|
43
|
+
from pipelab.services.mlflow_dataset_service import MlflowDatasetService
|
|
44
|
+
from pipelab.services.mlflow_experiment_service import MlflowExperimentService
|
|
45
|
+
from pipelab.services.mlflow_model_registry_service import MlflowModelRegistryService
|
|
46
|
+
from pipelab.services.mlflow_deployment_service import MlflowDeploymentService
|
|
47
|
+
from pipelab.services.mlflow_monitoring_service import MlflowMonitoringService
|
|
48
|
+
|
|
49
|
+
_services: dict[str, Any] = {}
|
|
50
|
+
_plugins: list[PipelabPlugin] = []
|
|
51
|
+
|
|
52
|
+
# Discovered projects and their services
|
|
53
|
+
_projects: list[ProjectConfig] = []
|
|
54
|
+
_discovered_services: dict[str, list[DiscoveredService]] = {} # project_name -> services
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_service(name: str) -> Any:
|
|
58
|
+
return _services[name]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_projects() -> list[ProjectConfig]:
|
|
62
|
+
return _projects
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_discovered_services(project_name: str) -> list[DiscoveredService]:
|
|
66
|
+
return _discovered_services.get(project_name, [])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_project(project_name: str) -> ProjectConfig | None:
|
|
70
|
+
for p in _projects:
|
|
71
|
+
if p.name == project_name:
|
|
72
|
+
return p
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register_plugin(plugin: PipelabPlugin) -> None:
|
|
77
|
+
_plugins.append(plugin)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_workdir() -> str:
|
|
81
|
+
"""Return the active workdir path."""
|
|
82
|
+
return os.environ.get("PIPELAB_WORKDIR", os.getcwd())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_discovery() -> None:
|
|
86
|
+
"""Re-scan the workdir for projects and services."""
|
|
87
|
+
global _projects, _discovered_services
|
|
88
|
+
workdir = get_workdir()
|
|
89
|
+
_projects = discover_projects(workdir)
|
|
90
|
+
_discovered_services = {}
|
|
91
|
+
for project in _projects:
|
|
92
|
+
_discovered_services[project.name] = discover_services(project)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def refresh_discovery() -> None:
|
|
96
|
+
"""Alias for run_discovery (backward compat)."""
|
|
97
|
+
run_discovery()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@asynccontextmanager
|
|
101
|
+
async def lifespan(app: FastAPI):
|
|
102
|
+
# Initialize MLflow — resolve relative tracking URI against workdir
|
|
103
|
+
tracking_uri = os.environ.get("MLFLOW_TRACKING_URI", "mlruns")
|
|
104
|
+
workdir = get_workdir()
|
|
105
|
+
if not tracking_uri.startswith(("/", "http", "sqlite", "file:")):
|
|
106
|
+
# Relative path — resolve against workdir
|
|
107
|
+
tracking_uri = str(Path(workdir) / tracking_uri)
|
|
108
|
+
configure_mlflow(tracking_uri)
|
|
109
|
+
|
|
110
|
+
# Register internal services
|
|
111
|
+
_services["projects"] = MlflowProjectService()
|
|
112
|
+
_services["datasets"] = MlflowDatasetService()
|
|
113
|
+
_services["experiments"] = MlflowExperimentService()
|
|
114
|
+
_services["models"] = MlflowModelRegistryService()
|
|
115
|
+
_services["deployments"] = MlflowDeploymentService()
|
|
116
|
+
_services["monitoring"] = MlflowMonitoringService()
|
|
117
|
+
|
|
118
|
+
# Discover projects and user pipeline services
|
|
119
|
+
refresh_discovery()
|
|
120
|
+
|
|
121
|
+
# Register plugins
|
|
122
|
+
for plugin in _plugins:
|
|
123
|
+
plugin.on_register()
|
|
124
|
+
overrides = plugin.get_services()
|
|
125
|
+
if overrides:
|
|
126
|
+
_services.update(overrides)
|
|
127
|
+
|
|
128
|
+
yield
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def create_app() -> FastAPI:
|
|
132
|
+
app = FastAPI(
|
|
133
|
+
title="PipeLab",
|
|
134
|
+
description="ML Pipeline Management Platform",
|
|
135
|
+
version="0.1.0",
|
|
136
|
+
lifespan=lifespan,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# CORS
|
|
140
|
+
app.add_middleware(
|
|
141
|
+
CORSMiddleware,
|
|
142
|
+
allow_origins=["*"],
|
|
143
|
+
allow_credentials=True,
|
|
144
|
+
allow_methods=["*"],
|
|
145
|
+
allow_headers=["*"],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# API routers
|
|
149
|
+
from pipelab.api.routers import (
|
|
150
|
+
projects, datasets, experiments, models,
|
|
151
|
+
pipelines, deployments, monitoring, settings,
|
|
152
|
+
)
|
|
153
|
+
app.include_router(projects.router, prefix="/api/v1/projects", tags=["Projects"])
|
|
154
|
+
app.include_router(datasets.router, prefix="/api/v1/datasets", tags=["Datasets"])
|
|
155
|
+
app.include_router(experiments.router, prefix="/api/v1/experiments", tags=["Experiments"])
|
|
156
|
+
app.include_router(models.router, prefix="/api/v1/models", tags=["Models"])
|
|
157
|
+
app.include_router(pipelines.router, prefix="/api/v1/pipelines", tags=["Pipelines"])
|
|
158
|
+
app.include_router(deployments.router, prefix="/api/v1/deployments", tags=["Deployments"])
|
|
159
|
+
app.include_router(monitoring.router, prefix="/api/v1/monitoring", tags=["Monitoring"])
|
|
160
|
+
app.include_router(settings.router, prefix="/api/v1/settings", tags=["Settings"])
|
|
161
|
+
|
|
162
|
+
# Plugin routes
|
|
163
|
+
for plugin in _plugins:
|
|
164
|
+
routes = plugin.get_routes()
|
|
165
|
+
if routes:
|
|
166
|
+
for r in routes:
|
|
167
|
+
app.include_router(r, prefix=f"/api/v1/plugins/{plugin.name}")
|
|
168
|
+
|
|
169
|
+
# Serve React static files
|
|
170
|
+
static_dir = Path(__file__).resolve().parent.parent / "static"
|
|
171
|
+
if static_dir.is_dir():
|
|
172
|
+
app.mount("/", SpaStaticFiles(directory=str(static_dir), html=True), name="static")
|
|
173
|
+
|
|
174
|
+
return app
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
|
|
4
|
+
"""Datasets API router — CRUD, versioning, splits."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
|
|
9
|
+
from pipelab.api.app import get_service
|
|
10
|
+
from pipelab.api.schemas import (
|
|
11
|
+
DatasetCreate, DatasetOut,
|
|
12
|
+
DatasetVersionCreate, DatasetVersionOut,
|
|
13
|
+
DataSplitCreate, DataSplitOut,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/", response_model=list[DatasetOut])
|
|
20
|
+
async def list_datasets(project_id: str | None = None):
|
|
21
|
+
svc = get_service("datasets")
|
|
22
|
+
return [DatasetOut(**{k: getattr(d, k) for k in DatasetOut.model_fields})
|
|
23
|
+
for d in svc.list_datasets(project_id)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("/{dataset_id}", response_model=DatasetOut)
|
|
27
|
+
async def get_dataset(dataset_id: str):
|
|
28
|
+
svc = get_service("datasets")
|
|
29
|
+
d = svc.get_dataset(dataset_id)
|
|
30
|
+
if d is None:
|
|
31
|
+
raise HTTPException(404, "Dataset not found")
|
|
32
|
+
return DatasetOut(**{k: getattr(d, k) for k in DatasetOut.model_fields})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.post("/", response_model=DatasetOut, status_code=201)
|
|
36
|
+
async def create_dataset(body: DatasetCreate):
|
|
37
|
+
svc = get_service("datasets")
|
|
38
|
+
d = svc.create_dataset(body.label, body.description, body.project_id)
|
|
39
|
+
return DatasetOut(**{k: getattr(d, k) for k in DatasetOut.model_fields})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.delete("/{dataset_id}")
|
|
43
|
+
async def delete_dataset(dataset_id: str):
|
|
44
|
+
svc = get_service("datasets")
|
|
45
|
+
if not svc.delete_dataset(dataset_id):
|
|
46
|
+
raise HTTPException(404, "Dataset not found")
|
|
47
|
+
return {"ok": True}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- Versions -----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@router.get("/{dataset_id}/versions", response_model=list[DatasetVersionOut])
|
|
53
|
+
async def list_versions(dataset_id: str):
|
|
54
|
+
svc = get_service("datasets")
|
|
55
|
+
return [DatasetVersionOut(**{k: getattr(v, k) for k in DatasetVersionOut.model_fields})
|
|
56
|
+
for v in svc.list_versions(dataset_id)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/{dataset_id}/versions", response_model=DatasetVersionOut, status_code=201)
|
|
60
|
+
async def create_version(dataset_id: str, body: DatasetVersionCreate):
|
|
61
|
+
svc = get_service("datasets")
|
|
62
|
+
v = svc.create_version(dataset_id, body.version, body.uri, body.metadata)
|
|
63
|
+
return DatasetVersionOut(**{k: getattr(v, k) for k in DatasetVersionOut.model_fields})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --- Splits -------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@router.get("/{dataset_id}/splits", response_model=list[DataSplitOut])
|
|
69
|
+
async def list_splits(dataset_id: str):
|
|
70
|
+
svc = get_service("datasets")
|
|
71
|
+
return [DataSplitOut(**{k: getattr(s, k) for k in DataSplitOut.model_fields})
|
|
72
|
+
for s in svc.list_splits(dataset_id)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.post("/{dataset_id}/splits", response_model=DataSplitOut, status_code=201)
|
|
76
|
+
async def create_split(dataset_id: str, body: DataSplitCreate):
|
|
77
|
+
svc = get_service("datasets")
|
|
78
|
+
s = svc.create_split(dataset_id, body.name, body.method, body.params)
|
|
79
|
+
return DataSplitOut(**{k: getattr(s, k) for k in DataSplitOut.model_fields})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
|
|
4
|
+
"""Deployments API router — deploy/undeploy models via discovered DeployService providers."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from pipelab.api.app import get_project, get_discovered_services, refresh_discovery
|
|
14
|
+
from pipelab.api.schemas import DeployRequest, DeploymentOut
|
|
15
|
+
from pipelab.discovery import instantiate_service
|
|
16
|
+
from pipelab.pipeline import PipelineContext
|
|
17
|
+
from pipelab.entities.services import DeployService
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
# In-memory deployment registry (production system would use persistent storage)
|
|
22
|
+
_active_deployments: dict[str, dict] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/", response_model=list[DeploymentOut])
|
|
26
|
+
async def list_deployments():
|
|
27
|
+
"""List all active deployments."""
|
|
28
|
+
return [DeploymentOut(**d) for d in _active_deployments.values()]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/", response_model=DeploymentOut, status_code=201)
|
|
32
|
+
async def deploy_model(body: DeployRequest):
|
|
33
|
+
"""Deploy a model using a discovered DeployService provider."""
|
|
34
|
+
proj = get_project(body.project_name)
|
|
35
|
+
if proj is None:
|
|
36
|
+
raise HTTPException(404, f"Project '{body.project_name}' not found")
|
|
37
|
+
|
|
38
|
+
# Instantiate the selected deploy service
|
|
39
|
+
try:
|
|
40
|
+
svc = instantiate_service(proj, body.deploy_service)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise HTTPException(404, str(exc))
|
|
43
|
+
|
|
44
|
+
if not isinstance(svc, DeployService):
|
|
45
|
+
raise HTTPException(400, f"'{body.deploy_service}' is not a DeployService")
|
|
46
|
+
|
|
47
|
+
# Build context for the deploy service
|
|
48
|
+
ctx = PipelineContext(
|
|
49
|
+
project_name=proj.name,
|
|
50
|
+
project_path=proj.path,
|
|
51
|
+
config={
|
|
52
|
+
"model_name": body.model_name,
|
|
53
|
+
"model_version": body.version,
|
|
54
|
+
"alias": body.alias,
|
|
55
|
+
"port": body.port,
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
result = svc.execute(ctx)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise HTTPException(500, f"Deploy failed: {exc}")
|
|
63
|
+
|
|
64
|
+
key = f"{body.model_name}-{body.version}"
|
|
65
|
+
dep = {
|
|
66
|
+
"model_name": body.model_name,
|
|
67
|
+
"version": body.version,
|
|
68
|
+
"alias": body.alias,
|
|
69
|
+
"deploy_service": body.deploy_service,
|
|
70
|
+
"endpoint_uri": result.get("endpoint_uri") if isinstance(result, dict) else None,
|
|
71
|
+
"status": "active",
|
|
72
|
+
"created_at": time.time(),
|
|
73
|
+
"port": body.port,
|
|
74
|
+
"pid": result.get("pid") if isinstance(result, dict) else None,
|
|
75
|
+
}
|
|
76
|
+
_active_deployments[key] = dep
|
|
77
|
+
|
|
78
|
+
# Record deployment in project YAML
|
|
79
|
+
from pipelab.discovery import load_project_config, save_project_config
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
yaml_path = Path(proj.path) / "pipelab.yaml"
|
|
82
|
+
fresh_proj = load_project_config(yaml_path)
|
|
83
|
+
fresh_proj.deployments.append({
|
|
84
|
+
"model_name": body.model_name,
|
|
85
|
+
"version": body.version,
|
|
86
|
+
"alias": body.alias,
|
|
87
|
+
"deploy_service": body.deploy_service,
|
|
88
|
+
"port": body.port,
|
|
89
|
+
})
|
|
90
|
+
save_project_config(fresh_proj)
|
|
91
|
+
refresh_discovery()
|
|
92
|
+
|
|
93
|
+
return DeploymentOut(**dep)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@router.delete("/{model_name}/{version}")
|
|
97
|
+
async def undeploy_model(model_name: str, version: str, project: str | None = None):
|
|
98
|
+
"""Undeploy a model — stops the serving process."""
|
|
99
|
+
key = f"{model_name}-{version}"
|
|
100
|
+
dep = _active_deployments.get(key)
|
|
101
|
+
if dep is None:
|
|
102
|
+
raise HTTPException(404, "Deployment not found")
|
|
103
|
+
|
|
104
|
+
deploy_service_name = dep.get("deploy_service", "MLflowServeProvider")
|
|
105
|
+
|
|
106
|
+
# Try to call undeploy on the original provider
|
|
107
|
+
if project:
|
|
108
|
+
proj = get_project(project)
|
|
109
|
+
if proj:
|
|
110
|
+
try:
|
|
111
|
+
svc = instantiate_service(proj, deploy_service_name)
|
|
112
|
+
if isinstance(svc, DeployService):
|
|
113
|
+
ctx = PipelineContext(
|
|
114
|
+
project_name=proj.name,
|
|
115
|
+
project_path=proj.path,
|
|
116
|
+
config={
|
|
117
|
+
"model_name": model_name,
|
|
118
|
+
"model_version": version,
|
|
119
|
+
"pid": dep.get("pid"),
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
svc.undeploy(ctx)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass # best-effort cleanup
|
|
125
|
+
else:
|
|
126
|
+
# Fallback: kill process directly if we have a pid
|
|
127
|
+
pid = dep.get("pid")
|
|
128
|
+
if pid:
|
|
129
|
+
import os, signal
|
|
130
|
+
try:
|
|
131
|
+
os.kill(pid, signal.SIGTERM)
|
|
132
|
+
except ProcessLookupError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
dep["status"] = "inactive"
|
|
136
|
+
_active_deployments[key] = dep
|
|
137
|
+
|
|
138
|
+
return {"ok": True, "model_name": model_name, "version": version, "status": "inactive"}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TestEndpointRequest(BaseModel):
|
|
142
|
+
endpoint_uri: str
|
|
143
|
+
payload: dict | list | str
|
|
144
|
+
content_type: str = "application/json"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.post("/test-endpoint")
|
|
148
|
+
async def test_endpoint(body: TestEndpointRequest):
|
|
149
|
+
"""Proxy a test payload to a deployed model endpoint and return the response."""
|
|
150
|
+
import httpx
|
|
151
|
+
|
|
152
|
+
headers = {"Content-Type": body.content_type}
|
|
153
|
+
payload = body.payload if isinstance(body.payload, str) else body.payload
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
157
|
+
import json
|
|
158
|
+
data = json.dumps(payload) if not isinstance(payload, str) else payload
|
|
159
|
+
resp = await client.post(body.endpoint_uri, content=data, headers=headers)
|
|
160
|
+
try:
|
|
161
|
+
resp_body = resp.json()
|
|
162
|
+
except Exception:
|
|
163
|
+
resp_body = resp.text
|
|
164
|
+
return {
|
|
165
|
+
"status_code": resp.status_code,
|
|
166
|
+
"headers": dict(resp.headers),
|
|
167
|
+
"body": resp_body,
|
|
168
|
+
"elapsed_ms": resp.elapsed.total_seconds() * 1000,
|
|
169
|
+
}
|
|
170
|
+
except httpx.ConnectError:
|
|
171
|
+
raise HTTPException(502, f"Cannot connect to {body.endpoint_uri} — is the model server running?")
|
|
172
|
+
except httpx.TimeoutException:
|
|
173
|
+
raise HTTPException(504, f"Request to {body.endpoint_uri} timed out (30s)")
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
raise HTTPException(500, f"Test request failed: {exc}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
|
|
4
|
+
"""Experiments API router — experiments, runs, compare."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pipelab.api.app import get_service
|
|
12
|
+
from pipelab.api.schemas import (
|
|
13
|
+
ExperimentCreate, ExperimentOut,
|
|
14
|
+
RunOut, CompareRequest,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/", response_model=list[ExperimentOut])
|
|
21
|
+
async def list_experiments(project_id: str | None = None):
|
|
22
|
+
svc = get_service("experiments")
|
|
23
|
+
return [ExperimentOut(**{k: getattr(e, k) for k in ExperimentOut.model_fields})
|
|
24
|
+
for e in svc.list_experiments(project_id)]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/{name}", response_model=ExperimentOut)
|
|
28
|
+
async def get_experiment(name: str):
|
|
29
|
+
svc = get_service("experiments")
|
|
30
|
+
e = svc.get_experiment(name)
|
|
31
|
+
if e is None:
|
|
32
|
+
raise HTTPException(404, "Experiment not found")
|
|
33
|
+
return ExperimentOut(**{k: getattr(e, k) for k in ExperimentOut.model_fields})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.post("/", response_model=ExperimentOut, status_code=201)
|
|
37
|
+
async def create_experiment(body: ExperimentCreate):
|
|
38
|
+
svc = get_service("experiments")
|
|
39
|
+
e = svc.create_experiment(body.name, body.project_id, body.description)
|
|
40
|
+
return ExperimentOut(**{k: getattr(e, k) for k in ExperimentOut.model_fields})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.delete("/{name}")
|
|
44
|
+
async def delete_experiment(name: str):
|
|
45
|
+
svc = get_service("experiments")
|
|
46
|
+
if not svc.delete_experiment(name):
|
|
47
|
+
raise HTTPException(404, "Experiment not found")
|
|
48
|
+
return {"ok": True}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- Runs ---------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@router.get("/{name}/runs", response_model=list[RunOut])
|
|
54
|
+
async def list_runs(name: str):
|
|
55
|
+
svc = get_service("experiments")
|
|
56
|
+
return [RunOut(**{k: getattr(r, k) for k in RunOut.model_fields})
|
|
57
|
+
for r in svc.list_runs(name)]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.get("/runs/{run_id}", response_model=RunOut)
|
|
61
|
+
async def get_run(run_id: str):
|
|
62
|
+
svc = get_service("experiments")
|
|
63
|
+
r = svc.get_run(run_id)
|
|
64
|
+
if r is None:
|
|
65
|
+
raise HTTPException(404, "Run not found")
|
|
66
|
+
return RunOut(**{k: getattr(r, k) for k in RunOut.model_fields})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/runs/{run_id}/artifacts")
|
|
70
|
+
async def get_run_artifacts(run_id: str) -> list[dict[str, Any]]:
|
|
71
|
+
svc = get_service("experiments")
|
|
72
|
+
return svc.get_run_artifacts(run_id)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.post("/runs/compare", response_model=list[RunOut])
|
|
76
|
+
async def compare_runs(body: CompareRequest):
|
|
77
|
+
svc = get_service("experiments")
|
|
78
|
+
return [RunOut(**{k: getattr(r, k) for k in RunOut.model_fields})
|
|
79
|
+
for r in svc.compare_runs(body.run_ids)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Benchmark ----------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class BenchmarkRequest(BaseModel):
|
|
85
|
+
experiment_names: list[str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post("/benchmark")
|
|
89
|
+
async def benchmark_models(body: BenchmarkRequest) -> list[dict[str, Any]]:
|
|
90
|
+
"""
|
|
91
|
+
Aggregate runs from the given experiments, group by model name
|
|
92
|
+
(derived from mlflow.runName tag or run name), and compute
|
|
93
|
+
mean / std of every metric across the runs of each model group.
|
|
94
|
+
Returns a list of model summaries for comparison.
|
|
95
|
+
"""
|
|
96
|
+
from statistics import mean, stdev
|
|
97
|
+
|
|
98
|
+
svc = get_service("experiments")
|
|
99
|
+
# Collect all runs across selected experiments
|
|
100
|
+
all_runs = []
|
|
101
|
+
for exp_name in body.experiment_names:
|
|
102
|
+
runs = svc.list_runs(exp_name)
|
|
103
|
+
all_runs.extend(runs)
|
|
104
|
+
|
|
105
|
+
# Group runs by model name (strip k-fold suffixes like _fold_0, _k0, etc.)
|
|
106
|
+
import re
|
|
107
|
+
def _model_group_name(run_name: str) -> str:
|
|
108
|
+
"""Strip trailing fold/k-fold index patterns to group CV runs."""
|
|
109
|
+
# Patterns: _fold_0, _fold0, _k0, _k_0, _cv0, _cv_0, _split_0
|
|
110
|
+
cleaned = re.sub(r'[_\-](fold|k|cv|split)[_\-]?\d+$', '', run_name, flags=re.IGNORECASE)
|
|
111
|
+
return cleaned if cleaned else run_name
|
|
112
|
+
|
|
113
|
+
model_groups: dict[str, list] = {}
|
|
114
|
+
for r in all_runs:
|
|
115
|
+
raw_name = r.name or r.tags.get("mlflow.runName") or r.run_id[:8]
|
|
116
|
+
model_name = _model_group_name(raw_name)
|
|
117
|
+
model_groups.setdefault(model_name, []).append(r)
|
|
118
|
+
|
|
119
|
+
results = []
|
|
120
|
+
for model_name, runs in model_groups.items():
|
|
121
|
+
# Collect all metric keys
|
|
122
|
+
all_metric_keys = set()
|
|
123
|
+
for r in runs:
|
|
124
|
+
all_metric_keys.update(r.metrics.keys())
|
|
125
|
+
|
|
126
|
+
# Compute mean/std for each metric
|
|
127
|
+
metric_summary = {}
|
|
128
|
+
for key in sorted(all_metric_keys):
|
|
129
|
+
values = [r.metrics[key] for r in runs if key in r.metrics]
|
|
130
|
+
if values:
|
|
131
|
+
m = mean(values)
|
|
132
|
+
s = stdev(values) if len(values) > 1 else 0.0
|
|
133
|
+
metric_summary[key] = {
|
|
134
|
+
"mean": round(m, 6),
|
|
135
|
+
"std": round(s, 6),
|
|
136
|
+
"values": [round(v, 6) for v in values],
|
|
137
|
+
"count": len(values),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
# Parameters (take from first run as representative)
|
|
141
|
+
first_run = runs[0]
|
|
142
|
+
params = dict(first_run.parameters)
|
|
143
|
+
|
|
144
|
+
# Collect run IDs
|
|
145
|
+
run_ids = [r.run_id for r in runs]
|
|
146
|
+
|
|
147
|
+
# Get experiment name from first run
|
|
148
|
+
experiment_name = first_run.experiment_name
|
|
149
|
+
|
|
150
|
+
results.append({
|
|
151
|
+
"model_name": model_name,
|
|
152
|
+
"experiment_name": experiment_name,
|
|
153
|
+
"run_count": len(runs),
|
|
154
|
+
"run_ids": run_ids,
|
|
155
|
+
"metrics": metric_summary,
|
|
156
|
+
"parameters": params,
|
|
157
|
+
"tags": dict(first_run.tags),
|
|
158
|
+
"model_uri": first_run.model_uri,
|
|
159
|
+
"status": first_run.status,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
return results
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2026 Tarlis Portela <tarlis@tarlis.com.br>
|
|
2
|
+
# Licensed under the Apache License, Version 2.0.
|
|
3
|
+
|
|
4
|
+
"""Models API router — registry, versions, stage transitions."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pipelab.api.app import get_service
|
|
11
|
+
from pipelab.api.schemas import (
|
|
12
|
+
ModelRegisterRequest, ModelTransitionRequest,
|
|
13
|
+
ModelRegistryOut,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/", response_model=list[dict[str, Any]])
|
|
20
|
+
async def list_models():
|
|
21
|
+
svc = get_service("models")
|
|
22
|
+
return svc.list_registered_models()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/{model_name}/versions", response_model=list[ModelRegistryOut])
|
|
26
|
+
async def get_model_versions(model_name: str):
|
|
27
|
+
svc = get_service("models")
|
|
28
|
+
return [ModelRegistryOut(**{k: getattr(v, k) for k in ModelRegistryOut.model_fields})
|
|
29
|
+
for v in svc.get_model_versions(model_name)]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/register", response_model=ModelRegistryOut, status_code=201)
|
|
33
|
+
async def register_model(body: ModelRegisterRequest):
|
|
34
|
+
svc = get_service("models")
|
|
35
|
+
v = svc.register_model(body.run_id, body.model_name, body.artifact_path)
|
|
36
|
+
return ModelRegistryOut(**{k: getattr(v, k) for k in ModelRegistryOut.model_fields})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.put("/{model_name}/versions/{version}/transition", response_model=ModelRegistryOut)
|
|
40
|
+
async def transition_stage(model_name: str, version: str, body: ModelTransitionRequest):
|
|
41
|
+
svc = get_service("models")
|
|
42
|
+
v = svc.transition_model_stage(model_name, version, body.stage)
|
|
43
|
+
return ModelRegistryOut(**{k: getattr(v, k) for k in ModelRegistryOut.model_fields})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.delete("/{model_name}")
|
|
47
|
+
async def delete_model(model_name: str):
|
|
48
|
+
svc = get_service("models")
|
|
49
|
+
if not svc.delete_model(model_name):
|
|
50
|
+
raise HTTPException(404, "Model not found")
|
|
51
|
+
return {"ok": True}
|