modelstudio-sdk 0.0.0.dev0__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.
@@ -0,0 +1,25 @@
1
+ """Model Studio Python SDK."""
2
+
3
+ from modelstudio._version import __version__
4
+ from modelstudio.client import ModelStudioClient
5
+ from modelstudio.exceptions import (
6
+ AuthenticationError,
7
+ BadRequestError,
8
+ ConflictError,
9
+ ModelStudioError,
10
+ NotFoundError,
11
+ ServerError,
12
+ TimeoutError,
13
+ )
14
+
15
+ __all__ = [
16
+ "__version__",
17
+ "ModelStudioClient",
18
+ "ModelStudioError",
19
+ "BadRequestError",
20
+ "AuthenticationError",
21
+ "NotFoundError",
22
+ "ConflictError",
23
+ "ServerError",
24
+ "TimeoutError",
25
+ ]
modelstudio/_http.py ADDED
@@ -0,0 +1,116 @@
1
+ """HTTP transport layer wrapping httpx with auth and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from modelstudio.exceptions import (
10
+ STATUS_CODE_MAP,
11
+ AuthenticationError,
12
+ ModelStudioError,
13
+ ServerError,
14
+ )
15
+
16
+
17
+ class HttpTransport:
18
+ """Thin httpx wrapper that injects auth headers and maps errors to typed exceptions."""
19
+
20
+ def __init__(
21
+ self,
22
+ base_url: str,
23
+ jwt_token: str | None = None,
24
+ timeout: float = 30.0,
25
+ org_name: str | None = None,
26
+ ) -> None:
27
+ headers: dict[str, str] = {
28
+ "X-Source": "modelstudio-sdk",
29
+ "Accept": "application/json",
30
+ }
31
+ if jwt_token:
32
+ headers["Authorization"] = f"Bearer {jwt_token}"
33
+ if org_name:
34
+ headers["X-Org-Name"] = org_name
35
+
36
+ self._client = httpx.Client(
37
+ base_url=base_url.rstrip("/"),
38
+ headers=headers,
39
+ timeout=timeout,
40
+ )
41
+
42
+ def close(self) -> None:
43
+ self._client.close()
44
+
45
+ # --- HTTP methods ---
46
+
47
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
48
+ resp = self._client.get(path, params=params)
49
+ return self._handle(resp)
50
+
51
+ def post(self, path: str, json: Any = None) -> Any:
52
+ resp = self._client.post(path, json=json)
53
+ return self._handle(resp)
54
+
55
+ def put(self, path: str, json: Any = None) -> Any:
56
+ resp = self._client.put(path, json=json)
57
+ return self._handle(resp)
58
+
59
+ def patch(self, path: str, json: Any = None) -> Any:
60
+ resp = self._client.patch(path, json=json)
61
+ return self._handle(resp)
62
+
63
+ def delete(self, path: str, json: Any = None, params: dict[str, Any] | None = None) -> Any:
64
+ if json is not None:
65
+ req = self._client.build_request("DELETE", path, json=json, params=params)
66
+ resp = self._client.send(req)
67
+ else:
68
+ resp = self._client.delete(path, params=params)
69
+ return self._handle(resp)
70
+
71
+ def delete_with_body(self, path: str, json: Any = None) -> Any:
72
+ """DELETE with a JSON request body (e.g. bulk annotation delete)."""
73
+ req = self._client.build_request("DELETE", path, json=json)
74
+ resp = self._client.send(req)
75
+ return self._handle(resp)
76
+
77
+ def post_accepted(self, path: str, json: Any = None) -> Any:
78
+ """POST that expects 202 Accepted (async operations)."""
79
+ resp = self._client.post(path, json=json)
80
+ return self._handle(resp)
81
+
82
+ # --- Response handling ---
83
+
84
+ def _handle(self, resp: httpx.Response) -> Any:
85
+ if resp.status_code == 204:
86
+ return None
87
+
88
+ if resp.is_success:
89
+ if not resp.content:
90
+ return None
91
+ return resp.json()
92
+
93
+ self._raise_for_status(resp)
94
+
95
+ def _raise_for_status(self, resp: httpx.Response) -> None:
96
+ error_code = "Unknown"
97
+ message = f"HTTP {resp.status_code}"
98
+
99
+ try:
100
+ body = resp.json()
101
+ error_code = body.get("error", error_code)
102
+ message = body.get("message", message)
103
+ except Exception:
104
+ message = resp.text or message
105
+
106
+ exc_class = STATUS_CODE_MAP.get(resp.status_code)
107
+ if exc_class:
108
+ raise exc_class(message=message, error_code=error_code)
109
+
110
+ if resp.status_code == 401:
111
+ raise AuthenticationError(message=message)
112
+ if resp.status_code >= 500:
113
+ raise ServerError(message=message)
114
+ raise ModelStudioError(
115
+ message=message, status_code=resp.status_code, error_code=error_code
116
+ )
modelstudio/_pandas.py ADDED
@@ -0,0 +1,33 @@
1
+ """Optional pandas DataFrame helpers (guarded import)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, TypeVar
6
+
7
+ from pydantic import BaseModel
8
+
9
+ if TYPE_CHECKING:
10
+ import pandas as pd
11
+
12
+ T = TypeVar("T", bound=BaseModel)
13
+
14
+
15
+ def _get_pandas() -> Any:
16
+ try:
17
+ import pandas
18
+
19
+ return pandas
20
+ except ImportError:
21
+ raise ImportError(
22
+ "pandas is required for DataFrame operations. "
23
+ "Install it with: pip install 'modelstudio-sdk[pandas]'"
24
+ ) from None
25
+
26
+
27
+ def to_dataframe(items: list[T]) -> pd.DataFrame:
28
+ """Convert a list of Pydantic models to a pandas DataFrame."""
29
+ pd = _get_pandas()
30
+ if not items:
31
+ return pd.DataFrame()
32
+ rows = [item.model_dump() for item in items]
33
+ return pd.DataFrame(rows)
@@ -0,0 +1,59 @@
1
+ """Poller for async operations (clone, import, finalize)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from modelstudio.exceptions import TimeoutError
10
+
11
+
12
+ class OperationPoller:
13
+ """Polls an async operation endpoint until a terminal status is reached."""
14
+
15
+ def __init__(
16
+ self,
17
+ transport: Any,
18
+ poll_url: str,
19
+ terminal_statuses: set[str] | None = None,
20
+ interval: float = 2.0,
21
+ max_wait: float = 300.0,
22
+ ) -> None:
23
+ self._transport = transport
24
+ self._poll_url = poll_url
25
+ self._terminal = terminal_statuses or {"COMPLETED", "FAILED", "ERROR", "DONE"}
26
+ self._interval = interval
27
+ self._max_wait = max_wait
28
+
29
+ def poll_once(self) -> dict[str, Any]:
30
+ """Single poll, returns the status response dict."""
31
+ return self._transport.get(self._poll_url) # type: ignore[no-any-return]
32
+
33
+ def wait(self, callback: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]:
34
+ """Block until terminal status. Optional progress callback called on each poll."""
35
+ elapsed = 0.0
36
+ while elapsed < self._max_wait:
37
+ result = self.poll_once()
38
+ if callback:
39
+ callback(result)
40
+
41
+ status = self._extract_status(result)
42
+ if status and status.upper() in self._terminal:
43
+ return result
44
+
45
+ time.sleep(self._interval)
46
+ elapsed += self._interval
47
+
48
+ raise TimeoutError(
49
+ f"Operation at {self._poll_url} did not complete within {self._max_wait}s"
50
+ )
51
+
52
+ @staticmethod
53
+ def _extract_status(data: dict[str, Any]) -> str | None:
54
+ """Extract status from common response shapes."""
55
+ for key in ("status", "clone_status", "finalize_status", "import_status"):
56
+ val = data.get(key)
57
+ if val is not None:
58
+ return str(val)
59
+ return None
@@ -0,0 +1 @@
1
+ __version__ = "0.0.0.dev0"
modelstudio/client.py ADDED
@@ -0,0 +1,108 @@
1
+ """ModelStudioClient — main entry point for the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from uuid import UUID
7
+
8
+ from modelstudio._http import HttpTransport
9
+ from modelstudio.resources.dataset import Dataset, DatasetsCollection
10
+
11
+
12
+ class ModelStudioClient:
13
+ """Client for the Model Studio REST API.
14
+
15
+ Usage::
16
+
17
+ # Auto-configured from environment variables
18
+ client = ModelStudioClient.from_env()
19
+
20
+ # Or explicit
21
+ client = ModelStudioClient(
22
+ base_url="http://localhost:8081",
23
+ jwt_token="eyJhbG...",
24
+ )
25
+
26
+ # List datasets
27
+ datasets = client.datasets.list()
28
+
29
+ # Work with a specific dataset
30
+ ds = client.dataset("uuid-here")
31
+ overview = ds.overview()
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str,
37
+ jwt_token: str | None = None,
38
+ timeout: float = 30.0,
39
+ org_name: str | None = None,
40
+ ) -> None:
41
+ self._transport = HttpTransport(
42
+ base_url=base_url,
43
+ jwt_token=jwt_token,
44
+ timeout=timeout,
45
+ org_name=org_name,
46
+ )
47
+ self._base_url = base_url.rstrip("/")
48
+
49
+ @classmethod
50
+ def from_env(cls) -> ModelStudioClient:
51
+ """Create client from environment variables.
52
+
53
+ Reads:
54
+ MODEL_STUDIO_API_URL: API base URL (required)
55
+ MODEL_STUDIO_JWT: JWT token (optional)
56
+ MODEL_STUDIO_ORG: Organization name (optional)
57
+ """
58
+ base_url = os.environ.get("MODEL_STUDIO_API_URL")
59
+ if not base_url:
60
+ raise ValueError(
61
+ "MODEL_STUDIO_API_URL environment variable is required. "
62
+ "Set it to the Model Studio API base URL "
63
+ "(e.g. http://model-studio-api-service.model-studio.svc.cluster.local)"
64
+ )
65
+ return cls(
66
+ base_url=base_url,
67
+ jwt_token=os.environ.get("MODEL_STUDIO_JWT"),
68
+ org_name=os.environ.get("MODEL_STUDIO_ORG"),
69
+ )
70
+
71
+ @property
72
+ def datasets(self) -> DatasetsCollection:
73
+ """Access dataset collection methods (list, create, merge)."""
74
+ return DatasetsCollection(self._transport)
75
+
76
+ def dataset(self, dataset_id: str | UUID) -> Dataset:
77
+ """Get a Dataset resource bound to a specific dataset ID."""
78
+ return Dataset(self._transport, str(dataset_id))
79
+
80
+ def full_image_url(
81
+ self, dataset_id: str | UUID, image_id: str | UUID, ext: str = "png"
82
+ ) -> str:
83
+ """Build a full image URL for direct browser/download use."""
84
+ return f"{self._base_url}/api/v1/media/images/{dataset_id}/{image_id}.{ext}"
85
+
86
+ def thumbnail_url(
87
+ self,
88
+ dataset_id: str | UUID,
89
+ image_id: str | UUID,
90
+ size: int,
91
+ hash: str,
92
+ ext: str = "webp",
93
+ ) -> str:
94
+ """Build a thumbnail URL."""
95
+ return (
96
+ f"{self._base_url}/api/v1/media/thumbs"
97
+ f"/{dataset_id}/{image_id}_{size}.{ext}?h={hash}"
98
+ )
99
+
100
+ def close(self) -> None:
101
+ """Close the underlying HTTP connection."""
102
+ self._transport.close()
103
+
104
+ def __enter__(self) -> ModelStudioClient:
105
+ return self
106
+
107
+ def __exit__(self, *args: object) -> None:
108
+ self.close()
@@ -0,0 +1,80 @@
1
+ """Typed exception hierarchy mapped to API error envelope."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ModelStudioError(Exception):
7
+ """Base exception for all Model Studio SDK errors."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ status_code: int | None = None,
13
+ error_code: str | None = None,
14
+ ) -> None:
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.status_code = status_code
18
+ self.error_code = error_code
19
+
20
+ def __repr__(self) -> str:
21
+ return (
22
+ f"{self.__class__.__name__}("
23
+ f"status_code={self.status_code}, "
24
+ f"error_code={self.error_code!r}, "
25
+ f"message={self.message!r})"
26
+ )
27
+
28
+
29
+ class BadRequestError(ModelStudioError):
30
+ """400 Bad Request — invalid input or validation failure."""
31
+
32
+ def __init__(self, message: str, error_code: str = "BadRequest") -> None:
33
+ super().__init__(message, status_code=400, error_code=error_code)
34
+
35
+
36
+ class AuthenticationError(ModelStudioError):
37
+ """401 Unauthorized — missing or invalid JWT token."""
38
+
39
+ def __init__(self, message: str = "Unauthorized", error_code: str = "Unauthorized") -> None:
40
+ super().__init__(message, status_code=401, error_code=error_code)
41
+
42
+
43
+ class NotFoundError(ModelStudioError):
44
+ """404 Not Found — resource does not exist."""
45
+
46
+ def __init__(self, message: str, error_code: str = "NotFound") -> None:
47
+ super().__init__(message, status_code=404, error_code=error_code)
48
+
49
+
50
+ class ConflictError(ModelStudioError):
51
+ """409 Conflict — operation conflicts with current state."""
52
+
53
+ def __init__(self, message: str, error_code: str = "Conflict") -> None:
54
+ super().__init__(message, status_code=409, error_code=error_code)
55
+
56
+
57
+ class ServerError(ModelStudioError):
58
+ """500 Internal Server Error — unexpected server failure."""
59
+
60
+ def __init__(
61
+ self, message: str = "Internal server error", error_code: str = "InternalServerError"
62
+ ) -> None:
63
+ super().__init__(message, status_code=500, error_code=error_code)
64
+
65
+
66
+ class TimeoutError(ModelStudioError):
67
+ """Polling timeout — async operation did not complete in time."""
68
+
69
+ def __init__(self, message: str = "Operation timed out") -> None:
70
+ super().__init__(message, status_code=None, error_code="Timeout")
71
+
72
+
73
+ # Map HTTP status codes to exception classes
74
+ STATUS_CODE_MAP: dict[int, type[ModelStudioError]] = {
75
+ 400: BadRequestError,
76
+ 401: AuthenticationError,
77
+ 404: NotFoundError,
78
+ 409: ConflictError,
79
+ 500: ServerError,
80
+ }
@@ -0,0 +1,81 @@
1
+ """Pydantic v2 response/request models matching the Model Studio API."""
2
+
3
+ from modelstudio.models.annotations import (
4
+ AnnotationModel,
5
+ CreateAnnotationRequest,
6
+ DeleteAnnotationsResponse,
7
+ PaginatedAnnotationModel,
8
+ UpdateAnnotationRequest,
9
+ )
10
+ from modelstudio.models.common import PagedResponse
11
+ from modelstudio.models.images import (
12
+ DatasetImageModel,
13
+ ImageModel,
14
+ MarkSyntheticResponse,
15
+ PaginatedImageModel,
16
+ RemoveEmptyImagesResponse,
17
+ SyntheticStatsModel,
18
+ )
19
+ from modelstudio.models.imports import (
20
+ ImportJobListModel,
21
+ ImportJobModel,
22
+ ImportJobQueuedModel,
23
+ ImportLogEntryModel,
24
+ ImportQueuedModel,
25
+ ImportStatusModel,
26
+ PreImportValidationModel,
27
+ ValidationStartedModel,
28
+ )
29
+ from modelstudio.models.media import (
30
+ MediaJobActionModel,
31
+ MediaJobErrorModel,
32
+ MediaJobStatusModel,
33
+ )
34
+ from modelstudio.models.splits import (
35
+ AlgorithmStats,
36
+ ClassAwareRedistributeResponse,
37
+ ClassError,
38
+ CreateSplitsResponse,
39
+ DistributionError,
40
+ LeakageResponse,
41
+ RedistributeResponse,
42
+ SmartRedistributeResponse,
43
+ SplitModel,
44
+ SplitStats,
45
+ )
46
+
47
+ __all__ = [
48
+ "AnnotationModel",
49
+ "CreateAnnotationRequest",
50
+ "DeleteAnnotationsResponse",
51
+ "PaginatedAnnotationModel",
52
+ "UpdateAnnotationRequest",
53
+ "PagedResponse",
54
+ "DatasetImageModel",
55
+ "ImageModel",
56
+ "MarkSyntheticResponse",
57
+ "PaginatedImageModel",
58
+ "RemoveEmptyImagesResponse",
59
+ "SyntheticStatsModel",
60
+ "ImportJobListModel",
61
+ "ImportJobModel",
62
+ "ImportJobQueuedModel",
63
+ "ImportLogEntryModel",
64
+ "ImportQueuedModel",
65
+ "ImportStatusModel",
66
+ "PreImportValidationModel",
67
+ "ValidationStartedModel",
68
+ "MediaJobActionModel",
69
+ "MediaJobErrorModel",
70
+ "MediaJobStatusModel",
71
+ "AlgorithmStats",
72
+ "ClassAwareRedistributeResponse",
73
+ "ClassError",
74
+ "CreateSplitsResponse",
75
+ "DistributionError",
76
+ "LeakageResponse",
77
+ "RedistributeResponse",
78
+ "SmartRedistributeResponse",
79
+ "SplitModel",
80
+ "SplitStats",
81
+ ]
@@ -0,0 +1,80 @@
1
+ """Annotation-related models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from uuid import UUID
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class AnnotationModel(BaseModel):
12
+ """COCO annotation."""
13
+
14
+ annotation_id: int
15
+ image_id: UUID
16
+ category_id: int
17
+ segmentation: Any | None = None
18
+ bbox: list[float] = []
19
+ ignore: bool = False
20
+ iscrowd: bool = False
21
+ area: float = 0.0
22
+ keypoints: Any | None = None
23
+ num_keypoints: int | None = None
24
+
25
+
26
+ class CreateAnnotationRequest(BaseModel):
27
+ """Request for creating an annotation."""
28
+
29
+ annotation_id: int | None = None
30
+ image_id: UUID
31
+ category_id: int
32
+ segmentation: Any | None = None
33
+ bbox: list[float] = []
34
+ ignore: bool = False
35
+ iscrowd: bool = False
36
+ area: float = 0.0
37
+ keypoints: Any | None = None
38
+ num_keypoints: int | None = None
39
+
40
+
41
+ class UpdateAnnotationRequest(BaseModel):
42
+ """Request for updating an annotation."""
43
+
44
+ category_id: int | None = None
45
+ segmentation: Any | None = None
46
+ bbox: list[float] | None = None
47
+ area: float | None = None
48
+ iscrowd: bool | None = None
49
+ ignore: bool | None = None
50
+
51
+
52
+ class DeleteAnnotationsResponse(BaseModel):
53
+ """Response from DELETE /datasets/{id}/annotations."""
54
+
55
+ success: bool = False
56
+ message: str | None = None
57
+ annotations_deleted: int = 0
58
+ images_deleted: int = 0
59
+ categories_deleted: int = 0
60
+
61
+
62
+ class PaginatedAnnotationModel(BaseModel):
63
+ """An annotation in the paginated dataset-level annotation listing."""
64
+
65
+ annotation_id: int
66
+ image_id: UUID
67
+ category_id: int
68
+ segmentation: Any | None = None
69
+ bbox: list[float] = []
70
+ ignore: bool = False
71
+ iscrowd: bool = False
72
+ area: float = 0.0
73
+ keypoints: Any | None = None
74
+ num_keypoints: int | None = None
75
+ category_name: str | None = None
76
+ segmentation_type: str | None = None
77
+ tags: list[str] = []
78
+ confidence_score: float | None = None
79
+ annotator_id: str | None = None
80
+ status: str | None = None
@@ -0,0 +1,106 @@
1
+ """Category-related models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class CategoryModel(BaseModel):
11
+ """A dataset category."""
12
+
13
+ category_id: int
14
+ name: str
15
+ supercategory: str | None = None
16
+ annotation_count: int = 0
17
+ image_count: int = 0
18
+ pixel_count: int | None = None
19
+ pixel_percentage: float | None = None
20
+ area_m2: float | None = None
21
+
22
+
23
+ class CategoryListModel(BaseModel):
24
+ """Response from GET /datasets/{id}/categories."""
25
+
26
+ categories: list[CategoryModel] = []
27
+ total_categories: int = 0
28
+ total_annotations: int = 0
29
+
30
+
31
+ class MergeCategoriesResponse(BaseModel):
32
+ """Response from POST /datasets/{id}/categories/merge."""
33
+
34
+ merged: bool = False
35
+ target_id: int | None = None
36
+ target_name: str | None = None
37
+ affected_annotations: int = 0
38
+ deleted_categories: list[str] = []
39
+
40
+
41
+ class RenameCategoryResponse(BaseModel):
42
+ """Response from PUT /datasets/{id}/categories/{cid}/rename."""
43
+
44
+ old_name: str | None = None
45
+ new_name: str | None = None
46
+ category_id: int | None = None
47
+ affected_annotations: int = 0
48
+
49
+
50
+ class RemoveCategoryResponse(BaseModel):
51
+ """Response from DELETE /datasets/{id}/categories/{cid}."""
52
+
53
+ removed: Any = None
54
+ category_id: int | None = None
55
+ annotations_affected: int = 0
56
+ action_taken: str | None = None
57
+
58
+
59
+ class ConsolidateLabelsResponse(BaseModel):
60
+ """Response from POST /datasets/{id}/categories/consolidate."""
61
+
62
+ consolidated: Any = None
63
+ mapping_applied: dict[str, Any] | None = None
64
+ total_annotations_updated: int = 0
65
+
66
+
67
+ class MergeBySupercategoryResponse(BaseModel):
68
+ """Response from POST /datasets/{id}/categories/merge-by-supercategory."""
69
+
70
+ supercategory: str | None = None
71
+ target_category_name: str | None = None
72
+ target_category_id: int | None = None
73
+ categories_merged: int = 0
74
+ merged_categories: list[str] = []
75
+ annotations_affected: int = 0
76
+
77
+
78
+ class SplitCategoryResponse(BaseModel):
79
+ """Response from POST /datasets/{id}/categories/split."""
80
+
81
+ operation_id: str | None = None
82
+ source_category_name: str | None = None
83
+ target_categories: list[str] = []
84
+ annotations_to_assign: int = 0
85
+
86
+
87
+ class CancelSplitCategoryResponse(BaseModel):
88
+ """Response from DELETE /datasets/{id}/categories/split/{opId}."""
89
+
90
+ operation_id: str | None = None
91
+ source_category_name: str | None = None
92
+ target_categories_expected: int = 0
93
+ target_categories_deleted: int = 0
94
+ status: str | None = None
95
+
96
+
97
+ class MergeSourcesResponse(BaseModel):
98
+ """Response from GET /datasets/{id}/categories/{cid}/merge-sources."""
99
+
100
+ dataset_id: str | None = None
101
+ category_id: int | None = None
102
+ category_name: str | None = None
103
+ has_merge_history: bool = False
104
+ leaf_categories: list[dict[str, Any]] = []
105
+ merge_chain: list[dict[str, Any]] = []
106
+ suggested_split_request: dict[str, Any] | None = None