datamint 2.3.1__tar.gz → 2.3.3__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.

Potentially problematic release.


This version of datamint might be problematic. Click here for more details.

Files changed (56) hide show
  1. {datamint-2.3.1 → datamint-2.3.3}/PKG-INFO +2 -1
  2. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/base_api.py +66 -8
  3. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/client.py +16 -5
  4. datamint-2.3.3/datamint/api/dto/__init__.py +18 -0
  5. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/__init__.py +2 -0
  6. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/annotations_api.py +47 -7
  7. datamint-2.3.3/datamint/api/endpoints/annotationsets_api.py +11 -0
  8. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/projects_api.py +36 -34
  9. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/resources_api.py +75 -28
  10. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/entity_base_api.py +11 -43
  11. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/dto/annotation_dto.py +6 -2
  12. {datamint-2.3.1 → datamint-2.3.3}/datamint/configs.py +6 -0
  13. {datamint-2.3.1 → datamint-2.3.3}/datamint/dataset/base_dataset.py +18 -12
  14. {datamint-2.3.1 → datamint-2.3.3}/datamint/dataset/dataset.py +2 -2
  15. {datamint-2.3.1 → datamint-2.3.3}/datamint/entities/__init__.py +4 -2
  16. {datamint-2.3.1 → datamint-2.3.3}/datamint/entities/annotation.py +74 -4
  17. {datamint-2.3.1 → datamint-2.3.3}/datamint/entities/base_entity.py +47 -6
  18. datamint-2.3.3/datamint/entities/cache_manager.py +302 -0
  19. datamint-2.3.3/datamint/entities/datasetinfo.py +129 -0
  20. {datamint-2.3.1 → datamint-2.3.3}/datamint/entities/project.py +47 -6
  21. datamint-2.3.3/datamint/entities/resource.py +257 -0
  22. datamint-2.3.3/datamint/types.py +17 -0
  23. {datamint-2.3.1 → datamint-2.3.3}/pyproject.toml +2 -1
  24. datamint-2.3.1/datamint/api/dto/__init__.py +0 -10
  25. datamint-2.3.1/datamint/entities/datasetinfo.py +0 -22
  26. datamint-2.3.1/datamint/entities/resource.py +0 -130
  27. {datamint-2.3.1 → datamint-2.3.3}/README.md +0 -0
  28. {datamint-2.3.1 → datamint-2.3.3}/datamint/__init__.py +0 -0
  29. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/__init__.py +0 -0
  30. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/channels_api.py +0 -0
  31. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/datasetsinfo_api.py +0 -0
  32. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/models_api.py +0 -0
  33. {datamint-2.3.1 → datamint-2.3.3}/datamint/api/endpoints/users_api.py +0 -0
  34. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/annotation_api_handler.py +0 -0
  35. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/api_handler.py +0 -0
  36. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/base_api_handler.py +0 -0
  37. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/dto/__init__.py +0 -0
  38. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/exp_api_handler.py +0 -0
  39. {datamint-2.3.1 → datamint-2.3.3}/datamint/apihandler/root_api_handler.py +0 -0
  40. {datamint-2.3.1 → datamint-2.3.3}/datamint/client_cmd_tools/__init__.py +0 -0
  41. {datamint-2.3.1 → datamint-2.3.3}/datamint/client_cmd_tools/datamint_config.py +0 -0
  42. {datamint-2.3.1 → datamint-2.3.3}/datamint/client_cmd_tools/datamint_upload.py +0 -0
  43. {datamint-2.3.1 → datamint-2.3.3}/datamint/dataset/__init__.py +0 -0
  44. {datamint-2.3.1 → datamint-2.3.3}/datamint/dataset/annotation.py +0 -0
  45. {datamint-2.3.1 → datamint-2.3.3}/datamint/entities/channel.py +0 -0
  46. {datamint-2.3.1 → datamint-2.3.3}/datamint/entities/user.py +0 -0
  47. {datamint-2.3.1 → datamint-2.3.3}/datamint/examples/__init__.py +0 -0
  48. {datamint-2.3.1 → datamint-2.3.3}/datamint/examples/example_projects.py +0 -0
  49. {datamint-2.3.1 → datamint-2.3.3}/datamint/exceptions.py +0 -0
  50. {datamint-2.3.1 → datamint-2.3.3}/datamint/experiment/__init__.py +0 -0
  51. {datamint-2.3.1 → datamint-2.3.3}/datamint/experiment/_patcher.py +0 -0
  52. {datamint-2.3.1 → datamint-2.3.3}/datamint/experiment/experiment.py +0 -0
  53. {datamint-2.3.1 → datamint-2.3.3}/datamint/logging.yaml +0 -0
  54. {datamint-2.3.1 → datamint-2.3.3}/datamint/utils/logging_utils.py +0 -0
  55. {datamint-2.3.1 → datamint-2.3.3}/datamint/utils/torchmetrics.py +0 -0
  56. {datamint-2.3.1 → datamint-2.3.3}/datamint/utils/visualization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datamint
3
- Version: 2.3.1
3
+ Version: 2.3.3
4
4
  Summary: A library for interacting with the Datamint API, designed for efficient data management, processing and Deep Learning workflows.
5
5
  Requires-Python: >=3.10
6
6
  Classifier: Programming Language :: Python :: 3
@@ -15,6 +15,7 @@ Requires-Dist: Deprecated (>=1.2.0)
15
15
  Requires-Dist: aiohttp (>=3.0.0,<4.0.0)
16
16
  Requires-Dist: aioresponses (>=0.7.8,<0.8.0) ; extra == "dev"
17
17
  Requires-Dist: albumentations (>=2.0.0)
18
+ Requires-Dist: backports-strenum ; python_version < "3.11"
18
19
  Requires-Dist: datamintapi (==0.0.*)
19
20
  Requires-Dist: httpx
20
21
  Requires-Dist: humanize (>=4.0.0,<5.0.0)
@@ -1,27 +1,28 @@
1
1
  import logging
2
- from typing import Any, Generator, AsyncGenerator, Sequence
2
+ from typing import Any, Generator, AsyncGenerator, Sequence, TYPE_CHECKING
3
3
  import httpx
4
4
  from dataclasses import dataclass
5
5
  from datamint.exceptions import DatamintException, ResourceNotFoundError
6
+ from datamint.types import ImagingData
6
7
  import aiohttp
7
8
  import json
8
- import pydicom.dataset
9
9
  from PIL import Image
10
10
  import cv2
11
11
  import nibabel as nib
12
- from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage
13
12
  from io import BytesIO
14
13
  import gzip
15
14
  import contextlib
16
15
  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
18
20
 
19
21
  logger = logging.getLogger(__name__)
20
22
 
21
23
  # Generic type for entities
22
24
  _PAGE_LIMIT = 5000
23
25
 
24
-
25
26
  @dataclass
26
27
  class ApiConfig:
27
28
  """Configuration for API client.
@@ -37,6 +38,15 @@ class ApiConfig:
37
38
  timeout: float = 30.0
38
39
  max_retries: int = 3
39
40
 
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
+
40
50
 
41
51
  class BaseApi:
42
52
  """Base class for all API endpoint handlers."""
@@ -53,6 +63,7 @@ class BaseApi:
53
63
  self.config = config
54
64
  self.client = client or self._create_client()
55
65
  self.semaphore = asyncio.Semaphore(20)
66
+ self._api_instance: 'Api | None' = None # Injected by Api class
56
67
 
57
68
  def _create_client(self) -> httpx.Client:
58
69
  """Create and configure HTTP client with authentication and timeouts."""
@@ -399,10 +410,30 @@ class BaseApi:
399
410
 
400
411
  @staticmethod
401
412
  def convert_format(bytes_array: bytes,
402
- mimetype: str,
413
+ mimetype: str | None = None,
403
414
  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.")
406
437
  content_io = BytesIO(bytes_array)
407
438
  if mimetype.endswith('/dicom'):
408
439
  return pydicom.dcmread(content_io)
@@ -429,3 +460,30 @@ class BaseApi:
429
460
  return nib.Nifti1Image.from_stream(f)
430
461
 
431
462
  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
@@ -1,6 +1,9 @@
1
1
  from typing import Optional
2
- from .base_api import ApiConfig
3
- from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi, UsersApi, DatasetsInfoApi, ModelsApi
2
+ from .base_api import ApiConfig, BaseApi
3
+ from .endpoints import (ProjectsApi, ResourcesApi, AnnotationsApi,
4
+ ChannelsApi, UsersApi, DatasetsInfoApi, ModelsApi,
5
+ AnnotationSetsApi
6
+ )
4
7
  import datamint.configs
5
8
  from datamint.exceptions import DatamintException
6
9
 
@@ -10,14 +13,15 @@ class Api:
10
13
  DEFAULT_SERVER_URL = 'https://api.datamint.io'
11
14
  DATAMINT_API_VENV_NAME = datamint.configs.ENV_VARS[datamint.configs.APIKEY_KEY]
12
15
 
13
- _API_MAP = {
16
+ _API_MAP : dict[str, type[BaseApi]] = {
14
17
  'projects': ProjectsApi,
15
18
  'resources': ResourcesApi,
16
19
  'annotations': AnnotationsApi,
17
20
  'channels': ChannelsApi,
18
21
  'users': UsersApi,
19
22
  'datasets': DatasetsInfoApi,
20
- 'models': ModelsApi
23
+ 'models': ModelsApi,
24
+ 'annotationsets': AnnotationSetsApi,
21
25
  }
22
26
 
23
27
  def __init__(self,
@@ -66,7 +70,10 @@ class Api:
66
70
  def _get_endpoint(self, name: str):
67
71
  if name not in self._endpoints:
68
72
  api_class = self._API_MAP[name]
69
- 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
70
77
  return self._endpoints[name]
71
78
 
72
79
  @property
@@ -97,3 +104,7 @@ class Api:
97
104
  @property
98
105
  def models(self) -> ModelsApi:
99
106
  return self._get_endpoint('models')
107
+
108
+ @property
109
+ def annotationsets(self) -> AnnotationSetsApi:
110
+ return self._get_endpoint('annotationsets')
@@ -0,0 +1,18 @@
1
+ from datamint.apihandler.dto import annotation_dto
2
+ from datamint.apihandler.dto.annotation_dto import (
3
+ AnnotationType, CreateAnnotationDto,
4
+ Geometry, BoxGeometry, LineGeometry,
5
+ CoordinateSystem
6
+ )
7
+
8
+ __all__ = [
9
+ "annotation_dto",
10
+ "AnnotationType",
11
+ "CreateAnnotationDto",
12
+ "Geometry",
13
+ "BoxGeometry",
14
+ "LineGeometry",
15
+ "CoordinateSystem"
16
+ "LineGeometry",
17
+ "CoordinateSystem"
18
+ ]
@@ -7,6 +7,7 @@ from .resources_api import ResourcesApi
7
7
  from .users_api import UsersApi
8
8
  from .datasetsinfo_api import DatasetsInfoApi
9
9
  from .models_api import ModelsApi
10
+ from .annotationsets_api import AnnotationSetsApi
10
11
 
11
12
  __all__ = [
12
13
  'AnnotationsApi',
@@ -16,4 +17,5 @@ __all__ = [
16
17
  'UsersApi',
17
18
  'DatasetsInfoApi',
18
19
  'ModelsApi',
20
+ 'AnnotationSetsApi',
19
21
  ]
@@ -7,7 +7,7 @@ from .models_api import ModelsApi
7
7
  from datamint.entities.annotation import Annotation
8
8
  from datamint.entities.resource import Resource
9
9
  from datamint.entities.project import Project
10
- from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry
10
+ from datamint.api.dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry
11
11
  import numpy as np
12
12
  import os
13
13
  import aiohttp
@@ -31,15 +31,21 @@ MAX_NUMBER_DISTINCT_COLORS = 2048 # Maximum number of distinct colors in a segm
31
31
  class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotation]):
32
32
  """API handler for annotation-related endpoints."""
33
33
 
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:
35
39
  """Initialize the annotations API handler.
36
40
 
37
41
  Args:
38
42
  config: API configuration containing base URL, API key, etc.
39
43
  client: Optional HTTP client instance. If None, a new one will be created.
40
44
  """
45
+ from .resources_api import ResourcesApi
41
46
  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
43
49
 
44
50
  def get_list(self,
45
51
  resource: str | Resource | None = None,
@@ -377,7 +383,7 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotati
377
383
 
378
384
  annotations = [annotation_dto] if isinstance(annotation_dto, CreateAnnotationDto) else annotation_dto
379
385
  annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations]
380
- resource_id = resource.id if isinstance(resource, Resource) else resource
386
+ resource_id = self._entid(resource)
381
387
  respdata = self._make_request('POST',
382
388
  f'{self.endpoint_base}/{resource_id}/annotations',
383
389
  json=annotations).json()
@@ -467,7 +473,7 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotati
467
473
  raise ValueError(f"AI model with name '{ai_model_name}' not found. ")
468
474
  raise ValueError(f"AI model with name '{ai_model_name}' not found. " +
469
475
  f"Available models: {available_models}")
470
- model_id = model_id['id']
476
+ model_id = model_id['name']
471
477
 
472
478
  # Handle NIfTI files specially - upload as single volume
473
479
  if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')):
@@ -780,6 +786,37 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotati
780
786
  img_bytes.seek(0)
781
787
  yield img_bytes
782
788
 
789
+ def create_image_classification(self,
790
+ resource: str | Resource,
791
+ identifier: str,
792
+ value: str,
793
+ imported_from: str | None = None,
794
+ model_id: str | None = None,
795
+ ) -> str:
796
+ """
797
+ Create an image-level classification annotation.
798
+
799
+ Args:
800
+ resource: The resource unique id or Resource instance.
801
+ identifier: The annotation identifier/label.
802
+ value: The classification value.
803
+ imported_from: The imported from source value.
804
+ model_id: The model unique id.
805
+
806
+ Returns:
807
+ The id of the created annotation.
808
+ """
809
+ annotation_dto = CreateAnnotationDto(
810
+ type=AnnotationType.CATEGORY,
811
+ identifier=identifier,
812
+ scope='image',
813
+ value=value,
814
+ imported_from=imported_from,
815
+ model_id=model_id
816
+ )
817
+
818
+ return self.create(resource, annotation_dto)
819
+
783
820
  def add_line_annotation(self,
784
821
  point1: tuple[int, int] | tuple[float, float, float],
785
822
  point2: tuple[int, int] | tuple[float, float, float],
@@ -903,7 +940,7 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotati
903
940
 
904
941
  def download_file(self,
905
942
  annotation: str | Annotation,
906
- fpath_out: str | Path | None = None) -> bytes:
943
+ fpath_out: str | os.PathLike | None = None) -> bytes:
907
944
  """
908
945
  Download the segmentation file for a given resource and annotation.
909
946
 
@@ -923,7 +960,7 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotati
923
960
 
924
961
  resp = self._make_request('GET', f'/annotations/{resource_id}/annotations/{annotation_id}/file')
925
962
  if fpath_out:
926
- with open(str(fpath_out), 'wb') as f:
963
+ with open(fpath_out, 'wb') as f:
927
964
  f.write(resp.content)
928
965
  return resp.content
929
966
 
@@ -1028,3 +1065,6 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotati
1028
1065
  respdata = resp.json()
1029
1066
  if isinstance(respdata, dict) and 'error' in respdata:
1030
1067
  raise DatamintException(respdata['error'])
1068
+
1069
+ def _get_resource(self, ann: Annotation) -> Resource:
1070
+ return self._resources_api.get_by_id(ann.resource_id)
@@ -0,0 +1,11 @@
1
+ from datamint.api.base_api import BaseApi
2
+ import logging
3
+
4
+ _LOGGER = logging.getLogger(__name__)
5
+
6
+
7
+ class AnnotationSetsApi(BaseApi):
8
+ def get_segmentation_group(self, annotation_set_id: str) -> dict:
9
+ """Get the segmentation group for a given annotation set ID."""
10
+ endpoint = f"/annotationsets/{annotation_set_id}/segmentation-group"
11
+ return self._make_request("GET", endpoint).json()
@@ -30,7 +30,8 @@ class ProjectsApi(CRUDEntityApi[Project]):
30
30
  """
31
31
  response = self._get_child_entities(project, 'resources')
32
32
  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
34
35
 
35
36
  def create(self,
36
37
  name: str,
@@ -148,47 +149,48 @@ class ProjectsApi(CRUDEntityApi[Project]):
148
149
  self._make_entity_request('POST', project_id, add_path='resources',
149
150
  json={'resource_ids_to_add': resources_ids, 'all_files_selected': False})
150
151
 
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)
182
183
 
183
184
  def set_work_status(self,
184
- resource: str | Resource,
185
185
  project: str | Project,
186
+ resource: str | Resource,
186
187
  status: Literal['opened', 'annotated', 'closed']) -> None:
187
188
  """
188
189
  Set the status of a resource.
189
190
 
190
191
  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.
192
194
  status: The new status to set.
193
195
  """
194
196
  resource_id = self._entid(resource)
@@ -1,9 +1,8 @@
1
1
  from typing import Any, Optional, Sequence, TypeAlias, Literal, IO
2
2
  from ..base_api import ApiConfig, BaseApi
3
- from ..entity_base_api import EntityBaseApi, CreatableEntityApi, DeletableEntityApi
4
- from .annotations_api import AnnotationsApi
5
- from .projects_api import ProjectsApi
3
+ from ..entity_base_api import CreatableEntityApi, DeletableEntityApi
6
4
  from datamint.entities.resource import Resource
5
+ from datamint.entities.project import Project
7
6
  from datamint.entities.annotation import Annotation
8
7
  from datamint.exceptions import DatamintException, ResourceNotFoundError
9
8
  import httpx
@@ -23,10 +22,9 @@ import asyncio
23
22
  import aiohttp
24
23
  from pathlib import Path
25
24
  import nest_asyncio # For running asyncio in jupyter notebooks
26
- import cv2
27
25
  from PIL import Image
28
- from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage
29
26
  import io
27
+ from datamint.types import ImagingData
30
28
 
31
29
 
32
30
  _LOGGER = logging.getLogger(__name__)
@@ -54,17 +52,25 @@ def _open_io(file_path: str | Path | IO, mode: str = 'rb') -> IO:
54
52
  class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
55
53
  """API handler for resource-related endpoints."""
56
54
 
57
- def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None:
55
+ def __init__(self,
56
+ config: ApiConfig,
57
+ client: Optional[httpx.Client] = None,
58
+ annotations_api=None,
59
+ projects_api=None
60
+ ) -> None:
58
61
  """Initialize the resources API handler.
59
62
 
60
63
  Args:
61
64
  config: API configuration containing base URL, API key, etc.
62
65
  client: Optional HTTP client instance. If None, a new one will be created.
63
66
  """
67
+ from .annotations_api import AnnotationsApi
68
+ from .projects_api import ProjectsApi
64
69
  super().__init__(config, Resource, 'resources', client)
65
70
  nest_asyncio.apply()
66
- self.annotations_api = AnnotationsApi(config, client)
67
- self.projects_api = ProjectsApi(config, client)
71
+ self.annotations_api = AnnotationsApi(
72
+ config, client, resources_api=self) if annotations_api is None else annotations_api
73
+ self.projects_api = ProjectsApi(config, client) if projects_api is None else projects_api
68
74
 
69
75
  def get_list(self,
70
76
  status: Optional[ResourceStatus] = None,
@@ -710,21 +716,6 @@ class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
710
716
  # This should not happen with single file uploads, but handle it just in case
711
717
  raise DatamintException(f"Unexpected return from upload_resources: {type(result)} | {result}")
712
718
 
713
- def _determine_mimetype(self,
714
- content,
715
- resource: str | Resource) -> tuple[str | None, str | None]:
716
- # Determine mimetype from file content
717
- mimetype_list, ext = guess_typez(content, use_magic=True)
718
- mimetype = mimetype_list[-1]
719
-
720
- # get mimetype from resource info if not detected
721
- if mimetype is None or mimetype == DEFAULT_MIME_TYPE:
722
- if not isinstance(resource, Resource):
723
- resource = self.get_by_id(resource)
724
- mimetype = resource.mimetype or mimetype
725
-
726
- return mimetype, ext
727
-
728
719
  async def _async_download_file(self,
729
720
  resource: str | Resource,
730
721
  save_path: str | Path,
@@ -761,8 +752,8 @@ class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
761
752
  f.write(data_bytes)
762
753
 
763
754
  # Determine mimetype from file content
764
- mimetype, ext = self._determine_mimetype(content=data_bytes,
765
- resource=resource)
755
+ mimetype, ext = BaseApi._determine_mimetype(content=data_bytes,
756
+ declared_mimetype=resource.mimetype if isinstance(resource, Resource) else None)
766
757
 
767
758
  # Generate final path with extension if needed
768
759
  if mimetype is not None and mimetype != DEFAULT_MIME_TYPE:
@@ -850,7 +841,7 @@ class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
850
841
  save_path: Optional[str] = None,
851
842
  auto_convert: bool = True,
852
843
  add_extension: bool = False
853
- ) -> bytes | pydicom.Dataset | Image.Image | cv2.VideoCapture | nib_FileBasedImage | tuple[Any, str]:
844
+ ) -> ImagingData | tuple[ImagingData, str] | bytes:
854
845
  """
855
846
  Download a resource file.
856
847
 
@@ -888,8 +879,8 @@ class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
888
879
  mimetype = None
889
880
  ext = None
890
881
  if auto_convert or add_extension:
891
- mimetype, ext = self._determine_mimetype(content=response.content,
892
- resource=resource)
882
+ mimetype, ext = BaseApi._determine_mimetype(content=response.content,
883
+ declared_mimetype=resource.mimetype if isinstance(resource, Resource) else None)
893
884
  if auto_convert:
894
885
  if mimetype is None:
895
886
  _LOGGER.warning("Could not determine mimetype. Returning a bytes array.")
@@ -997,6 +988,12 @@ class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
997
988
  resource: str | Resource,
998
989
  tags: Sequence[str],
999
990
  ):
991
+ """
992
+ Set tags for a resource, IMPORTANT: This replaces all existing tags.
993
+ Args:
994
+ resource: The resource unique id or Resource object.
995
+ tags: The tags to set.
996
+ """
1000
997
  data = {'tags': tags}
1001
998
  resource_id = self._entid(resource)
1002
999
 
@@ -1005,3 +1002,53 @@ class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]):
1005
1002
  add_path='tags',
1006
1003
  json=data)
1007
1004
  return response
1005
+
1006
+ # def get_projects(self, resource: Resource) -> Sequence[Project]:
1007
+ # """
1008
+ # Get all projects this resource belongs to.
1009
+
1010
+ # Args:
1011
+ # resource: The Resource instance.
1012
+
1013
+ # Returns:
1014
+ # List of Project instances
1015
+ # """
1016
+ # resource._ensure_attr('projects')
1017
+ # proj_ids = [p['id'] for p in resource.projects]
1018
+ # return [proj for proj in self.projects_api.get_all() if proj.id in proj_ids]
1019
+
1020
+ def add_tags(self,
1021
+ resource: str | Resource,
1022
+ tags: Sequence[str],
1023
+ ):
1024
+ """
1025
+ Add tags to a resource, IMPORTANT: This appends to existing tags.
1026
+ Args:
1027
+ resource: The resource unique id or Resource object.
1028
+ tags: The tags to add.
1029
+ """
1030
+ if isinstance(resource, str):
1031
+ resource = self.get_by_id(resource)
1032
+ old_tags = resource.tags if resource.tags is not None else []
1033
+ return self.set_tags(resource, old_tags + list(tags))
1034
+
1035
+ def bulk_delete(self, entities: Sequence[str | Resource]) -> None:
1036
+ """Delete multiple entities. Faster than deleting them one by one.
1037
+
1038
+ Args:
1039
+ entities: Sequence of unique identifiers for the entities to delete or the entity instances themselves.
1040
+
1041
+ Raises:
1042
+ httpx.HTTPStatusError: If deletion fails or any entity not found
1043
+ """
1044
+ from math import ceil
1045
+
1046
+ resources_ids = [self._entid(ent) for ent in entities]
1047
+ if len(resources_ids) == 0:
1048
+ return
1049
+ batch_size = 200
1050
+ for i in range(0, ceil(len(resources_ids)/batch_size)):
1051
+ batch_ids = resources_ids[i*batch_size:(i+1)*batch_size]
1052
+ self._make_request('DELETE',
1053
+ f'{self.endpoint_base}',
1054
+ params={'resource_ids': ','.join(batch_ids)})