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.
Files changed (70) hide show
  1. matyan_backend-0.1.0/PKG-INFO +25 -0
  2. matyan_backend-0.1.0/README.md +0 -0
  3. matyan_backend-0.1.0/pyproject.toml +82 -0
  4. matyan_backend-0.1.0/setup.cfg +4 -0
  5. matyan_backend-0.1.0/src/matyan_backend/__init__.py +0 -0
  6. matyan_backend-0.1.0/src/matyan_backend/api/__init__.py +3 -0
  7. matyan_backend-0.1.0/src/matyan_backend/api/_main.py +18 -0
  8. matyan_backend-0.1.0/src/matyan_backend/api/dashboard_apps/__init__.py +0 -0
  9. matyan_backend-0.1.0/src/matyan_backend/api/dashboard_apps/pydantic_models.py +24 -0
  10. matyan_backend-0.1.0/src/matyan_backend/api/dashboard_apps/views.py +71 -0
  11. matyan_backend-0.1.0/src/matyan_backend/api/dashboards/__init__.py +0 -0
  12. matyan_backend-0.1.0/src/matyan_backend/api/dashboards/pydantic_models.py +24 -0
  13. matyan_backend-0.1.0/src/matyan_backend/api/dashboards/views.py +79 -0
  14. matyan_backend-0.1.0/src/matyan_backend/api/experiments/__init__.py +3 -0
  15. matyan_backend-0.1.0/src/matyan_backend/api/experiments/_main.py +242 -0
  16. matyan_backend-0.1.0/src/matyan_backend/api/experiments/_pydantic_models.py +52 -0
  17. matyan_backend-0.1.0/src/matyan_backend/api/projects/__init__.py +3 -0
  18. matyan_backend-0.1.0/src/matyan_backend/api/projects/_main.py +65 -0
  19. matyan_backend-0.1.0/src/matyan_backend/api/projects/_pydantic_models.py +43 -0
  20. matyan_backend-0.1.0/src/matyan_backend/api/reports/__init__.py +0 -0
  21. matyan_backend-0.1.0/src/matyan_backend/api/reports/pydantic_models.py +27 -0
  22. matyan_backend-0.1.0/src/matyan_backend/api/reports/views.py +69 -0
  23. matyan_backend-0.1.0/src/matyan_backend/api/runs/__init__.py +3 -0
  24. matyan_backend-0.1.0/src/matyan_backend/api/runs/_blob_uri.py +37 -0
  25. matyan_backend-0.1.0/src/matyan_backend/api/runs/_collections.py +115 -0
  26. matyan_backend-0.1.0/src/matyan_backend/api/runs/_custom_objects.py +566 -0
  27. matyan_backend-0.1.0/src/matyan_backend/api/runs/_planner.py +149 -0
  28. matyan_backend-0.1.0/src/matyan_backend/api/runs/_pydantic_models.py +242 -0
  29. matyan_backend-0.1.0/src/matyan_backend/api/runs/_query.py +179 -0
  30. matyan_backend-0.1.0/src/matyan_backend/api/runs/_range_utils.py +23 -0
  31. matyan_backend-0.1.0/src/matyan_backend/api/runs/_run.py +496 -0
  32. matyan_backend-0.1.0/src/matyan_backend/api/runs/_streaming.py +395 -0
  33. matyan_backend-0.1.0/src/matyan_backend/api/runs/_views.py +304 -0
  34. matyan_backend-0.1.0/src/matyan_backend/api/streaming.py +189 -0
  35. matyan_backend-0.1.0/src/matyan_backend/api/tags/__init__.py +3 -0
  36. matyan_backend-0.1.0/src/matyan_backend/api/tags/_main.py +135 -0
  37. matyan_backend-0.1.0/src/matyan_backend/api/tags/_pydantic_models.py +47 -0
  38. matyan_backend-0.1.0/src/matyan_backend/app.py +51 -0
  39. matyan_backend-0.1.0/src/matyan_backend/cli.py +79 -0
  40. matyan_backend-0.1.0/src/matyan_backend/config.py +38 -0
  41. matyan_backend-0.1.0/src/matyan_backend/deps.py +39 -0
  42. matyan_backend-0.1.0/src/matyan_backend/fdb_types.py +120 -0
  43. matyan_backend-0.1.0/src/matyan_backend/kafka/__init__.py +3 -0
  44. matyan_backend-0.1.0/src/matyan_backend/kafka/producer.py +74 -0
  45. matyan_backend-0.1.0/src/matyan_backend/py.typed +0 -0
  46. matyan_backend-0.1.0/src/matyan_backend/storage/__init__.py +25 -0
  47. matyan_backend-0.1.0/src/matyan_backend/storage/context_utils.py +17 -0
  48. matyan_backend-0.1.0/src/matyan_backend/storage/encoding.py +33 -0
  49. matyan_backend-0.1.0/src/matyan_backend/storage/entities.py +544 -0
  50. matyan_backend-0.1.0/src/matyan_backend/storage/fdb_client.py +60 -0
  51. matyan_backend-0.1.0/src/matyan_backend/storage/indexes.py +311 -0
  52. matyan_backend-0.1.0/src/matyan_backend/storage/project.py +174 -0
  53. matyan_backend-0.1.0/src/matyan_backend/storage/runs.py +321 -0
  54. matyan_backend-0.1.0/src/matyan_backend/storage/s3_client.py +49 -0
  55. matyan_backend-0.1.0/src/matyan_backend/storage/sequences.py +246 -0
  56. matyan_backend-0.1.0/src/matyan_backend/storage/tree.py +154 -0
  57. matyan_backend-0.1.0/src/matyan_backend/workers/__init__.py +0 -0
  58. matyan_backend-0.1.0/src/matyan_backend/workers/control.py +173 -0
  59. matyan_backend-0.1.0/src/matyan_backend/workers/ingestion.py +372 -0
  60. matyan_backend-0.1.0/src/matyan_backend.egg-info/PKG-INFO +25 -0
  61. matyan_backend-0.1.0/src/matyan_backend.egg-info/SOURCES.txt +68 -0
  62. matyan_backend-0.1.0/src/matyan_backend.egg-info/dependency_links.txt +1 -0
  63. matyan_backend-0.1.0/src/matyan_backend.egg-info/entry_points.txt +2 -0
  64. matyan_backend-0.1.0/src/matyan_backend.egg-info/requires.txt +18 -0
  65. matyan_backend-0.1.0/src/matyan_backend.egg-info/top_level.txt +1 -0
  66. matyan_backend-0.1.0/tests/test_app.py +42 -0
  67. matyan_backend-0.1.0/tests/test_cli.py +138 -0
  68. matyan_backend-0.1.0/tests/test_infra.py +77 -0
  69. matyan_backend-0.1.0/tests/test_kafka_producer.py +43 -0
  70. 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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,3 @@
1
+ from ._main import main_router
2
+
3
+ __all__ = ["main_router"]
@@ -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"])
@@ -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)
@@ -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,3 @@
1
+ from ._main import rest_router_experiments
2
+
3
+ __all__ = ["rest_router_experiments"]
@@ -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}
@@ -0,0 +1,3 @@
1
+ from ._main import rest_router_projects
2
+
3
+ __all__ = ["rest_router_projects"]