matyan-backend 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.
- matyan_backend-0.1.0/PKG-INFO +25 -0
- matyan_backend-0.1.0/README.md +0 -0
- matyan_backend-0.1.0/pyproject.toml +82 -0
- matyan_backend-0.1.0/setup.cfg +4 -0
- matyan_backend-0.1.0/src/matyan_backend/__init__.py +0 -0
- matyan_backend-0.1.0/src/matyan_backend/api/__init__.py +3 -0
- matyan_backend-0.1.0/src/matyan_backend/api/_main.py +18 -0
- matyan_backend-0.1.0/src/matyan_backend/api/dashboard_apps/__init__.py +0 -0
- matyan_backend-0.1.0/src/matyan_backend/api/dashboard_apps/pydantic_models.py +24 -0
- matyan_backend-0.1.0/src/matyan_backend/api/dashboard_apps/views.py +71 -0
- matyan_backend-0.1.0/src/matyan_backend/api/dashboards/__init__.py +0 -0
- matyan_backend-0.1.0/src/matyan_backend/api/dashboards/pydantic_models.py +24 -0
- matyan_backend-0.1.0/src/matyan_backend/api/dashboards/views.py +79 -0
- matyan_backend-0.1.0/src/matyan_backend/api/experiments/__init__.py +3 -0
- matyan_backend-0.1.0/src/matyan_backend/api/experiments/_main.py +242 -0
- matyan_backend-0.1.0/src/matyan_backend/api/experiments/_pydantic_models.py +52 -0
- matyan_backend-0.1.0/src/matyan_backend/api/projects/__init__.py +3 -0
- matyan_backend-0.1.0/src/matyan_backend/api/projects/_main.py +65 -0
- matyan_backend-0.1.0/src/matyan_backend/api/projects/_pydantic_models.py +43 -0
- matyan_backend-0.1.0/src/matyan_backend/api/reports/__init__.py +0 -0
- matyan_backend-0.1.0/src/matyan_backend/api/reports/pydantic_models.py +27 -0
- matyan_backend-0.1.0/src/matyan_backend/api/reports/views.py +69 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/__init__.py +3 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_blob_uri.py +37 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_collections.py +115 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_custom_objects.py +566 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_planner.py +149 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_pydantic_models.py +242 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_query.py +179 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_range_utils.py +23 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_run.py +496 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_streaming.py +395 -0
- matyan_backend-0.1.0/src/matyan_backend/api/runs/_views.py +304 -0
- matyan_backend-0.1.0/src/matyan_backend/api/streaming.py +189 -0
- matyan_backend-0.1.0/src/matyan_backend/api/tags/__init__.py +3 -0
- matyan_backend-0.1.0/src/matyan_backend/api/tags/_main.py +135 -0
- matyan_backend-0.1.0/src/matyan_backend/api/tags/_pydantic_models.py +47 -0
- matyan_backend-0.1.0/src/matyan_backend/app.py +51 -0
- matyan_backend-0.1.0/src/matyan_backend/cli.py +79 -0
- matyan_backend-0.1.0/src/matyan_backend/config.py +38 -0
- matyan_backend-0.1.0/src/matyan_backend/deps.py +39 -0
- matyan_backend-0.1.0/src/matyan_backend/fdb_types.py +120 -0
- matyan_backend-0.1.0/src/matyan_backend/kafka/__init__.py +3 -0
- matyan_backend-0.1.0/src/matyan_backend/kafka/producer.py +74 -0
- matyan_backend-0.1.0/src/matyan_backend/py.typed +0 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/__init__.py +25 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/context_utils.py +17 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/encoding.py +33 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/entities.py +544 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/fdb_client.py +60 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/indexes.py +311 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/project.py +174 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/runs.py +321 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/s3_client.py +49 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/sequences.py +246 -0
- matyan_backend-0.1.0/src/matyan_backend/storage/tree.py +154 -0
- matyan_backend-0.1.0/src/matyan_backend/workers/__init__.py +0 -0
- matyan_backend-0.1.0/src/matyan_backend/workers/control.py +173 -0
- matyan_backend-0.1.0/src/matyan_backend/workers/ingestion.py +372 -0
- matyan_backend-0.1.0/src/matyan_backend.egg-info/PKG-INFO +25 -0
- matyan_backend-0.1.0/src/matyan_backend.egg-info/SOURCES.txt +68 -0
- matyan_backend-0.1.0/src/matyan_backend.egg-info/dependency_links.txt +1 -0
- matyan_backend-0.1.0/src/matyan_backend.egg-info/entry_points.txt +2 -0
- matyan_backend-0.1.0/src/matyan_backend.egg-info/requires.txt +18 -0
- matyan_backend-0.1.0/src/matyan_backend.egg-info/top_level.txt +1 -0
- matyan_backend-0.1.0/tests/test_app.py +42 -0
- matyan_backend-0.1.0/tests/test_cli.py +138 -0
- matyan_backend-0.1.0/tests/test_infra.py +77 -0
- matyan_backend-0.1.0/tests/test_kafka_producer.py +43 -0
- matyan_backend-0.1.0/tests/test_worker_lifecycle.py +331 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matyan-backend
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Production-ready experiment tracker
|
|
5
|
+
Author-email: Tigran Grigoryan <grigoryan.tigran119@gmail.com>
|
|
6
|
+
Requires-Python: <4,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi~=0.115.12
|
|
9
|
+
Requires-Dist: uvicorn~=0.34.2
|
|
10
|
+
Requires-Dist: pydantic~=2.0
|
|
11
|
+
Requires-Dist: pydantic-settings~=2.0
|
|
12
|
+
Requires-Dist: loguru~=0.7.3
|
|
13
|
+
Requires-Dist: websockets~=15.0
|
|
14
|
+
Requires-Dist: RestrictedPython~=8.0
|
|
15
|
+
Requires-Dist: numpy~=2.0
|
|
16
|
+
Requires-Dist: tqdm~=4.0
|
|
17
|
+
Requires-Dist: msgpack~=1.0
|
|
18
|
+
Requires-Dist: frozendict~=2.4
|
|
19
|
+
Requires-Dist: foundationdb==7.3.69
|
|
20
|
+
Requires-Dist: boto3~=1.35
|
|
21
|
+
Requires-Dist: cryptography~=44.0
|
|
22
|
+
Requires-Dist: aiokafka~=0.13
|
|
23
|
+
Requires-Dist: click~=8.0
|
|
24
|
+
Requires-Dist: stream-zip~=0.0.83
|
|
25
|
+
Requires-Dist: matyan-api-models~=0.1.0
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "matyan-backend"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Production-ready experiment tracker"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Tigran Grigoryan", email = "grigoryan.tigran119@gmail.com" },
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.12, <4"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi~=0.115.12",
|
|
16
|
+
"uvicorn~=0.34.2",
|
|
17
|
+
"pydantic~=2.0",
|
|
18
|
+
"pydantic-settings~=2.0",
|
|
19
|
+
"loguru~=0.7.3",
|
|
20
|
+
"websockets~=15.0",
|
|
21
|
+
"RestrictedPython~=8.0",
|
|
22
|
+
"numpy~=2.0",
|
|
23
|
+
"tqdm~=4.0",
|
|
24
|
+
"msgpack~=1.0",
|
|
25
|
+
"frozendict~=2.4",
|
|
26
|
+
"foundationdb==7.3.69",
|
|
27
|
+
"boto3~=1.35",
|
|
28
|
+
"cryptography~=44.0",
|
|
29
|
+
"aiokafka~=0.13",
|
|
30
|
+
"click~=8.0",
|
|
31
|
+
"stream-zip~=0.0.83",
|
|
32
|
+
"matyan-api-models~=0.1.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
matyan-backend = "matyan_backend.cli:main"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 120
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
exclude = ["*.ipynb"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["ALL"]
|
|
46
|
+
ignore = [
|
|
47
|
+
"FBT001",
|
|
48
|
+
"FBT002",
|
|
49
|
+
"D100",
|
|
50
|
+
"D101",
|
|
51
|
+
"D102",
|
|
52
|
+
"D103",
|
|
53
|
+
"D104",
|
|
54
|
+
"D105",
|
|
55
|
+
"D106",
|
|
56
|
+
"D107",
|
|
57
|
+
"D203",
|
|
58
|
+
"D213",
|
|
59
|
+
"D417",
|
|
60
|
+
"FBT003",
|
|
61
|
+
"EXE002",
|
|
62
|
+
"S311",
|
|
63
|
+
"TD003",
|
|
64
|
+
"D205",
|
|
65
|
+
"PLR2004",
|
|
66
|
+
"PLR0913",
|
|
67
|
+
"S101",
|
|
68
|
+
"S104"
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.uv.sources]
|
|
72
|
+
matyan-api-models = { path = "../matyan-api-models", editable = true }
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
[dependency-groups]
|
|
76
|
+
dev = [
|
|
77
|
+
"pytest~=8.0",
|
|
78
|
+
"httpx~=0.28",
|
|
79
|
+
"matyan-api-models",
|
|
80
|
+
"types-boto3[s3]",
|
|
81
|
+
"pytest-cov>=7.0.0",
|
|
82
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from .dashboard_apps.views import dashboard_apps_router
|
|
4
|
+
from .dashboards.views import dashboards_router
|
|
5
|
+
from .experiments import rest_router_experiments
|
|
6
|
+
from .projects import rest_router_projects
|
|
7
|
+
from .reports.views import reports_router
|
|
8
|
+
from .runs import rest_router_runs
|
|
9
|
+
from .tags import tags_router
|
|
10
|
+
|
|
11
|
+
main_router = APIRouter(prefix="/rest")
|
|
12
|
+
main_router.include_router(rest_router_runs)
|
|
13
|
+
main_router.include_router(tags_router)
|
|
14
|
+
main_router.include_router(rest_router_experiments)
|
|
15
|
+
main_router.include_router(rest_router_projects)
|
|
16
|
+
main_router.include_router(dashboards_router, prefix="/dashboards", tags=["dashboards"])
|
|
17
|
+
main_router.include_router(dashboard_apps_router, prefix="/apps", tags=["apps"])
|
|
18
|
+
main_router.include_router(reports_router, prefix="/reports", tags=["reports"])
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExploreStateCreateIn(BaseModel):
|
|
7
|
+
type: str
|
|
8
|
+
state: dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExploreStateUpdateIn(BaseModel):
|
|
12
|
+
type: str | None = None
|
|
13
|
+
state: dict | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExploreStateGetOut(BaseModel):
|
|
17
|
+
id: str
|
|
18
|
+
type: str
|
|
19
|
+
updated_at: float | None = None
|
|
20
|
+
created_at: float | None = None
|
|
21
|
+
state: dict
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ExploreStateListOut = list[ExploreStateGetOut]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
|
|
5
|
+
from matyan_backend.deps import FdbDb # noqa: TC001
|
|
6
|
+
from matyan_backend.storage import entities
|
|
7
|
+
|
|
8
|
+
from .pydantic_models import (
|
|
9
|
+
ExploreStateCreateIn,
|
|
10
|
+
ExploreStateGetOut,
|
|
11
|
+
ExploreStateListOut,
|
|
12
|
+
ExploreStateUpdateIn,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
dashboard_apps_router = APIRouter()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _app_to_out(a: dict) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"id": a["id"],
|
|
21
|
+
"type": a.get("type", ""),
|
|
22
|
+
"updated_at": a.get("updated_at"),
|
|
23
|
+
"created_at": a.get("created_at"),
|
|
24
|
+
"state": a.get("state", {}),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dashboard_apps_router.get("/", response_model=ExploreStateListOut)
|
|
29
|
+
async def get_apps_api(db: FdbDb) -> list[dict]:
|
|
30
|
+
apps = entities.list_dashboard_apps(db)
|
|
31
|
+
return [_app_to_out(a) for a in apps]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dashboard_apps_router.post("/", response_model=ExploreStateGetOut, status_code=201)
|
|
35
|
+
async def create_app_api(body: ExploreStateCreateIn, db: FdbDb) -> dict:
|
|
36
|
+
a = entities.create_dashboard_app(db, body.type, state=body.state)
|
|
37
|
+
return _app_to_out(a)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dashboard_apps_router.get("/{app_id}/", response_model=ExploreStateGetOut)
|
|
41
|
+
async def get_app_api(app_id: str, db: FdbDb) -> dict:
|
|
42
|
+
a = entities.get_dashboard_app(db, app_id)
|
|
43
|
+
if not a:
|
|
44
|
+
raise HTTPException(status_code=404)
|
|
45
|
+
return _app_to_out(a)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dashboard_apps_router.put("/{app_id}/", response_model=ExploreStateGetOut)
|
|
49
|
+
async def update_app_api(app_id: str, body: ExploreStateUpdateIn, db: FdbDb) -> dict:
|
|
50
|
+
a = entities.get_dashboard_app(db, app_id)
|
|
51
|
+
if not a:
|
|
52
|
+
raise HTTPException(status_code=404)
|
|
53
|
+
updates = {}
|
|
54
|
+
if body.type is not None:
|
|
55
|
+
updates["type"] = body.type
|
|
56
|
+
if body.state is not None:
|
|
57
|
+
updates["state"] = body.state
|
|
58
|
+
if updates:
|
|
59
|
+
entities.update_dashboard_app(db, app_id, **updates)
|
|
60
|
+
updated = entities.get_dashboard_app(db, app_id)
|
|
61
|
+
if not updated:
|
|
62
|
+
raise HTTPException(status_code=404)
|
|
63
|
+
return _app_to_out(updated)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dashboard_apps_router.delete("/{app_id}/", status_code=204, response_model=None)
|
|
67
|
+
async def delete_app_api(app_id: str, db: FdbDb) -> None:
|
|
68
|
+
a = entities.get_dashboard_app(db, app_id)
|
|
69
|
+
if not a:
|
|
70
|
+
raise HTTPException(status_code=404)
|
|
71
|
+
entities.delete_dashboard_app(db, app_id)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DashboardOut(BaseModel):
|
|
7
|
+
id: str
|
|
8
|
+
name: str
|
|
9
|
+
description: str | None = None
|
|
10
|
+
app_id: str | None = None
|
|
11
|
+
app_type: str | None = None
|
|
12
|
+
updated_at: float | None = None
|
|
13
|
+
created_at: float | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DashboardUpdateIn(BaseModel):
|
|
17
|
+
name: str | None = None
|
|
18
|
+
description: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DashboardCreateIn(BaseModel):
|
|
22
|
+
name: str
|
|
23
|
+
description: str | None = None
|
|
24
|
+
app_id: str | None = None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
|
|
5
|
+
from matyan_backend.deps import FdbDb # noqa: TC001
|
|
6
|
+
from matyan_backend.storage import entities
|
|
7
|
+
|
|
8
|
+
from .pydantic_models import DashboardCreateIn, DashboardOut, DashboardUpdateIn
|
|
9
|
+
|
|
10
|
+
dashboards_router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _dash_to_out(d: dict, db: object | None = None) -> dict:
|
|
14
|
+
app_type: str | None = None
|
|
15
|
+
app_id = d.get("app_id")
|
|
16
|
+
if app_id and db is not None:
|
|
17
|
+
app = entities.get_dashboard_app(db, app_id)
|
|
18
|
+
if app:
|
|
19
|
+
app_type = app.get("type")
|
|
20
|
+
return {
|
|
21
|
+
"id": d["id"],
|
|
22
|
+
"name": d.get("name", ""),
|
|
23
|
+
"description": d.get("description"),
|
|
24
|
+
"app_id": app_id,
|
|
25
|
+
"app_type": app_type,
|
|
26
|
+
"updated_at": d.get("updated_at"),
|
|
27
|
+
"created_at": d.get("created_at"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dashboards_router.get("/", response_model=list[DashboardOut])
|
|
32
|
+
async def get_dashboards_api(db: FdbDb) -> list[dict]:
|
|
33
|
+
dashboards = entities.list_dashboards(db)
|
|
34
|
+
return [_dash_to_out(d, db) for d in dashboards if not d.get("is_archived")]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dashboards_router.post("/", response_model=DashboardOut, status_code=201)
|
|
38
|
+
async def create_dashboard_api(body: DashboardCreateIn, db: FdbDb) -> dict:
|
|
39
|
+
d = entities.create_dashboard(
|
|
40
|
+
db,
|
|
41
|
+
body.name,
|
|
42
|
+
description=body.description,
|
|
43
|
+
app_id=str(body.app_id) if body.app_id else None,
|
|
44
|
+
)
|
|
45
|
+
return _dash_to_out(d, db)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dashboards_router.get("/{dashboard_id}/", response_model=DashboardOut)
|
|
49
|
+
async def get_dashboard_api(dashboard_id: str, db: FdbDb) -> dict:
|
|
50
|
+
d = entities.get_dashboard(db, dashboard_id)
|
|
51
|
+
if not d:
|
|
52
|
+
raise HTTPException(status_code=404)
|
|
53
|
+
return _dash_to_out(d, db)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dashboards_router.put("/{dashboard_id}/", response_model=DashboardOut)
|
|
57
|
+
async def update_dashboard_api(dashboard_id: str, body: DashboardUpdateIn, db: FdbDb) -> dict:
|
|
58
|
+
d = entities.get_dashboard(db, dashboard_id)
|
|
59
|
+
if not d:
|
|
60
|
+
raise HTTPException(status_code=404)
|
|
61
|
+
updates = {}
|
|
62
|
+
if body.name is not None:
|
|
63
|
+
updates["name"] = body.name
|
|
64
|
+
if body.description is not None:
|
|
65
|
+
updates["description"] = body.description
|
|
66
|
+
if updates:
|
|
67
|
+
entities.update_dashboard(db, dashboard_id, **updates)
|
|
68
|
+
updated = entities.get_dashboard(db, dashboard_id)
|
|
69
|
+
if not updated:
|
|
70
|
+
raise HTTPException(status_code=404)
|
|
71
|
+
return _dash_to_out(updated, db)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dashboards_router.delete("/{dashboard_id}/", status_code=204, response_model=None)
|
|
75
|
+
async def delete_dashboard_api(dashboard_id: str, db: FdbDb) -> None:
|
|
76
|
+
d = entities.get_dashboard(db, dashboard_id)
|
|
77
|
+
if not d:
|
|
78
|
+
raise HTTPException(status_code=404)
|
|
79
|
+
entities.update_dashboard(db, dashboard_id, is_archived=True)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Header, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from matyan_backend.deps import FdbDb, KafkaProducerDep # noqa: TC001
|
|
11
|
+
from matyan_backend.kafka.producer import emit_control_event
|
|
12
|
+
from matyan_backend.storage import entities
|
|
13
|
+
from matyan_backend.storage.runs import get_run_meta
|
|
14
|
+
|
|
15
|
+
from ._pydantic_models import (
|
|
16
|
+
ExperimentActivityApiOut,
|
|
17
|
+
ExperimentCreateRequest,
|
|
18
|
+
ExperimentGetOut,
|
|
19
|
+
ExperimentGetRunsResponse,
|
|
20
|
+
ExperimentListOut,
|
|
21
|
+
ExperimentUpdateOut,
|
|
22
|
+
ExperimentUpdateRequest,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
rest_router_experiments = APIRouter(prefix="/experiments")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoteIn(BaseModel):
|
|
29
|
+
content: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _exp_to_out(exp: dict, run_count: int) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"id": exp["id"],
|
|
35
|
+
"name": exp.get("name", ""),
|
|
36
|
+
"description": exp.get("description", ""),
|
|
37
|
+
"run_count": run_count,
|
|
38
|
+
"archived": exp.get("is_archived", False),
|
|
39
|
+
"creation_time": exp.get("created_at"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@rest_router_experiments.get("/", response_model=ExperimentListOut)
|
|
44
|
+
async def get_experiments_list_api(db: FdbDb) -> list[dict]:
|
|
45
|
+
exps = entities.list_experiments(db)
|
|
46
|
+
result: list[dict] = []
|
|
47
|
+
for exp in exps:
|
|
48
|
+
run_hashes = entities.get_runs_for_experiment(db, exp["id"])
|
|
49
|
+
result.append(_exp_to_out(exp, run_count=len(run_hashes)))
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@rest_router_experiments.get("/search/", response_model=ExperimentListOut)
|
|
54
|
+
async def search_experiments_by_name_api(
|
|
55
|
+
db: FdbDb,
|
|
56
|
+
q: Annotated[str | None, Query()] = None,
|
|
57
|
+
) -> list[dict]:
|
|
58
|
+
exps = entities.list_experiments(db)
|
|
59
|
+
search_term = q.strip().lower() if q else ""
|
|
60
|
+
if search_term:
|
|
61
|
+
exps = [e for e in exps if search_term in e.get("name", "").lower()]
|
|
62
|
+
result: list[dict] = []
|
|
63
|
+
for exp in exps:
|
|
64
|
+
run_hashes = entities.get_runs_for_experiment(db, exp["id"])
|
|
65
|
+
result.append(_exp_to_out(exp, run_count=len(run_hashes)))
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@rest_router_experiments.post("/", response_model=ExperimentUpdateOut)
|
|
70
|
+
async def create_experiment_api(request: ExperimentCreateRequest, db: FdbDb) -> dict[str, Any]:
|
|
71
|
+
try:
|
|
72
|
+
exp = entities.create_experiment(db, name=request.name.strip())
|
|
73
|
+
except ValueError as e:
|
|
74
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
75
|
+
return {"id": exp["id"], "status": "OK"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@rest_router_experiments.get("/{uuid}/", response_model=ExperimentGetOut)
|
|
79
|
+
async def get_experiment_api(uuid: str, db: FdbDb) -> dict:
|
|
80
|
+
exp = entities.get_experiment(db, uuid)
|
|
81
|
+
if not exp:
|
|
82
|
+
raise HTTPException(status_code=404)
|
|
83
|
+
run_hashes = entities.get_runs_for_experiment(db, uuid)
|
|
84
|
+
return _exp_to_out(exp, run_count=len(run_hashes))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@rest_router_experiments.delete("/{uuid}/")
|
|
88
|
+
async def delete_experiment_api(uuid: str, db: FdbDb, producer: KafkaProducerDep) -> dict[str, str]:
|
|
89
|
+
exp = entities.get_experiment(db, uuid)
|
|
90
|
+
if not exp:
|
|
91
|
+
raise HTTPException(status_code=404)
|
|
92
|
+
entities.delete_experiment(db, uuid)
|
|
93
|
+
await emit_control_event(producer, "experiment_deleted", experiment_id=uuid)
|
|
94
|
+
return {"status": "OK"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@rest_router_experiments.put("/{uuid}/", response_model=ExperimentUpdateOut)
|
|
98
|
+
async def update_experiment_properties_api(
|
|
99
|
+
uuid: str,
|
|
100
|
+
exp_in: ExperimentUpdateRequest,
|
|
101
|
+
db: FdbDb,
|
|
102
|
+
) -> dict[str, str]:
|
|
103
|
+
exp = entities.get_experiment(db, uuid)
|
|
104
|
+
if not exp:
|
|
105
|
+
raise HTTPException(status_code=404)
|
|
106
|
+
|
|
107
|
+
updates: dict = {}
|
|
108
|
+
if exp_in.name:
|
|
109
|
+
updates["name"] = exp_in.name.strip()
|
|
110
|
+
if exp_in.description is not None:
|
|
111
|
+
updates["description"] = exp_in.description
|
|
112
|
+
if exp_in.archived is not None:
|
|
113
|
+
updates["is_archived"] = exp_in.archived
|
|
114
|
+
|
|
115
|
+
if updates:
|
|
116
|
+
entities.update_experiment(db, uuid, **updates)
|
|
117
|
+
|
|
118
|
+
return {"id": uuid, "status": "OK"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@rest_router_experiments.get("/{uuid}/runs/", response_model=ExperimentGetRunsResponse)
|
|
122
|
+
async def get_experiment_runs_api(
|
|
123
|
+
uuid: str,
|
|
124
|
+
db: FdbDb,
|
|
125
|
+
limit: Annotated[int | None, Query()] = None,
|
|
126
|
+
offset: Annotated[str | None, Query()] = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
exp = entities.get_experiment(db, uuid)
|
|
129
|
+
if not exp:
|
|
130
|
+
raise HTTPException(status_code=404)
|
|
131
|
+
|
|
132
|
+
run_hashes = entities.get_runs_for_experiment(db, uuid)
|
|
133
|
+
|
|
134
|
+
offset_idx = 0
|
|
135
|
+
if offset and offset in run_hashes:
|
|
136
|
+
offset_idx = run_hashes.index(offset) + 1
|
|
137
|
+
run_hashes = run_hashes[offset_idx : offset_idx + limit] if limit else run_hashes[offset_idx:]
|
|
138
|
+
|
|
139
|
+
runs = []
|
|
140
|
+
for rh in run_hashes:
|
|
141
|
+
meta = get_run_meta(db, rh)
|
|
142
|
+
runs.append(
|
|
143
|
+
{
|
|
144
|
+
"run_id": rh,
|
|
145
|
+
"name": meta.get("name", ""),
|
|
146
|
+
"creation_time": meta.get("created_at", 0),
|
|
147
|
+
"end_time": meta.get("finalized_at"),
|
|
148
|
+
"archived": meta.get("is_archived", False),
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return {"id": uuid, "runs": runs}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Notes
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@rest_router_experiments.get("/{exp_id}/note/")
|
|
161
|
+
async def list_note_api(exp_id: str, db: FdbDb) -> list[dict]:
|
|
162
|
+
exp = entities.get_experiment(db, exp_id)
|
|
163
|
+
if not exp:
|
|
164
|
+
raise HTTPException(status_code=404)
|
|
165
|
+
return entities.list_notes_for_experiment(db, exp_id)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@rest_router_experiments.post("/{exp_id}/note/", status_code=201)
|
|
169
|
+
async def create_note_api(exp_id: str, note_in: NoteIn, db: FdbDb) -> dict[str, Any]:
|
|
170
|
+
exp = entities.get_experiment(db, exp_id)
|
|
171
|
+
if not exp:
|
|
172
|
+
raise HTTPException(status_code=404)
|
|
173
|
+
note = entities.create_note(db, note_in.content.strip(), experiment_id=exp_id)
|
|
174
|
+
return {"id": note["id"], "created_at": note["created_at"]}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@rest_router_experiments.get("/{exp_id}/note/{_id}/")
|
|
178
|
+
async def get_note_api(exp_id: str, _id: str, db: FdbDb) -> dict[str, Any]: # noqa: ARG001
|
|
179
|
+
note = entities.get_note(db, _id)
|
|
180
|
+
if not note:
|
|
181
|
+
raise HTTPException(status_code=404)
|
|
182
|
+
return {"id": note["id"], "content": note.get("content", ""), "updated_at": note.get("updated_at")}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@rest_router_experiments.put("/{exp_id}/note/{_id}/")
|
|
186
|
+
async def update_note_api(exp_id: str, _id: str, note_in: NoteIn, db: FdbDb) -> dict[str, Any]: # noqa: ARG001
|
|
187
|
+
note = entities.get_note(db, _id)
|
|
188
|
+
if not note:
|
|
189
|
+
raise HTTPException(status_code=404)
|
|
190
|
+
entities.update_note(db, _id, content=note_in.content.strip())
|
|
191
|
+
updated = entities.get_note(db, _id)
|
|
192
|
+
if not updated:
|
|
193
|
+
raise HTTPException(status_code=404)
|
|
194
|
+
return {"id": _id, "content": updated.get("content", ""), "updated_at": updated.get("updated_at")}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@rest_router_experiments.delete("/{exp_id}/note/{_id}/")
|
|
198
|
+
async def delete_note_api(exp_id: str, _id: str, db: FdbDb) -> dict[str, str]: # noqa: ARG001
|
|
199
|
+
note = entities.get_note(db, _id)
|
|
200
|
+
if not note:
|
|
201
|
+
raise HTTPException(status_code=404)
|
|
202
|
+
entities.delete_note(db, _id)
|
|
203
|
+
return {"status": "OK"}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@rest_router_experiments.get("/{exp_id}/activity/", response_model=ExperimentActivityApiOut)
|
|
207
|
+
async def experiment_runs_activity_api(
|
|
208
|
+
exp_id: str,
|
|
209
|
+
db: FdbDb,
|
|
210
|
+
x_timezone_offset: Annotated[int, Header()] = 0,
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
exp = entities.get_experiment(db, exp_id)
|
|
213
|
+
if not exp:
|
|
214
|
+
raise HTTPException(status_code=404)
|
|
215
|
+
|
|
216
|
+
run_hashes = entities.get_runs_for_experiment(db, exp_id)
|
|
217
|
+
|
|
218
|
+
num_runs = 0
|
|
219
|
+
num_archived = 0
|
|
220
|
+
num_active = 0
|
|
221
|
+
activity_counter: Counter[str] = Counter()
|
|
222
|
+
|
|
223
|
+
for rh in run_hashes:
|
|
224
|
+
meta = get_run_meta(db, rh)
|
|
225
|
+
num_runs += 1
|
|
226
|
+
if meta.get("is_archived"):
|
|
227
|
+
num_archived += 1
|
|
228
|
+
if meta.get("active"):
|
|
229
|
+
num_active += 1
|
|
230
|
+
|
|
231
|
+
created = meta.get("created_at")
|
|
232
|
+
if created:
|
|
233
|
+
ts = created - x_timezone_offset * 60
|
|
234
|
+
day = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC).strftime("%Y-%m-%dT%H:00:00")
|
|
235
|
+
activity_counter[day] += 1
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
"num_runs": num_runs,
|
|
239
|
+
"num_archived_runs": num_archived,
|
|
240
|
+
"num_active_runs": num_active,
|
|
241
|
+
"activity_map": dict(activity_counter),
|
|
242
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations # noqa: I001
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from uuid import UUID # noqa: TC003
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExperimentCreateRequest(BaseModel):
|
|
10
|
+
name: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExperimentUpdateRequest(BaseModel):
|
|
14
|
+
name: str | None = ""
|
|
15
|
+
description: str | None = ""
|
|
16
|
+
archived: bool | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExperimentGetOut(BaseModel):
|
|
20
|
+
id: UUID
|
|
21
|
+
name: str
|
|
22
|
+
description: str | None = ""
|
|
23
|
+
run_count: int
|
|
24
|
+
archived: bool
|
|
25
|
+
creation_time: float | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ExperimentListOut = list[ExperimentGetOut]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExperimentUpdateOut(BaseModel):
|
|
32
|
+
id: UUID
|
|
33
|
+
status: str = "OK"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ExperimentGetRunsResponse(BaseModel):
|
|
37
|
+
class Run(BaseModel):
|
|
38
|
+
run_id: str
|
|
39
|
+
name: str
|
|
40
|
+
creation_time: float
|
|
41
|
+
end_time: float | None
|
|
42
|
+
archived: bool
|
|
43
|
+
|
|
44
|
+
id: UUID
|
|
45
|
+
runs: list[Run]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ExperimentActivityApiOut(BaseModel):
|
|
49
|
+
num_runs: int
|
|
50
|
+
num_archived_runs: int
|
|
51
|
+
num_active_runs: int
|
|
52
|
+
activity_map: dict[str, int] = {"2021-01-01": 54}
|