Skip to content

Commit c68f320

Browse files
authored
Merge pull request #57 from SonanceAI/feat/object-oriented-api
Refactor ResourcesApi and EntityBaseApi for improved structure
2 parents be468c3 + 21f7b6c commit c68f320

19 files changed

Lines changed: 930 additions & 162 deletions

datamint/api/base_api.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,28 @@
11
import logging
2-
from typing import Any, Generator, AsyncGenerator, Sequence
2+
from typing import Any, Generator, AsyncGenerator, Sequence, TYPE_CHECKING
33
import httpx
44
from dataclasses import dataclass
55
from datamint.exceptions import DatamintException, ResourceNotFoundError
6+
from datamint.types import ImagingData
67
import aiohttp
78
import json
8-
import pydicom.dataset
99
from PIL import Image
1010
import cv2
1111
import nibabel as nib
12-
from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage
1312
from io import BytesIO
1413
import gzip
1514
import contextlib
1615
import asyncio
17-
from medimgkit.format_detection import GZIP_MIME_TYPES
16+
from medimgkit.format_detection import GZIP_MIME_TYPES, DEFAULT_MIME_TYPE, guess_typez, guess_extension
17+
18+
if TYPE_CHECKING:
19+
from datamint.api.client import Api
1820

1921
logger = logging.getLogger(__name__)
2022

2123
# Generic type for entities
2224
_PAGE_LIMIT = 5000
2325

24-
2526
@dataclass
2627
class ApiConfig:
2728
"""Configuration for API client.
@@ -37,6 +38,15 @@ class ApiConfig:
3738
timeout: float = 30.0
3839
max_retries: int = 3
3940

41+
@property
42+
def web_app_url(self) -> str:
43+
"""Get the base URL for the web application."""
44+
if self.server_url.startswith('http://localhost:3001'):
45+
return 'http://localhost:3000'
46+
if self.server_url.startswith('https://stagingapi.datamint.io'):
47+
return 'https://staging.datamint.io'
48+
return 'https://app.datamint.io'
49+
4050

4151
class BaseApi:
4252
"""Base class for all API endpoint handlers."""
@@ -53,6 +63,7 @@ def __init__(self,
5363
self.config = config
5464
self.client = client or self._create_client()
5565
self.semaphore = asyncio.Semaphore(20)
66+
self._api_instance: 'Api | None' = None # Injected by Api class
5667

5768
def _create_client(self) -> httpx.Client:
5869
"""Create and configure HTTP client with authentication and timeouts."""
@@ -399,10 +410,30 @@ def _convert_array_response(self,
399410

400411
@staticmethod
401412
def convert_format(bytes_array: bytes,
402-
mimetype: str,
413+
mimetype: str | None = None,
403414
file_path: str | None = None
404-
) -> pydicom.dataset.Dataset | Image.Image | cv2.VideoCapture | bytes | nib_FileBasedImage:
405-
""" Convert the bytes array to the appropriate format based on the mimetype."""
415+
) -> ImagingData | bytes:
416+
""" Convert the bytes array to the appropriate format based on the mimetype.
417+
418+
Args:
419+
bytes_array: Raw file content bytes
420+
mimetype: Optional MIME type of the content
421+
file_path: deprecated
422+
423+
Returns:
424+
Converted content in appropriate format (pydicom.Dataset, PIL Image, cv2.VideoCapture, ...)
425+
426+
Example:
427+
>>> fpath = 'path/to/file.dcm'
428+
>>> with open(fpath, 'rb') as f:
429+
... dicom_bytes = f.read()
430+
>>> dicom = BaseApi.convert_format(dicom_bytes)
431+
432+
"""
433+
if mimetype is None:
434+
mimetype, ext = BaseApi._determine_mimetype(bytes_array)
435+
if mimetype is None:
436+
raise ValueError("Could not determine mimetype from content.")
406437
content_io = BytesIO(bytes_array)
407438
if mimetype.endswith('/dicom'):
408439
return pydicom.dcmread(content_io)
@@ -429,3 +460,30 @@ def convert_format(bytes_array: bytes,
429460
return nib.Nifti1Image.from_stream(f)
430461

431462
raise ValueError(f"Unsupported mimetype: {mimetype}")
463+
464+
@staticmethod
465+
def _determine_mimetype(content: bytes,
466+
declared_mimetype: str | None = None) -> tuple[str | None, str | None]:
467+
"""Infer MIME type and file extension from content and optional declared type.
468+
469+
Args:
470+
content: Raw file content bytes
471+
declared_mimetype: Optional MIME type declared by the source
472+
473+
Returns:
474+
Tuple of (inferred_mimetype, file_extension)
475+
"""
476+
# Determine mimetype from file content
477+
mimetype_list, ext = guess_typez(content, use_magic=True)
478+
mimetype = mimetype_list[-1]
479+
480+
# get mimetype from resource info if not detected
481+
if declared_mimetype is not None:
482+
if mimetype is None:
483+
mimetype = declared_mimetype
484+
ext = guess_extension(mimetype)
485+
elif mimetype == DEFAULT_MIME_TYPE:
486+
mimetype = declared_mimetype
487+
ext = guess_extension(mimetype)
488+
489+
return mimetype, ext

datamint/api/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ def check_connection(self):
7070
def _get_endpoint(self, name: str):
7171
if name not in self._endpoints:
7272
api_class = self._API_MAP[name]
73-
self._endpoints[name] = api_class(self.config, self._client)
73+
endpoint = api_class(self.config, self._client)
74+
# Inject this API instance into the endpoint so it can inject into entities
75+
endpoint._api_instance = self
76+
self._endpoints[name] = endpoint
7477
return self._endpoints[name]
7578

7679
@property

datamint/api/dto/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from datamint.apihandler.dto import annotation_dto
2-
from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, Geometry, BoxGeometry, LineGeometry, CoordinateSystem
3-
2+
from datamint.apihandler.dto.annotation_dto import (
3+
AnnotationType, CreateAnnotationDto,
4+
Geometry, BoxGeometry, LineGeometry,
5+
CoordinateSystem
6+
)
47

58
__all__ = [
69
"annotation_dto",
@@ -10,4 +13,6 @@
1013
"BoxGeometry",
1114
"LineGeometry",
1215
"CoordinateSystem"
13-
]
16+
"LineGeometry",
17+
"CoordinateSystem"
18+
]

datamint/api/endpoints/annotations_api.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,21 @@
3131
class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotation]):
3232
"""API handler for annotation-related endpoints."""
3333

34-
def __init__(self, config: ApiConfig, client: httpx.Client | None = None) -> None:
34+
def __init__(self,
35+
config: ApiConfig,
36+
client: httpx.Client | None = None,
37+
models_api=None,
38+
resources_api=None) -> None:
3539
"""Initialize the annotations API handler.
3640
3741
Args:
3842
config: API configuration containing base URL, API key, etc.
3943
client: Optional HTTP client instance. If None, a new one will be created.
4044
"""
45+
from .resources_api import ResourcesApi
4146
super().__init__(config, Annotation, 'annotations', client)
42-
self._models_api = ModelsApi(config, client=client)
47+
self._models_api = ModelsApi(config, client=client) if models_api is None else models_api
48+
self._resources_api = ResourcesApi(config, client=client, annotations_api=self) if resources_api is None else resources_api
4349

4450
def get_list(self,
4551
resource: str | Resource | None = None,
@@ -934,7 +940,7 @@ def _create_geometry_annotation(self,
934940

935941
def download_file(self,
936942
annotation: str | Annotation,
937-
fpath_out: str | Path | None = None) -> bytes:
943+
fpath_out: str | os.PathLike | None = None) -> bytes:
938944
"""
939945
Download the segmentation file for a given resource and annotation.
940946
@@ -954,7 +960,7 @@ def download_file(self,
954960

955961
resp = self._make_request('GET', f'/annotations/{resource_id}/annotations/{annotation_id}/file')
956962
if fpath_out:
957-
with open(str(fpath_out), 'wb') as f:
963+
with open(fpath_out, 'wb') as f:
958964
f.write(resp.content)
959965
return resp.content
960966

@@ -1059,3 +1065,6 @@ def patch(self,
10591065
respdata = resp.json()
10601066
if isinstance(respdata, dict) and 'error' in respdata:
10611067
raise DatamintException(respdata['error'])
1068+
1069+
def _get_resource(self, ann: Annotation) -> Resource:
1070+
return self._resources_api.get_by_id(ann.resource_id)

datamint/api/endpoints/projects_api.py

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ def get_project_resources(self, project: Project | str) -> list[Resource]:
3030
"""
3131
response = self._get_child_entities(project, 'resources')
3232
resources_data = response.json()
33-
return [Resource(**item) for item in resources_data]
33+
resources = [Resource(**item) for item in resources_data]
34+
return resources
3435

3536
def create(self,
3637
name: str,
@@ -148,47 +149,48 @@ def add_resources(self,
148149
self._make_entity_request('POST', project_id, add_path='resources',
149150
json={'resource_ids_to_add': resources_ids, 'all_files_selected': False})
150151

151-
def download(self, project: str | Project,
152-
outpath: str,
153-
all_annotations: bool = False,
154-
include_unannotated: bool = False,
155-
) -> None:
156-
"""Download a project by its id.
157-
158-
Args:
159-
project: The project id or Project instance.
160-
outpath: The path to save the project zip file.
161-
all_annotations: Whether to include all annotations in the downloaded dataset,
162-
even those not made by the provided project.
163-
include_unannotated: Whether to include unannotated resources in the downloaded dataset.
164-
"""
165-
from tqdm.auto import tqdm
166-
params = {'all_annotations': all_annotations}
167-
if include_unannotated:
168-
params['include_unannotated'] = include_unannotated
169-
170-
project_id = self._entid(project)
171-
with self._stream_entity_request('GET', project_id,
172-
add_path='annotated_dataset',
173-
params=params) as response:
174-
total_size = int(response.headers.get('content-length', 0))
175-
if total_size == 0:
176-
total_size = None
177-
with tqdm(total=total_size, unit='B', unit_scale=True) as progress_bar:
178-
with open(outpath, 'wb') as file:
179-
for data in response.iter_bytes(1024):
180-
progress_bar.update(len(data))
181-
file.write(data)
152+
# def download(self, project: str | Project,
153+
# outpath: str,
154+
# all_annotations: bool = False,
155+
# include_unannotated: bool = False,
156+
# ) -> None:
157+
# """Download a project by its id.
158+
159+
# Args:
160+
# project: The project id or Project instance.
161+
# outpath: The path to save the project zip file.
162+
# all_annotations: Whether to include all annotations in the downloaded dataset,
163+
# even those not made by the provided project.
164+
# include_unannotated: Whether to include unannotated resources in the downloaded dataset.
165+
# """
166+
# from tqdm.auto import tqdm
167+
# params = {'all_annotations': all_annotations}
168+
# if include_unannotated:
169+
# params['include_unannotated'] = include_unannotated
170+
171+
# project_id = self._entid(project)
172+
# with self._stream_entity_request('GET', project_id,
173+
# add_path='annotated_dataset',
174+
# params=params) as response:
175+
# total_size = int(response.headers.get('content-length', 0))
176+
# if total_size == 0:
177+
# total_size = None
178+
# with tqdm(total=total_size, unit='B', unit_scale=True) as progress_bar:
179+
# with open(outpath, 'wb') as file:
180+
# for data in response.iter_bytes(1024):
181+
# progress_bar.update(len(data))
182+
# file.write(data)
182183

183184
def set_work_status(self,
184-
resource: str | Resource,
185185
project: str | Project,
186+
resource: str | Resource,
186187
status: Literal['opened', 'annotated', 'closed']) -> None:
187188
"""
188189
Set the status of a resource.
189190
190191
Args:
191-
annotation: The annotation unique id or an annotation object.
192+
project: The project unique id or a project object.
193+
resource: The resource unique id or a resource object.
192194
status: The new status to set.
193195
"""
194196
resource_id = self._entid(resource)

0 commit comments

Comments
 (0)