From 6f2a75b76034e650faf438abd08001052f349cfe Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Fri, 5 Sep 2025 17:45:39 -0300 Subject: [PATCH 01/13] base classes --- datamint/api/base_api.py | 271 +++++++++++++++++++++++++++++++ datamint/entities/base_entity.py | 37 +++++ pyproject.toml | 2 + 3 files changed, 310 insertions(+) create mode 100644 datamint/api/base_api.py create mode 100644 datamint/entities/base_entity.py diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py new file mode 100644 index 00000000..ccd8ab93 --- /dev/null +++ b/datamint/api/base_api.py @@ -0,0 +1,271 @@ +import logging +from typing import Any, Optional, TypeVar, Generic, Type, Sequence, Generator +import httpx +from dataclasses import dataclass +from datamint.entities.base_entity import BaseEntity + +logger = logging.getLogger(__name__) + +# Generic type for entities +T = TypeVar('T', bound=BaseEntity) +_PAGE_LIMIT = 5000 + + +@dataclass +class ApiConfig: + """Configuration for API client.""" + base_url: str + api_key: Optional[str] = None + timeout: float = 30.0 + max_retries: int = 3 + + +class BaseApi: + """Base class for all API endpoint handlers.""" + + def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None: + """Initialize the base API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + self.config = config + self.client = client or self._create_client() + + def _create_client(self) -> httpx.Client: + """Create and configure HTTP client with authentication and timeouts.""" + headers = {"Content-Type": "application/json"} + if self.config.api_key: + headers["apikey"] = self.config.api_key + + return httpx.Client( + base_url=self.config.base_url, + headers=headers, + timeout=self.config.timeout + ) + + def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: + """Make HTTP request with error handling and retries. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + **kwargs: Additional arguments for the request + + Returns: + HTTP response object + + Raises: + httpx.HTTPStatusError: If the request fails + """ + url = endpoint.lstrip('/') # Remove leading slash for httpx + + try: + response = self.client.request(method, url, **kwargs) + response.raise_for_status() + return response + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error {e.response.status_code} for {method} {endpoint}: {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Request error for {method} {endpoint}: {e}") + raise + + def _make_request_with_pagination(self, + method: str, + endpoint: str, + return_field: str | None = None, + **kwargs + ) -> Generator[tuple[httpx.Response, dict | list | str], None, None]: + offset = 0 + params = kwargs.get('params', {}) + while True: + params['offset'] = offset + params['limit'] = _PAGE_LIMIT + + response = self._make_request(method=method, + endpoint=endpoint, + **kwargs) + items = self._convert_response(response.json(), return_field=return_field) + yield response, items + + if len(items) < _PAGE_LIMIT: + break + + offset += _PAGE_LIMIT + + def _convert_response(self, + data: dict | list, + return_field: str | None = None) -> list | dict | str: + + if isinstance(data, list): + items = data + else: + if 'data' in data: + items = data['data'] + elif 'items' in data: + items = data['items'] + else: + return data + if return_field is not None: + if 'totalCount' in data and len(items) == 1 and return_field in items[0]: + items = items[0][return_field] + return items + + +class EntityBaseApi(BaseApi, Generic[T]): + """Base API handler for entity-related endpoints with CRUD operations. + + This class provides a template for API handlers that work with specific + entity types, offering common CRUD operations with proper typing. + + Type Parameters: + T: The entity type this API handler manages (must extend BaseEntity) + """ + + def __init__(self, config: ApiConfig, + entity_class: Type[T], + endpoint_base: str, + client: Optional[httpx.Client] = None) -> None: + """Initialize the entity API handler. + + Args: + config: API configuration containing base URL, API key, etc. + entity_class: The entity class this handler manages + endpoint_base: Base endpoint path (e.g., 'projects', 'annotations') + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, client) + self.entity_class = entity_class + self.endpoint_base = endpoint_base.strip('/') + + def get_list(self, **kwargs) -> Sequence[T]: + """Get entities with optional filtering. + + Returns: + List of entity instances + + Raises: + httpx.HTTPStatusError: If the request fails + """ + params = dict(kwargs) + + # Remove None values from the payload. + for k in list(params.keys()): + if params[k] is None: + del params[k] + + # response = self._make_request('GET', f'/{self.endpoint_base}', + # params=params) + # items = self._convert_response(response.json()) + items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', + return_field=self.endpoint_base, + params=params) + + + return [self.entity_class(**item) for resp,items in items_gen for item in items] + + def get_all(self) -> Sequence[T]: + """Get all entities with optional pagination and filtering. + + Returns: + List of entity instances + + Raises: + httpx.HTTPStatusError: If the request fails + """ + return self.get_list() + + def get_by_id(self, entity_id: str) -> T: + """Get a specific entity by its ID. + + Args: + entity_id: Unique identifier for the entity + + Returns: + Entity instance + + Raises: + httpx.HTTPStatusError: If the entity is not found or request fails + """ + response = self._make_request('GET', f'/{self.endpoint_base}/{entity_id}') + return self.entity_class(**response.json()) + + def create(self, entity_data: dict[str, Any]) -> T: + """Create a new entity. + + Args: + entity_data: Dictionary containing entity data for creation + + Returns: + Created entity instance + + Raises: + httpx.HTTPStatusError: If creation fails + """ + response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) + return self.entity_class(**response.json()) + + def update(self, entity_id: str, entity_data: dict[str, Any]) -> T: + """Update an existing entity. + + Args: + entity_id: Unique identifier for the entity + entity_data: Dictionary containing updated entity data + + Returns: + Updated entity instance + + Raises: + httpx.HTTPStatusError: If update fails or entity not found + """ + response = self._make_request('PUT', f'/{self.endpoint_base}/{entity_id}', json=entity_data) + return self.entity_class(**response.json()) + + def delete(self, entity_id: str) -> None: + """Delete an entity by its ID. + + Args: + entity_id: Unique identifier for the entity to delete + + Raises: + httpx.HTTPStatusError: If deletion fails or entity not found + """ + self._make_request('DELETE', f'/{self.endpoint_base}/{entity_id}') + + # def bulk_create(self, entities_data: list[dict[str, Any]]) -> list[T]: + # """Create multiple entities in a single request. + + # Args: + # entities_data: List of dictionaries containing entity data + + # Returns: + # List of created entity instances + + # Raises: + # httpx.HTTPStatusError: If bulk creation fails + # """ + # payload = {'items': entities_data} # Common bulk API format + # response = self._make_request('POST', f'/{self.endpoint_base}/bulk', json=payload) + # data = response.json() + + # # Handle response format - may be direct list or wrapped + # items = data if isinstance(data, list) else data.get('items', []) + # return [self.entity_class(**item) for item in items] + + # def count(self, **params: Any) -> int: + # """Get the total count of entities matching the given filters. + + # Args: + # **params: Query parameters for filtering + + # Returns: + # Total count of matching entities + + # Raises: + # httpx.HTTPStatusError: If the request fails + # """ + # response = self._make_request('GET', f'/{self.endpoint_base}/count', params=params) + # data = response.json() + # return data.get('count', 0) if isinstance(data, dict) else data diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py new file mode 100644 index 00000000..0f4e8795 --- /dev/null +++ b/datamint/entities/base_entity.py @@ -0,0 +1,37 @@ +import logging +import sys +from typing import Any +from pydantic import ConfigDict, BaseModel + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self +_LOGGER = logging.getLogger(__name__) + +MISSING_FIELD = 'MISSING_FIELD' # Used when a field is sometimes missing for one endpoint but not on another endpoint + + +class BaseEntity(BaseModel): + """ + Base class for all entities in the Datamint system. + + This class provides common functionality for all entities, such as + serialization and deserialization from dictionaries, as well as + handling unknown fields gracefully. + """ + + model_config = ConfigDict(extra='allow') # Allow extra fields not defined in the model + + def asdict(self) -> dict[str, Any]: + """Convert the entity to a dictionary, including unknown fields.""" + return self.model_dump() + + def asjson(self) -> str: + """Convert the entity to a JSON string, including unknown fields.""" + return self.model_dump_json() + + def model_post_init(self, __context: Any) -> None: + if self.__pydantic_extra__: + _LOGGER.warning(f"Unknown fields found in {self.__class__.__name__} " + f"fields: {self.__pydantic_extra__.keys()}. ") diff --git a/pyproject.toml b/pyproject.toml index 4d61cb43..0c84431a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ lightning = "*" albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" medimgkit = ">=0.5.0" +typing_extensions = ">=4.0.0" +pydantic = ">=2.6.4" # For compatibility with the datamintapi package datamintapi = "0.0.*" # Extra dependencies for docs From 6651a4ebc7b4157df13840740eb211415753a4fd Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 8 Sep 2025 17:05:48 -0300 Subject: [PATCH 02/13] Refactor API error handling and enhance entity management - Added streaming request support in BaseApi for large file handling. - Improved error handling with ResourceNotFoundError in API requests. - Updated ApiConfig to include detailed attribute documentation. - Enhanced entity request methods for better clarity and functionality. - Adjusted entity serialization methods to suppress warnings for unknown fields. --- datamint/api/base_api.py | 165 ++++++++++++++++++------ datamint/apihandler/base_api_handler.py | 29 +---- datamint/entities/base_entity.py | 12 +- datamint/exceptions.py | 28 +++- 4 files changed, 163 insertions(+), 71 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index ccd8ab93..7ed6a25a 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -1,8 +1,9 @@ import logging -from typing import Any, Optional, TypeVar, Generic, Type, Sequence, Generator +from typing import Any, TypeVar, Generic, Type, Sequence, Generator import httpx from dataclasses import dataclass from datamint.entities.base_entity import BaseEntity +from datamint.exceptions import DatamintException, ResourceNotFoundError logger = logging.getLogger(__name__) @@ -13,9 +14,16 @@ @dataclass class ApiConfig: - """Configuration for API client.""" + """Configuration for API client. + + Attributes: + base_url: Base URL for the API. + api_key: Optional API key for authentication. + timeout: Request timeout in seconds. + max_retries: Maximum number of retries for requests. + """ base_url: str - api_key: Optional[str] = None + api_key: str | None = None timeout: float = 30.0 max_retries: int = 3 @@ -23,7 +31,9 @@ class ApiConfig: class BaseApi: """Base class for all API endpoint handlers.""" - def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None: + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: """Initialize the base API handler. Args: @@ -45,6 +55,33 @@ def _create_client(self) -> httpx.Client: timeout=self.config.timeout ) + def _stream_request(self, method: str, endpoint: str, **kwargs): + """Make streaming HTTP request with error handling. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + **kwargs: Additional arguments for the request + + Returns: + HTTP response object configured for streaming + + Raises: + httpx.HTTPStatusError: If the request fails + + Example: + with api._stream_request('GET', '/large-file') as response: + for chunk in response.iter_bytes(): + process_chunk(chunk) + """ + url = endpoint.lstrip('/') # Remove leading slash for httpx + + try: + return self.client.stream(method, url, **kwargs) + except httpx.RequestError as e: + logger.error(f"Request error for streaming {method} {endpoint}: {e}") + raise + def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: """Make HTTP request with error handling and retries. @@ -77,9 +114,23 @@ def _make_request_with_pagination(self, endpoint: str, return_field: str | None = None, **kwargs - ) -> Generator[tuple[httpx.Response, dict | list | str], None, None]: + ) -> Generator[tuple[httpx.Response, list | dict | str], None, None]: + """Make paginated HTTP requests, yielding each page of results. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint path + return_field: Optional field name to extract from each item in the response + **kwargs: Additional arguments for the request (e.g., params, json) + + Yields: + Tuples of (HTTP response, items from the current page `response.json()`, for convenience) + """ offset = 0 - params = kwargs.get('params', {}) + params = dict(kwargs.get('params', {})) + # Ensure kwargs carries our params reference so mutations below take effect + kwargs['params'] = params + while True: params['offset'] = offset params['limit'] = _PAGE_LIMIT @@ -87,7 +138,7 @@ def _make_request_with_pagination(self, response = self._make_request(method=method, endpoint=endpoint, **kwargs) - items = self._convert_response(response.json(), return_field=return_field) + items = self._convert_array_response(response.json(), return_field=return_field) yield response, items if len(items) < _PAGE_LIMIT: @@ -95,10 +146,18 @@ def _make_request_with_pagination(self, offset += _PAGE_LIMIT - def _convert_response(self, - data: dict | list, - return_field: str | None = None) -> list | dict | str: + def _convert_array_response(self, + data: dict | list, + return_field: str | None = None) -> list | dict | str: + """Normalize array-like responses into a list when possible. + Args: + data: Parsed JSON response. + return_field: Preferred top-level field to extract when present. + + Returns: + A list of items when identifiable, otherwise the original data. + """ if isinstance(data, list): items = data else: @@ -127,7 +186,7 @@ class EntityBaseApi(BaseApi, Generic[T]): def __init__(self, config: ApiConfig, entity_class: Type[T], endpoint_base: str, - client: Optional[httpx.Client] = None) -> None: + client: httpx.Client | None = None) -> None: """Initialize the entity API handler. Args: @@ -140,14 +199,40 @@ def __init__(self, config: ApiConfig, self.entity_class = entity_class self.endpoint_base = endpoint_base.strip('/') + def _make_entity_request(self, + method: str, + entity_id: str, + add_path: str = '', + **kwargs) -> httpx.Response: + try: + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return self._make_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + def _stream_entity_request(self, + method: str, + entity_id: str, + add_path: str = '', + **kwargs): + try: + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return self._stream_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + def get_list(self, **kwargs) -> Sequence[T]: """Get entities with optional filtering. Returns: - List of entity instances + List of entity instances. Raises: - httpx.HTTPStatusError: If the request fails + httpx.HTTPStatusError: If the request fails. """ params = dict(kwargs) @@ -156,15 +241,11 @@ def get_list(self, **kwargs) -> Sequence[T]: if params[k] is None: del params[k] - # response = self._make_request('GET', f'/{self.endpoint_base}', - # params=params) - # items = self._convert_response(response.json()) items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', - return_field=self.endpoint_base, - params=params) - + return_field=self.endpoint_base, + params=params) - return [self.entity_class(**item) for resp,items in items_gen for item in items] + return [self.entity_class(**item) for resp, items in items_gen for item in items] def get_all(self) -> Sequence[T]: """Get all entities with optional pagination and filtering. @@ -181,58 +262,70 @@ def get_by_id(self, entity_id: str) -> T: """Get a specific entity by its ID. Args: - entity_id: Unique identifier for the entity + entity_id: Unique identifier for the entity. Returns: - Entity instance + Entity instance. Raises: - httpx.HTTPStatusError: If the entity is not found or request fails + httpx.HTTPStatusError: If the entity is not found or request fails. """ - response = self._make_request('GET', f'/{self.endpoint_base}/{entity_id}') + response = self._make_entity_request('GET', entity_id) return self.entity_class(**response.json()) - def create(self, entity_data: dict[str, Any]) -> T: + def _create(self, entity_data: dict[str, Any]) -> str: """Create a new entity. Args: - entity_data: Dictionary containing entity data for creation + entity_data: Dictionary containing entity data for creation. Returns: - Created entity instance + The id of the created entity. Raises: - httpx.HTTPStatusError: If creation fails + httpx.HTTPStatusError: If creation fails. """ response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) - return self.entity_class(**response.json()) + respdata = response.json() + if isinstance(respdata, str): + return respdata + + return respdata.get('id') def update(self, entity_id: str, entity_data: dict[str, Any]) -> T: """Update an existing entity. Args: - entity_id: Unique identifier for the entity - entity_data: Dictionary containing updated entity data + entity_id: Unique identifier for the entity. + entity_data: Dictionary containing updated entity data. Returns: - Updated entity instance + Updated entity instance. Raises: - httpx.HTTPStatusError: If update fails or entity not found + httpx.HTTPStatusError: If update fails or entity not found. """ - response = self._make_request('PUT', f'/{self.endpoint_base}/{entity_id}', json=entity_data) + response = self._make_entity_request('PUT', entity_id, json=entity_data) return self.entity_class(**response.json()) def delete(self, entity_id: str) -> None: """Delete an entity by its ID. Args: - entity_id: Unique identifier for the entity to delete + entity_id: Unique identifier for the entity to delete. Raises: httpx.HTTPStatusError: If deletion fails or entity not found """ - self._make_request('DELETE', f'/{self.endpoint_base}/{entity_id}') + self._make_entity_request('DELETE', entity_id) + + def _get_child_entities(self, + parent_entity: BaseEntity | str, + child_entity_name: str) -> httpx.Response: + entid = parent_entity if isinstance(parent_entity, str) else parent_entity.id + # response = self._make_request('GET', f'/{self.endpoint_base}/{entid}/{child_entity_name}') + response = self._make_entity_request('GET', entid, add_path=child_entity_name) + return response # def bulk_create(self, entities_data: list[dict[str, Any]]) -> list[T]: # """Create multiple entities in a single request. diff --git a/datamint/apihandler/base_api_handler.py b/datamint/apihandler/base_api_handler.py index 517d6952..a6dfd1d0 100644 --- a/datamint/apihandler/base_api_handler.py +++ b/datamint/apihandler/base_api_handler.py @@ -15,7 +15,7 @@ from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage from datamint import configs import gzip -from datamint.exceptions import DatamintException +from datamint.exceptions import DatamintException, ResourceNotFoundError _LOGGER = logging.getLogger(__name__) @@ -30,33 +30,6 @@ _PAGE_LIMIT = 5000 -class ResourceNotFoundError(DatamintException): - """ - Exception raised when a resource is not found. - For instance, when trying to get a resource by a non-existing id. - """ - - def __init__(self, - resource_type: str, - params: dict): - """ Constructor. - - Args: - resource_type (str): A resource type. - params (dict): Dict of params identifying the sought resource. - """ - super().__init__() - self.resource_type = resource_type - self.params = params - - def set_params(self, resource_type: str, params: dict): - self.resource_type = resource_type - self.params = params - - def __str__(self): - return f"Resource '{self.resource_type}' not found for parameters: {self.params}" - - class BaseAPIHandler: """ Class to handle the API requests to the Datamint API diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py index 0f4e8795..b90d620f 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -25,13 +25,13 @@ class BaseEntity(BaseModel): def asdict(self) -> dict[str, Any]: """Convert the entity to a dictionary, including unknown fields.""" - return self.model_dump() + return self.model_dump(warnings='none') def asjson(self) -> str: """Convert the entity to a JSON string, including unknown fields.""" - return self.model_dump_json() + return self.model_dump_json(warnings='none') - def model_post_init(self, __context: Any) -> None: - if self.__pydantic_extra__: - _LOGGER.warning(f"Unknown fields found in {self.__class__.__name__} " - f"fields: {self.__pydantic_extra__.keys()}. ") + # def model_post_init(self, __context: Any) -> None: + # if self.__pydantic_extra__: + # _LOGGER.warning(f"Unknown fields found in {self.__class__.__name__} " + # f"fields: {self.__pydantic_extra__.keys()}. ") diff --git a/datamint/exceptions.py b/datamint/exceptions.py index 872a0c7c..93e30c40 100644 --- a/datamint/exceptions.py +++ b/datamint/exceptions.py @@ -2,4 +2,30 @@ class DatamintException(Exception): """ Base class for exceptions in this module. """ - pass \ No newline at end of file + pass + +class ResourceNotFoundError(DatamintException): + """ + Exception raised when a resource is not found. + For instance, when trying to get a resource by a non-existing id. + """ + + def __init__(self, + resource_type: str, + params: dict): + """ Constructor. + + Args: + resource_type (str): A resource type. + params (dict): Dict of params identifying the sought resource. + """ + super().__init__() + self.resource_type = resource_type + self.params = params + + def set_params(self, resource_type: str, params: dict): + self.resource_type = resource_type + self.params = params + + def __str__(self): + return f"Resource '{self.resource_type}' not found for parameters: {self.params}" \ No newline at end of file From 831ac8177d3ebdecf61e61fc63ca763815657338 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 8 Sep 2025 17:06:49 -0300 Subject: [PATCH 03/13] Implement ProjectsApi class for project-related API endpoints --- datamint/api/endpoints/projects_api.py | 163 +++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 datamint/api/endpoints/projects_api.py diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py new file mode 100644 index 00000000..88ff3e1f --- /dev/null +++ b/datamint/api/endpoints/projects_api.py @@ -0,0 +1,163 @@ +from typing import Sequence +from ..base_api import EntityBaseApi, ApiConfig +from datamint.entities.project import Project +from datamint.entities.resource import Resource +import httpx + + +class ProjectsApi(EntityBaseApi[Project]): + """API handler for project-related endpoints.""" + + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + """Initialize the projects API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Project, 'projects', client) + + def get_project_resources(self, project: Project | str) -> Sequence[Resource]: + """Get resources associated with a specific project. + + Args: + project: The ID or instance of the project to fetch resources for. + + Returns: + A list of resource instances associated with the project. + """ + response = self._get_child_entities(project, 'resources') + resources_data = response.json() + return [Resource(**item) for item in resources_data] + + def create(self, + name: str, + description: str, + resources_ids: list[str] | None = None, + is_active_learning: bool = False, + two_up_display: bool = False + ) -> str: + """Create a new project. + + Args: + name: The name of the project. + description: The description of the project. + resources_ids: The list of resource ids to be included in the project. + is_active_learning: Whether the project is an active learning project or not. + two_up_display: Allow annotators to display multiple resources for annotation. + + Returns: + The id of the created project. + """ + resources_ids = resources_ids or [] + project_data = {'name': name, + 'is_active_learning': is_active_learning, + 'resource_ids': resources_ids, + 'annotation_set': { + "annotators": [], + "resource_ids": resources_ids, + "annotations": [], + "frame_labels": [], + "image_labels": [], + }, + "two_up_display": two_up_display, + "require_review": False, + 'description': description} + + return self._create(project_data) + + def get_by_name(self, name: str) -> Project | None: + """Get a project by its name. + + Args: + name (str): The name of the project. + + Returns: + The project instance if found, otherwise None. + """ + projects = self.get_all() + for project in projects: + if project.name == name: + return project + return None + + def _get_by_name_or_id(self, project: str) -> Project | None: + """Get a project by its name or ID. + + Args: + project (str): The name or ID of the project. + + Returns: + The project instance if found, otherwise None. + """ + projects = self.get_all() + for proj in projects: + if proj.name == project or proj.id == project: + return proj + return None + + def add_resources(self, + resources: str | Sequence[str] | Resource | Sequence[Resource], + project: str | Project, + ) -> None: + """ + Add resources to a project. + + Args: + resources: The resource unique id or a list of resource unique ids. + project: The project name, id or :class:`Project` object to add the resource to. + """ + if isinstance(resources, str): + resources_ids = [resources] + elif isinstance(resources, Resource): + resources_ids = [resources.id] + else: + resources_ids = [res if isinstance(res, str) else res.id for res in resources] + + if isinstance(project, str): + if len(project) == 36: + project_id = project + else: + # get the project id by its name + project_found = self._get_by_name_or_id(project) + if project_found is None: + raise ValueError(f"Project '{project}' not found.") + project_id = project_found.id + else: + project_id = project.id + + self._make_entity_request('POST', project_id, add_path='resources', + json={'resource_ids_to_add': resources_ids, 'all_files_selected': False}) + + def download(self, project_id: str, + outpath: str, + all_annotations: bool = False, + include_unannotated: bool = False, + ) -> None: + """Download a project by its id. + + Args: + project_id: The project id. + outpath: The path to save the project zip file. + all_annotations: Whether to include all annotations in the downloaded dataset, + even those not made by the provided project. + include_unannotated: Whether to include unannotated resources in the downloaded dataset. + """ + from tqdm.auto import tqdm + params = {'all_annotations': all_annotations} + if include_unannotated: + params['include_unannotated'] = include_unannotated + + with self._stream_entity_request('GET', project_id, + add_path='annotated_dataset', + params=params) as response: + total_size = int(response.headers.get('content-length', 0)) + if total_size == 0: + total_size = None + with tqdm(total=total_size, unit='B', unit_scale=True) as progress_bar: + with open(outpath, 'wb') as file: + for data in response.iter_bytes(1024): + progress_bar.update(len(data)) + file.write(data) From f2bcba76e2360400ed11fcd69323e0a437a90f7d Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 8 Sep 2025 19:41:35 -0300 Subject: [PATCH 04/13] feat: Implement asynchronous API client and endpoints for DataMint - Added `Api` class to serve as the main API client, providing access to endpoint handlers. - Introduced `AnnotationsApi` and `ResourcesApi` classes for handling annotation and resource-related API calls. - Enhanced `BaseApi` with asynchronous request - Created `Annotation` and `Resource` entity models to represent data structures returned by the API. - Implemented methods for uploading resources, including support for DICOM files and metadata handling. --- datamint/api/base_api.py | 172 +++++- datamint/api/client.py | 69 +++ datamint/api/endpoints/annotations_api.py | 59 +++ datamint/api/endpoints/resources_api.py | 612 ++++++++++++++++++++++ datamint/entities/annotation.py | 79 +++ datamint/entities/project.py | 58 ++ datamint/entities/resource.py | 130 +++++ 7 files changed, 1174 insertions(+), 5 deletions(-) create mode 100644 datamint/api/client.py create mode 100644 datamint/api/endpoints/annotations_api.py create mode 100644 datamint/api/endpoints/resources_api.py create mode 100644 datamint/entities/annotation.py create mode 100644 datamint/entities/project.py create mode 100644 datamint/entities/resource.py diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 7ed6a25a..a6ac2ef4 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -1,9 +1,11 @@ import logging -from typing import Any, TypeVar, Generic, Type, Sequence, Generator +from typing import Any, TypeVar, Generic, Type, Sequence, Generator, Literal import httpx from dataclasses import dataclass from datamint.entities.base_entity import BaseEntity from datamint.exceptions import DatamintException, ResourceNotFoundError +import aiohttp +import json logger = logging.getLogger(__name__) @@ -109,6 +111,166 @@ def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: logger.error(f"Request error for {method} {endpoint}: {e}") raise + def _generate_curl_command(self, request_args: dict) -> str: + """ + Generate a curl command for debugging purposes. + + Args: + request_args (dict): Request arguments dictionary containing method, url, headers, etc. + + Returns: + str: Equivalent curl command + """ + method = request_args.get('method', 'GET').upper() + url = request_args['url'] + headers = request_args.get('headers', {}) + data = request_args.get('json') or request_args.get('data') + params = request_args.get('params') + + curl_command = ['curl'] + + # Add method if not GET + if method != 'GET': + curl_command.extend(['-X', method]) + + # Add headers + for key, value in headers.items(): + if key.lower() == 'apikey': + value = '' # Mask API key for security + curl_command.extend(['-H', f"'{key}: {value}'"]) + + # Add query parameters + if params: + param_str = '&'.join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{param_str}" + # Add URL + curl_command.append(f"'{url}'") + + # Add data + if data: + if isinstance(data, aiohttp.FormData): # Check if it's aiohttp.FormData + # Handle FormData by extracting fields + form_parts = [] + for options, headers, value in data._fields: + # get the name from options + name = options.get('name', 'file') + if hasattr(value, 'read'): # File-like object + filename = getattr(value, 'name', 'file') + form_parts.extend(['-F', f"'{name}=@{filename}'"]) + else: + form_parts.extend(['-F', f"'{name}={value}'"]) + curl_command.extend(form_parts) + elif isinstance(data, dict): + curl_command.extend(['-d', f"'{json.dumps(data)}'"]) + else: + curl_command.extend(['-d', f"'{data}'"]) + + return ' '.join(curl_command) + + @staticmethod + def get_status_code(e) -> int: + if not hasattr(e, 'response') or e.response is None: + return -1 + return e.response.status_code + + def _check_errors_response(self, + response, + url: str): + try: + if hasattr(response, 'raise_for_status'): + response.raise_for_status() + except Exception as e: + status_code = BaseApi.get_status_code(e) + if status_code >= 500 and status_code < 600: + logger.error(f"Error in request to {url}: {e}") + if status_code >= 400 and status_code < 500: + try: + logger.info(f"Error response: {response.text}") + error_data = response.json() + except Exception as e2: + logger.info(f"Error parsing the response. {e2}") + else: + if isinstance(error_data['message'], str) and ' not found' in error_data['message'].lower(): + # Will be caught by the caller and properly initialized: + raise ResourceNotFoundError('unknown', {}) + + raise + + async def _make_request_async(self, + method: str, + endpoint: str, + session: aiohttp.ClientSession | None = None, + data_to_get: Literal['json', 'text', 'content'] = 'json', + **kwargs) -> aiohttp.ClientResponse: + """Make asynchronous HTTP request with error handling. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + session: Optional aiohttp session. If None, a new one will be created. + **kwargs: Additional arguments for the request + + Returns: + HTTP response object + + Raises: + aiohttp.ClientError: If the request fails + """ + url = f"{self.config.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + + # Prepare headers + headers = kwargs.pop('headers', {}) + if self.config.api_key: + headers['apikey'] = self.config.api_key + + # Set timeout + timeout = aiohttp.ClientTimeout(total=self.config.timeout) + + async def make_request(client_session: aiohttp.ClientSession) -> aiohttp.ClientResponse | Any: + try: + # logger.debug(f"Making async {method} request to {url} with headers {headers} and kwargs:\n {kwargs}") + try: + logger.debug(f"Running request to {url}") + logger.debug(f'Equivalent curl command: "{self._generate_curl_command({"method": method, + "url": url, + "headers": headers, + **kwargs})}"' + ) + except Exception as e: + logger.debug(f"Error generating curl command: {e}") + async with client_session.request( + method=method, + url=url, + headers=headers, + timeout=timeout, + **kwargs + ) as response: + # Check for HTTP errors + # if response.status >= 400: + # error_text = await response.text() + # logger.error(f"HTTP error {response.status} for {method} {endpoint}: {error_text}") + self._check_errors_response(response, url=url) + + if data_to_get == 'json': + return await response.json() + elif data_to_get == 'text': + return await response.text() + elif data_to_get == 'content': + return await response.read() + else: + raise ValueError("data_to_get must be either 'json', 'text', or 'content'") + + return response + except aiohttp.ClientError as e: + logger.error(f"Request error for {method} {endpoint}: {e}") + raise + + if session is not None: + return await make_request(session) + else: + async with aiohttp.ClientSession() as temp_session: + return await make_request(temp_session) + def _make_request_with_pagination(self, method: str, endpoint: str, @@ -213,10 +375,10 @@ def _make_entity_request(self, raise def _stream_entity_request(self, - method: str, - entity_id: str, - add_path: str = '', - **kwargs): + method: str, + entity_id: str, + add_path: str = '', + **kwargs): try: add_path = '/'.join(add_path.strip().strip('/').split('/')) return self._stream_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) diff --git a/datamint/api/client.py b/datamint/api/client.py new file mode 100644 index 00000000..39787cf9 --- /dev/null +++ b/datamint/api/client.py @@ -0,0 +1,69 @@ +from typing import Optional +import httpx +from .base_api import ApiConfig +from .endpoints import ProjectsApi, ResourcesApi + + +class Api: + """Main API client that provides access to all endpoint handlers.""" + + def __init__(self, base_url: str, api_key: Optional[str] = None, + timeout: float = 30.0, max_retries: int = 3, + client: Optional[httpx.Client] = None) -> None: + """Initialize the API client. + + Args: + base_url: Base URL for the API + api_key: Optional API key for authentication + timeout: Request timeout in seconds + max_retries: Maximum number of retry attempts + client: Optional HTTP client instance + """ + self.config = ApiConfig( + base_url=base_url, + api_key=api_key, + timeout=timeout, + max_retries=max_retries + ) + self._client = client + + # Initialize endpoint handlers + self._projects = None + self._annotations = None + self._resources = None + + @property + def projects(self) -> ProjectsApi: + """Access to project-related endpoints.""" + if self._projects is None: + self._projects = ProjectsApi(self.config, self._client) + return self._projects + + @property + def resources(self) -> ResourcesApi: + """Access to resource-related endpoints.""" + if self._resources is None: + self._resources = ResourcesApi(self.config, self._client) + return self._resources + + # @property + # def annotations(self) -> AnnotationsApi: + # """Access to annotation-related endpoints.""" + # if self._annotations is None: + # self._annotations = AnnotationsApi(self.config, self._client) + # return self._annotations + + # def close(self) -> None: + # """Close the HTTP client connections.""" + # if self._projects and self._projects.client: + # self._projects.client.close() + # if self._annotations and self._annotations.client: + # self._annotations.client.close() + + # def __enter__(self): + # """Context manager entry.""" + # return self + + # def __exit__(self, exc_type, exc_val, exc_tb): + # """Context manager exit.""" + # self.close() diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py new file mode 100644 index 00000000..8a240c83 --- /dev/null +++ b/datamint/api/endpoints/annotations_api.py @@ -0,0 +1,59 @@ +from typing import Any, Sequence, Literal +import httpx +from datetime import date + +from ..base_api import EntityBaseApi, ApiConfig +from datamint.entities.annotation import Annotation +from datamint.entities.resource import Resource +from datamint.apihandler.dto.annotation_dto import AnnotationType + + +class AnnotationsApi(EntityBaseApi[Annotation]): + """API handler for annotation-related endpoints.""" + + def __init__(self, config: ApiConfig, client: httpx.Client | None = None) -> None: + """Initialize the annotations API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Annotation, 'annotations', client) + + # def create(self, annotation_data: dict[str, Any]) -> str: + # """Create a new annotation. + + # Args: + # annotation_data: Dictionary payload for the annotation. + + # Returns: + # The id of the created annotation. + # """ + # return self._create(annotation_data) + + def get_list(self, + resource: str | Resource | None = None, + annotation_type: AnnotationType | str | None = None, + annotator_email: str | None = None, + date_from: date | None = None, + date_to: date | None = None, + dataset_id: str | None = None, + worklist_id: str | None = None, + status: Literal['new', 'published'] | None = None, + load_ai_segmentations: bool | None = None, + ) -> Sequence[Annotation]: + payload = { + 'resource_id': resource.id if isinstance(resource, Resource) else resource, + 'annotation_type': annotation_type, + 'annotatorEmail': annotator_email, + 'from': date_from.isoformat() if date_from is not None else None, + 'to': date_to.isoformat() if date_to is not None else None, + 'dataset_id': dataset_id, + 'annotation_worklist_id': worklist_id, + 'status': status, + 'load_ai_segmentations': load_ai_segmentations + } + + # remove nones + payload = {k: v for k, v in payload.items() if v is not None} + return super().get_list(**payload) diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py new file mode 100644 index 00000000..f13b1be6 --- /dev/null +++ b/datamint/api/endpoints/resources_api.py @@ -0,0 +1,612 @@ +from typing import Any, Optional, Sequence, TypeAlias, Literal, IO +from ..base_api import EntityBaseApi, ApiConfig +from .annotations_api import AnnotationsApi +from .projects_api import ProjectsApi +from datamint.entities.resource import Resource +from datamint.entities.annotation import Annotation +from datamint.exceptions import DatamintException +import httpx +from datetime import date +import json +import logging +import pydicom +import pydicom.dataset +from medimgkit.dicom_utils import anonymize_dicom, to_bytesio, is_dicom, is_dicom_report, GeneratorWithLength +from medimgkit import dicom_utils, standardize_mimetype +from medimgkit.io_utils import is_io_object, peek +from medimgkit.format_detection import guess_typez, guess_extension, DEFAULT_MIME_TYPE +from medimgkit.nifti_utils import DEFAULT_NIFTI_MIME, NIFTI_MIMES +import os +import itertools +from tqdm.auto import tqdm +import asyncio +import aiohttp +from pathlib import Path +import nest_asyncio # For running asyncio in jupyter notebooks + +_LOGGER = logging.getLogger(__name__) +_USER_LOGGER = logging.getLogger('user_logger') + +ResourceStatus: TypeAlias = Literal['new', 'inbox', 'published', 'archived'] +"""TypeAlias: The available resource status. Possible values: 'new', 'inbox', 'published', 'archived'. +""" +ResourceFields: TypeAlias = Literal['modality', 'created_by', 'published_by', 'published_on', 'filename', 'created_at'] +"""TypeAlias: The available fields to order resources. Possible values: 'modality', 'created_by', 'published_by', 'published_on', 'filename', 'created_at' (default). +""" + + +def _infinite_gen(x): + while True: + yield x + + +def _open_io(file_path: str | Path | IO, mode: str = 'rb') -> IO: + if isinstance(file_path, str) or isinstance(file_path, Path): + return open(file_path, 'rb') + return file_path + + +class ResourcesApi(EntityBaseApi[Resource]): + """API handler for resource-related endpoints.""" + + def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None: + """Initialize the resources API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Resource, 'resources', client) + nest_asyncio.apply() + self.annotations_api = AnnotationsApi(config, client) + self.projects_api = ProjectsApi(config, client) + + def get_list(self, + status: Optional[ResourceStatus] = None, + from_date: date | str | None = None, + to_date: date | str | None = None, + tags: Optional[Sequence[str]] = None, + modality: Optional[str] = None, + mimetype: Optional[str] = None, + # return_ids_only: bool = False, + order_field: Optional[ResourceFields] = None, + order_ascending: Optional[bool] = None, + channel: Optional[str] = None, + project_name: str | list[str] | None = None, + filename: Optional[str] = None, + ) -> Sequence[Resource]: + """Get resources with optional filtering. + Args: + status (ResourceStatus): The resource status. Possible values: 'inbox', 'published', 'archived' or None. If None, it will return all resources. + from_date (date | str | None): The start date. + to_date (date | str | None): The end date. + tags (Optional[list[str]]): The tags to filter the resources. + modality (Optional[str]): The modality of the resources. + mimetype (Optional[str]): The mimetype of the resources. + # return_ids_only (bool): Whether to return only the ids of the resources. + order_field (Optional[ResourceFields]): The field to order the resources. See :data:`~.base_api_handler.ResourceFields`. + order_ascending (Optional[bool]): Whether to order the resources in ascending order. + project_name (str | list[str] | None): The project name or a list of project names to filter resources by project. + If multiple projects are provided, resources will be filtered to include only those belonging to ALL of the specified projects. + + """ + + # Convert datetime objects to ISO format + if from_date: + if isinstance(from_date, str): + date.fromisoformat(from_date) + else: + from_date = from_date.isoformat() + if to_date: + if isinstance(to_date, str): + date.fromisoformat(to_date) + else: + to_date = to_date.isoformat() + + # Prepare the payload + payload = { + "from": from_date, + "to": to_date, + "status": status if status is not None else "", + "modality": modality, + "mimetype": mimetype, + # "ids": return_ids_only, + "order_field": order_field, + "order_by_asc": order_ascending, + "channel_name": channel, + "filename": filename, + } + if project_name is not None: + if isinstance(project_name, str): + project_name = [project_name] + payload["project"] = json.dumps({'items': project_name, + 'filterType': 'intersection'}) # union or intersection + + if tags is not None: + if isinstance(tags, str): + tags = [tags] + tags_filter = { + "items": tags, + "filterType": "union" + } + payload['tags'] = json.dumps(tags_filter) + + return super().get_list(**payload) + + def get_annotations(self, resource_id: str | Resource) -> Sequence[Annotation]: + """Get annotations for a specific resource. + + Args: + resource_id: The ID of the resource to fetch annotations for. + + Returns: + A sequence of Annotation objects associated with the specified resource. + """ + return self.annotations_api.get_list(resource=resource_id) + + @staticmethod + def __process_files_parameter(file_path: str | IO | Sequence[str | IO] | pydicom.dataset.Dataset + ) -> tuple[Sequence[str | IO], bool]: + """ + Process the file_path parameter to ensure it is a list of file paths or IO objects. + """ + if isinstance(file_path, pydicom.dataset.Dataset): + file_path = to_bytesio(file_path, file_path.filename) + + if isinstance(file_path, str): + if os.path.isdir(file_path): + is_list = True + new_file_path = [f'{file_path}/{f}' for f in os.listdir(file_path)] + else: + is_list = False + new_file_path = [file_path] + # Check if is an IO object + elif is_io_object(file_path): + is_list = False + new_file_path = [file_path] + elif not hasattr(file_path, '__len__'): + if hasattr(file_path, '__iter__'): + is_list = True + new_file_path = list(file_path) + else: + is_list = False + new_file_path = [file_path] + else: + is_list = True + new_file_path = file_path + return new_file_path, is_list + + def _assemble_dicoms(self, files_path: Sequence[str | IO] + ) -> tuple[Sequence[str | IO], bool, Sequence[int]]: + """ + Assembles DICOM files into a single file. + + Args: + files_path: The paths to the DICOM files to assemble. + + Returns: + A tuple containing: + - The paths to the assembled DICOM files. + - A boolean indicating whether the assembly was successful. + - same length as the output assembled DICOMs, mapping assembled DICOM to original DICOMs. + """ + dicoms_files_path = [] + other_files_path = [] + dicom_original_idxs = [] + others_original_idxs = [] + for i, f in enumerate(files_path): + if is_dicom(f): + dicoms_files_path.append(f) + dicom_original_idxs.append(i) + else: + other_files_path.append(f) + others_original_idxs.append(i) + + orig_len = len(dicoms_files_path) + if orig_len == 0: + _LOGGER.debug("No DICOM files found to assemble.") + return files_path, False, [] + dicoms_files_path = dicom_utils.assemble_dicoms(dicoms_files_path, return_as_IO=True) + + new_len = len(dicoms_files_path) + if new_len != orig_len: + _LOGGER.info(f"Assembled {new_len} dicom files out of {orig_len} files.") + mapping_idx = [None] * len(files_path) + + files_path = GeneratorWithLength(itertools.chain(dicoms_files_path, other_files_path), + length=new_len + len(other_files_path)) + assembled = True + for orig_idx, value in zip(dicom_original_idxs, dicoms_files_path.inverse_mapping_idx): + mapping_idx[orig_idx] = value + for i, orig_idx in enumerate(others_original_idxs): + mapping_idx[orig_idx] = new_len + i + else: + assembled = False + mapping_idx = [i for i in range(len(files_path))] + + return files_path, assembled, mapping_idx + + async def _upload_single_resource_async(self, + file_path: str | IO, + mimetype: Optional[str] = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + tags: list[str] = [], + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: Optional[str] = None, + session=None, + modality: Optional[str] = None, + publish: bool = False, + metadata_file: Optional[str | dict] = None, + ) -> str: + if is_io_object(file_path): + name = file_path.name + else: + name = file_path + + if session is not None and not isinstance(session, aiohttp.ClientSession): + raise ValueError("session must be an aiohttp.ClientSession object.") + + name = os.path.expanduser(os.path.normpath(name)) + if len(Path(name).parts) == 0: + raise ValueError(f"File path '{name}' is not valid.") + name = os.path.join(*[x if x != '..' else '_' for x in Path(name).parts]) + + if mung_filename is not None: + file_parts = Path(name).parts + if file_parts[0] == os.path.sep: + file_parts = file_parts[1:] + if mung_filename == 'all': + new_file_path = '_'.join(file_parts) + else: + folder_parts = file_parts[:-1] + new_file_path = '_'.join([folder_parts[i-1] for i in mung_filename if i <= len(folder_parts)]) + new_file_path += '_' + file_parts[-1] + name = new_file_path + _LOGGER.debug(f"New file path: {name}") + + is_a_dicom_file = None + if mimetype is None: + mimetype_list, ext = guess_typez(file_path, use_magic=True) + for mime in mimetype_list: + if mime in NIFTI_MIMES: + mimetype = DEFAULT_NIFTI_MIME + break + else: + if ext == '.nii.gz' or name.lower().endswith('nii.gz'): + mimetype = DEFAULT_NIFTI_MIME + else: + mimetype = mimetype_list[-1] if mimetype_list else DEFAULT_MIME_TYPE + + mimetype = standardize_mimetype(mimetype) + filename = os.path.basename(name) + _LOGGER.debug(f"File name '{filename}' mimetype: {mimetype}") + + if is_a_dicom_file == True or is_dicom(file_path): + if tags is None: + tags = [] + else: + tags = list(tags) + ds = pydicom.dcmread(file_path) + if anonymize: + _LOGGER.info(f"Anonymizing {file_path}") + ds = anonymize_dicom(ds, retain_codes=anonymize_retain_codes) + lat = dicom_utils.get_dicom_laterality(ds) + if lat == 'L': + tags.append("left") + elif lat == 'R': + tags.append("right") + # make the dicom `ds` object a file-like object in order to avoid unnecessary disk writes + f = to_bytesio(ds, name) + else: + f = _open_io(file_path) + + try: + metadata_content = None + metadata_dict = None + if metadata_file is not None: + if isinstance(metadata_file, dict): + # Metadata is already a dictionary + metadata_dict = metadata_file + metadata_content = json.dumps(metadata_dict) + _LOGGER.debug("Using provided metadata dictionary") + else: + # Metadata is a file path + try: + with open(metadata_file, 'r') as metadata_f: + metadata_content = metadata_f.read() + metadata_dict = json.loads(metadata_content) + except Exception as e: + _LOGGER.warning(f"Failed to read metadata file {metadata_file}: {e}") + + # Extract modality from metadata if available + if metadata_dict is not None: + metadata_dict_lower = {k.lower(): v for k, v in metadata_dict.items() if isinstance(k, str)} + try: + if modality is None: + if 'modality' in metadata_dict_lower: + modality = metadata_dict_lower['modality'] + except Exception as e: + _LOGGER.debug(f"Failed to extract modality from metadata: {e}") + + form = aiohttp.FormData() + file_key = 'resource' + form.add_field('source', 'api') + + form.add_field(file_key, f, filename=filename, content_type=mimetype) + form.add_field('source_filepath', name) # full path to the file + if mimetype is not None: + form.add_field('mimetype', mimetype) + if channel is not None: + form.add_field('channel', channel) + if modality is not None: + form.add_field('modality', modality) + form.add_field('bypass_inbox', 'true' if publish else 'false') + if tags is not None and len(tags) > 0: + # comma separated list of tags + form.add_field('tags', ','.join([l.strip() for l in tags])) + + # Add JSON metadata if provided + if metadata_content is not None: + try: + _LOGGER.debug("Adding metadata to form data") + form.add_field('metadata', metadata_content, content_type='application/json') + except Exception as e: + _LOGGER.warning(f"Failed to add metadata to form: {e}") + + resp_data = await self._make_request_async(method='POST', + endpoint=self.endpoint_base, + data=form, + data_to_get='json') + if 'error' in resp_data: + raise DatamintException(resp_data['error']) + _LOGGER.debug(f"Response on uploading {name}: {resp_data}") + return resp_data['id'] + except Exception as e: + if 'name' in locals(): + _LOGGER.error(f"Error uploading {name}: {e}") + else: + _LOGGER.error(f"Error uploading {file_path}: {e}") + raise e + finally: + f.close() + + async def _upload_resources_async(self, + files_path: Sequence[str | IO], + mimetype: Optional[str] = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + on_error: Literal['raise', 'skip'] = 'raise', + tags=None, + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: Optional[str] = None, + modality: Optional[str] = None, + publish: bool = False, + segmentation_files: Sequence[dict] | None = None, + transpose_segmentation: bool = False, + metadata_files: Sequence[str | dict | None] | None = None, + progress_bar: tqdm | None = None, + ) -> list[str]: + if on_error not in ['raise', 'skip']: + raise ValueError("on_error must be either 'raise' or 'skip'") + + if segmentation_files is None: + segmentation_files = _infinite_gen(None) + + if metadata_files is None: + metadata_files = _infinite_gen(None) + + async with aiohttp.ClientSession() as session: + async def __upload_single_resource(file_path, segfiles: dict[str, list | dict], + metadata_file: str | dict | None): + name = file_path.name if is_io_object(file_path) else file_path + name = os.path.basename(name) + rid = await self._upload_single_resource_async( + file_path=file_path, + mimetype=mimetype, + anonymize=anonymize, + anonymize_retain_codes=anonymize_retain_codes, + tags=tags, + session=session, + mung_filename=mung_filename, + channel=channel, + modality=modality, + publish=publish, + metadata_file=metadata_file, + ) + if progress_bar: + progress_bar.update(1) + progress_bar.set_postfix(file=name) + else: + _USER_LOGGER.info(f'"{name}" uploaded') + + if segfiles is not None: + fpaths = segfiles['files'] + names = segfiles.get('names', _infinite_gen(None)) + if isinstance(names, dict): + names = _infinite_gen(names) + frame_indices = segfiles.get('frame_index', _infinite_gen(None)) + for f, name, frame_index in tqdm(zip(fpaths, names, frame_indices), + desc=f"Uploading segmentations for {file_path}", + total=len(fpaths)): + if f is not None: + raise NotImplementedError("Uploading segmentations is not implemented yet.") + # await self._upload_segmentations_async(rid, + # file_path=f, + # name=name, + # frame_index=frame_index, + # transpose_segmentation=transpose_segmentation) + return rid + + tasks = [__upload_single_resource(f, segfiles, metadata_file) + for f, segfiles, metadata_file in zip(files_path, segmentation_files, metadata_files)] + return await asyncio.gather(*tasks, return_exceptions=on_error == 'skip') + + def upload_resources(self, + files_path: str | IO | Sequence[str | IO] | pydicom.dataset.Dataset, + mimetype: Optional[str] = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + on_error: Literal['raise', 'skip'] = 'raise', + tags: Optional[Sequence[str]] = None, + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: Optional[str] = None, + publish: bool = False, + publish_to: Optional[str] = None, + segmentation_files: Optional[list[list[str] | dict]] = None, + transpose_segmentation: bool = False, + modality: Optional[str] = None, + assemble_dicoms: bool = True, + metadata: list[str | dict | None] | dict | str | None = None, + discard_dicom_reports: bool = True, + progress_bar: bool = False + ) -> list[str | Exception] | str | Exception: + """ + Upload resources. + + Args: + files_path (str | IO | Sequence[str | IO]): The path to the resource file or a list of paths to resources files. + mimetype (str): The mimetype of the resources. If None, it will be guessed. + anonymize (bool): Whether to anonymize the dicoms or not. + anonymize_retain_codes (Sequence[tuple]): The tags to retain when anonymizing the dicoms. + on_error (Literal['raise', 'skip']): Whether to raise an exception when an error occurs or to skip the error. + tags (Optional[Sequence[str]]): The tags to add to the resources. + mung_filename (Sequence[int] | Literal['all']): The parts of the filepath to keep when renaming the resource file. + ''all'' keeps all parts. + channel (Optional[str]): The channel to upload the resources to. An arbitrary name to group the resources. + publish (bool): Whether to directly publish the resources or not. They will have the 'published' status. + publish_to (Optional[str]): The project name or id to publish the resources to. + They will have the 'published' status and will be added to the project. + If this is set, `publish` parameter is ignored. + segmentation_files (Optional[list[Union[list[str], dict]]]): The segmentation files to upload. + If each element is a dict, it should have two keys: 'files' and 'names'. + - files: A list of paths to the segmentation files. Example: ['seg1.nii.gz', 'seg2.nii.gz']. + - names: Can be a list (same size of `files`) of labels for the segmentation files. Example: ['Brain', 'Lung']. + transpose_segmentation (bool): Whether to transpose the segmentation files or not. + modality (Optional[str]): The modality of the resources. + assemble_dicoms (bool): Whether to assemble the dicom files or not based on the SeriesInstanceUID and InstanceNumber attributes. + metadatas (Optional[list[str | dict | None]]): JSON metadata to include with each resource. + Must have the same length as `files_path`. + Can be file paths (str) or already loaded dictionaries (dict). + + Raises: + ResourceNotFoundError: If `publish_to` is supplied, and the project does not exists. + + Returns: + list[str | Exception]: A list of resource IDs or errors. + """ + + if on_error not in ['raise', 'skip']: + raise ValueError("on_error must be either 'raise' or 'skip'") + + files_path, is_multiple_resources = ResourcesApi.__process_files_parameter(files_path) + + # Discard DICOM reports + if discard_dicom_reports: + old_size = len(files_path) + # Create filtered lists maintaining index correspondence + filtered_files = [] + filtered_metadata = [] + + for i, f in enumerate(files_path): + if not is_dicom_report(f): + filtered_files.append(f) + if metadata is not None: + filtered_metadata.append(metadata[i]) + + files_path = filtered_files + if metadata is not None: + metadata = filtered_metadata + + if old_size is not None and old_size != len(files_path): + _LOGGER.info(f"Discarded {old_size - len(files_path)} DICOM report files from upload.") + + if isinstance(metadata, (str, dict)): + _LOGGER.debug("Converting metadatas to a list") + metadata = [metadata] + + if metadata is not None and len(metadata) != len(files_path): + raise ValueError("The number of metadata files must match the number of resources.") + if assemble_dicoms: + files_path, assembled, mapping_idx = self._assemble_dicoms(files_path) + assemble_dicoms = assembled + else: + mapping_idx = [i for i in range(len(files_path))] + n_files = len(files_path) + + if n_files <= 1: + # Disable progress bar for single file uploads + progress_bar = False + + if segmentation_files is not None: + if assemble_dicoms: + raise NotImplementedError("Segmentation files cannot be uploaded when assembling dicoms yet.") + if is_multiple_resources: + if len(segmentation_files) != len(files_path): + raise ValueError("The number of segmentation files must match the number of resources.") + else: + if isinstance(segmentation_files, list) and isinstance(segmentation_files[0], list): + raise ValueError("segmentation_files should not be a list of lists if files_path is not a list.") + if isinstance(segmentation_files, dict): + segmentation_files = [segmentation_files] + + segmentation_files = [segfiles if (isinstance(segfiles, dict) or segfiles is None) else {'files': segfiles} + for segfiles in segmentation_files] + + for segfiles in segmentation_files: + if segfiles is None: + continue + if 'files' not in segfiles: + raise ValueError("segmentation_files must contain a 'files' key with a list of file paths.") + if 'names' in segfiles: + # same length as files + if isinstance(segfiles['names'], (list, tuple)) and len(segfiles['names']) != len(segfiles['files']): + raise ValueError( + "segmentation_files['names'] must have the same length as segmentation_files['files'].") + + loop = asyncio.get_event_loop() + pbar = None + try: + if progress_bar: + pbar = tqdm(total=n_files, desc="Uploading resources", unit="file") + + task = self._upload_resources_async(files_path=files_path, + mimetype=mimetype, + anonymize=anonymize, + anonymize_retain_codes=anonymize_retain_codes, + on_error=on_error, + tags=tags, + mung_filename=mung_filename, + channel=channel, + publish=publish, + segmentation_files=segmentation_files, + transpose_segmentation=transpose_segmentation, + modality=modality, + metadata_files=metadata, + progress_bar=pbar + ) + + resource_ids = loop.run_until_complete(task) + finally: + if pbar: + pbar.close() + + _LOGGER.info(f"Resources uploaded: {resource_ids}") + + if publish_to is not None: + _USER_LOGGER.info('Adding resources to project') + resource_ids_succ = [rid for rid in resource_ids if not isinstance(rid, Exception)] + try: + self.projects_api.add_resources(resource_ids_succ, publish_to) + except Exception as e: + _LOGGER.error(f"Error adding resources to project: {e}") + if on_error == 'raise': + raise e + + if mapping_idx: + _LOGGER.debug(f"Mapping indices for DICOM files: {mapping_idx}") + resource_ids = [resource_ids[idx] for idx in mapping_idx] + + if is_multiple_resources: + return resource_ids + return resource_ids[0] diff --git a/datamint/entities/annotation.py b/datamint/entities/annotation.py new file mode 100644 index 00000000..44c89464 --- /dev/null +++ b/datamint/entities/annotation.py @@ -0,0 +1,79 @@ +# filepath: datamint/entities/annotation.py +"""Annotation entity module for DataMint API. + +This module defines the Annotation model used to represent annotation +records returned by the DataMint API. +""" + +from typing import Any +import logging +from .base_entity import BaseEntity + +logger = logging.getLogger(__name__) + + +class Annotation(BaseEntity): + """Pydantic Model representing a DataMint annotation. + + Attributes: + id: Unique identifier for the annotation. + identifier: User-friendly identifier or label for the annotation. + scope: Scope of the annotation (e.g., "frame", "image"). + frame_index: Index of the frame if scope is frame-based. + annotation_type: Type of annotation (e.g., "segmentation", "bbox", "label"). + text_value: Optional text value associated with the annotation. + numeric_value: Optional numeric value associated with the annotation. + units: Optional units for numeric_value. + geometry: Optional geometry payload (e.g., polygons, masks) as a list. + created_at: ISO timestamp for when the annotation was created. + created_by: Email or identifier of the creating user. + annotation_worklist_id: Optional worklist ID associated with the annotation. + status: Lifecycle status of the annotation (e.g., "new", "approved"). + approved_at: Optional ISO timestamp for approval time. + approved_by: Optional identifier of the approver. + resource_id: ID of the resource this annotation belongs to. + associated_file: Path or identifier of any associated file artifact. + deleted: Whether the annotation is marked as deleted. + deleted_at: Optional ISO timestamp for deletion time. + deleted_by: Optional identifier of the user who deleted the annotation. + created_by_model: Optional identifier of the model that created this annotation. + old_geometry: Optional previous geometry payload for change tracking. + set_name: Optional set name this annotation belongs to. + resource_filename: Optional filename of the resource. + resource_modality: Optional modality of the resource (e.g., CT, MR). + annotation_worklist_name: Optional worklist name associated with the annotation. + user_info: Optional user information with keys like firstname and lastname. + values: Optional extra values payload for flexible schemas. + """ + + id: str + identifier: str + scope: str + frame_index: int + annotation_type: str + text_value: str + numeric_value: float | int + units: str + geometry: list + created_at: str # ISO timestamp string + created_by: str + annotation_worklist_id: str + status: str + approved_at: str # ISO timestamp string + approved_by: str + resource_id: str + associated_file: str + deleted: bool + deleted_at: str # ISO timestamp string + deleted_by: str + created_by_model: str + old_geometry: Any + set_name: str + resource_filename: str + resource_modality: str + annotation_worklist_name: str + user_info: dict + values: Any + + # TODO: Consider constraining some fields with Literal types and parsing timestamps to datetime + # once the API schema is stable, to provide stronger validation. diff --git a/datamint/entities/project.py b/datamint/entities/project.py new file mode 100644 index 00000000..38e9b3a7 --- /dev/null +++ b/datamint/entities/project.py @@ -0,0 +1,58 @@ +"""Project entity module for DataMint API.""" + +from datetime import datetime +import logging +from .base_entity import BaseEntity, MISSING_FIELD + +logger = logging.getLogger(__name__) + + +class Project(BaseEntity): + """Pydantic Model representing a DataMint project. + + This class models a project entity from the DataMint API, containing + information about the project, its dataset, worklist, AI model, and + annotation statistics. + + Attributes: + id: Unique identifier for the project + name: Human-readable name of the project + description: Optional description of the project + created_at: ISO timestamp when the project was created + created_by: Email of the user who created the project + dataset_id: ID of the associated dataset + worklist_id: ID of the associated worklist + ai_model_id: Optional ID of the associated AI model + viewable_ai_segs: Optional configuration for viewable AI segments + editable_ai_segs: Optional configuration for editable AI segments + archived: Whether the project is archived + resource_count: Total number of resources in the project + annotated_resource_count: Number of resources that have been annotated + most_recent_experiment: Optional information about the most recent experiment + closed_resources_count: Number of resources marked as closed/completed + resources_to_annotate_count: Number of resources still needing annotation + annotators: List of annotators assigned to this project + """ + id: str + name: str + created_at: str # ISO timestamp string + created_by: str + dataset_id: str + worklist_id: str + archived: bool + resource_count: int + annotated_resource_count: int + description: str | None + ai_model_id: str | None + viewable_ai_segs: list | None + editable_ai_segs: list | None + closed_resources_count: int = MISSING_FIELD + resources_to_annotate_count: int = MISSING_FIELD + most_recent_experiment: str | None = MISSING_FIELD # ISO timestamp string + annotators: list[dict] = MISSING_FIELD + customer_id: str | None = MISSING_FIELD + archived_on: str | None = MISSING_FIELD + archived_by: str | None = MISSING_FIELD + is_active_learning: bool = MISSING_FIELD + two_up_display: bool = MISSING_FIELD + require_review: bool = MISSING_FIELD diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py new file mode 100644 index 00000000..42a4b8d3 --- /dev/null +++ b/datamint/entities/resource.py @@ -0,0 +1,130 @@ +"""Resource entity module for DataMint API.""" + +from datetime import datetime +from typing import Optional, Any +import logging +from .base_entity import BaseEntity, MISSING_FIELD +from pydantic import Field + +logger = logging.getLogger(__name__) + +class Resource(BaseEntity): + """Represents a DataMint resource with all its properties and metadata. + + This class models a resource entity from the DataMint API, containing + information about uploaded files, their metadata, and associated projects. + + Attributes: + id: Unique identifier for the resource + resource_uri: URI path to access the resource file + storage: Storage type (e.g., 'DicomResource') + location: Storage location path + upload_channel: Channel used for upload (e.g., 'tmp') + filename: Original filename of the resource + modality: Medical imaging modality + mimetype: MIME type of the file + size: File size in bytes + upload_mechanism: Mechanism used for upload (e.g., 'api') + customer_id: Customer/organization identifier + status: Current status of the resource + created_at: ISO timestamp when resource was created + created_by: Email of the user who created the resource + published: Whether the resource is published + published_on: ISO timestamp when resource was published + published_by: Email of the user who published the resource + publish_transforms: Optional publication transforms + deleted: Whether the resource is deleted + deleted_at: Optional ISO timestamp when resource was deleted + deleted_by: Optional email of the user who deleted the resource + metadata: Resource metadata with DICOM information + source_filepath: Original source file path + tags: List of tags associated with the resource + instance_uid: DICOM SOP Instance UID (top-level) + series_uid: DICOM Series Instance UID (top-level) + study_uid: DICOM Study Instance UID (top-level) + patient_id: Patient identifier (top-level) + segmentations: Optional segmentation data + measurements: Optional measurement data + categories: Optional category data + labels: List of labels associated with the resource + user_info: Information about the user who created the resource + projects: List of projects this resource belongs to + """ + id: str + resource_uri: str + storage: str + location: str + upload_channel: str + filename: str + modality: str + mimetype: str + size: int + upload_mechanism: str + customer_id: str + status: str + created_at: str + created_by: str + published: bool + deleted: bool + source_filepath: str + metadata: dict + projects: list[dict] = MISSING_FIELD + published_on: str | None + published_by: str | None + tags: list[str] | None = None + publish_transforms: Optional[Any] = None + deleted_at: Optional[str] = None + deleted_by: Optional[str] = None + instance_uid: Optional[str] = None + series_uid: Optional[str] = None + study_uid: Optional[str] = None + patient_id: Optional[str] = None + segmentations: Optional[Any] = None # TODO: Define proper type when spec available + measurements: Optional[Any] = None # TODO: Define proper type when spec available + categories: Optional[Any] = None # TODO: Define proper type when spec available + user_info: Optional[dict] = None + + @property + def size_mb(self) -> float: + """Get file size in megabytes. + + Returns: + File size in MB rounded to 2 decimal places + """ + return round(self.size / (1024 * 1024), 2) + + def is_dicom(self) -> bool: + """Check if the resource is a DICOM file. + + Returns: + True if the resource is a DICOM file, False otherwise + """ + return self.mimetype == 'application/dicom' or self.storage == 'DicomResource' + + def get_project_names(self) -> list[str]: + """Get list of project names this resource belongs to. + + Returns: + List of project names + """ + return [proj['name'] for proj in self.projects] + + def __str__(self) -> str: + """String representation of the resource. + + Returns: + Human-readable string describing the resource + """ + return f"Resource(id='{self.id}', filename='{self.filename}', size={self.size_mb}MB)" + + def __repr__(self) -> str: + """Detailed string representation of the resource. + + Returns: + Detailed string representation for debugging + """ + return ( + f"Resource(id='{self.id}', filename='{self.filename}', " + f"modality='{self.modality}', status='{self.status}', " + f"published={self.published})" + ) From 439cf05eb01e5383cda2be66ea93c49501aecc64 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 9 Sep 2025 14:55:44 -0300 Subject: [PATCH 05/13] feat: Enhance API client and endpoints - Added `Api` class to replace `APIHandler` for improved API interactions. - Updated `ApiConfig` to use `server_url` instead of `base_url`. - Introduced `AnnotationsApi` and `ResourcesApi` with pagination support. - Implemented asynchronous methods for uploading segmentations and files. - Enhanced error handling and logging for better debugging. - Updated entity models to support optional fields and missing values. - Added DTOs for annotations to streamline data handling. - Refactored upload logic in `datamint_upload.py` to utilize new API structure. --- datamint/__init__.py | 2 + datamint/api/base_api.py | 91 ++- datamint/api/client.py | 69 ++- datamint/api/dto/__init__.py | 2 + datamint/api/endpoints/__init__.py | 7 + datamint/api/endpoints/annotations_api.py | 571 ++++++++++++++++++- datamint/api/endpoints/resources_api.py | 16 +- datamint/apihandler/dto/__init__.py | 0 datamint/apihandler/dto/annotation_dto.py | 2 +- datamint/client_cmd_tools/datamint_upload.py | 49 +- datamint/entities/__init__.py | 6 + datamint/entities/annotation.py | 39 +- datamint/entities/base_entity.py | 22 +- 13 files changed, 773 insertions(+), 103 deletions(-) create mode 100644 datamint/api/dto/__init__.py create mode 100644 datamint/api/endpoints/__init__.py create mode 100644 datamint/apihandler/dto/__init__.py create mode 100644 datamint/entities/__init__.py diff --git a/datamint/__init__.py b/datamint/__init__.py index 941c8b93..eb77e435 100644 --- a/datamint/__init__.py +++ b/datamint/__init__.py @@ -8,6 +8,7 @@ from .dataset.dataset import DatamintDataset as Dataset from .apihandler.api_handler import APIHandler from .experiment import Experiment + from .api.client import Api else: import lazy_loader as lazy @@ -19,6 +20,7 @@ "dataset": ['Dataset'], "apihandler.api_handler": ["APIHandler"], "experiment": ["Experiment"], + "api.client": ["Api"], }, ) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index a6ac2ef4..70d680ff 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -19,12 +19,12 @@ class ApiConfig: """Configuration for API client. Attributes: - base_url: Base URL for the API. + server_url: Base URL for the API. api_key: Optional API key for authentication. timeout: Request timeout in seconds. max_retries: Maximum number of retries for requests. """ - base_url: str + server_url: str api_key: str | None = None timeout: float = 30.0 max_retries: int = 3 @@ -47,12 +47,12 @@ def __init__(self, def _create_client(self) -> httpx.Client: """Create and configure HTTP client with authentication and timeouts.""" - headers = {"Content-Type": "application/json"} + headers = None if self.config.api_key: - headers["apikey"] = self.config.api_key + headers = {"apikey": self.config.api_key} return httpx.Client( - base_url=self.config.base_url, + base_url=self.config.server_url, headers=headers, timeout=self.config.timeout ) @@ -101,6 +101,11 @@ def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: url = endpoint.lstrip('/') # Remove leading slash for httpx try: + curl_command = self._generate_curl_command({"method": method, + "url": url, + "headers": self.client.headers, + **kwargs}) + logger.debug(f'Equivalent curl command: "{curl_command}"') response = self.client.request(method, url, **kwargs) response.raise_for_status() return response @@ -201,7 +206,7 @@ async def _make_request_async(self, endpoint: str, session: aiohttp.ClientSession | None = None, data_to_get: Literal['json', 'text', 'content'] = 'json', - **kwargs) -> aiohttp.ClientResponse: + **kwargs): """Make asynchronous HTTP request with error handling. Args: @@ -216,7 +221,7 @@ async def _make_request_async(self, Raises: aiohttp.ClientError: If the request fails """ - url = f"{self.config.base_url.rstrip('/')}/{endpoint.lstrip('/')}" + url = f"{self.config.server_url.rstrip('/')}/{endpoint.lstrip('/')}" # Prepare headers headers = kwargs.pop('headers', {}) @@ -260,7 +265,6 @@ async def make_request(client_session: aiohttp.ClientSession) -> aiohttp.ClientR else: raise ValueError("data_to_get must be either 'json', 'text', or 'content'") - return response except aiohttp.ClientError as e: logger.error(f"Request error for {method} {endpoint}: {e}") raise @@ -275,6 +279,7 @@ def _make_request_with_pagination(self, method: str, endpoint: str, return_field: str | None = None, + limit: int | None = None, **kwargs ) -> Generator[tuple[httpx.Response, list | dict | str], None, None]: """Make paginated HTTP requests, yielding each page of results. @@ -283,30 +288,50 @@ def _make_request_with_pagination(self, method: HTTP method (GET, POST, etc.) endpoint: API endpoint path return_field: Optional field name to extract from each item in the response + limit: Optional maximum number of items to retrieve **kwargs: Additional arguments for the request (e.g., params, json) Yields: Tuples of (HTTP response, items from the current page `response.json()`, for convenience) """ offset = 0 + total_fetched = 0 params = dict(kwargs.get('params', {})) # Ensure kwargs carries our params reference so mutations below take effect kwargs['params'] = params while True: + if limit is not None and total_fetched >= limit: + break + + page_limit = _PAGE_LIMIT + if limit is not None: + remaining = limit - total_fetched + page_limit = min(_PAGE_LIMIT, remaining) + params['offset'] = offset - params['limit'] = _PAGE_LIMIT + params['limit'] = page_limit response = self._make_request(method=method, endpoint=endpoint, **kwargs) items = self._convert_array_response(response.json(), return_field=return_field) - yield response, items + + if not items: + break + + items_to_yield = items + if limit is not None: + # This ensures we don't yield more than the limit if the API returns more than requested in the last page + items_to_yield = items[:limit - total_fetched] + + yield response, items_to_yield + total_fetched += len(items_to_yield) if len(items) < _PAGE_LIMIT: break - offset += _PAGE_LIMIT + offset += len(items) def _convert_array_response(self, data: dict | list, @@ -387,7 +412,7 @@ def _stream_entity_request(self, raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e raise - def get_list(self, **kwargs) -> Sequence[T]: + def get_list(self, limit: int | None = None, **kwargs) -> Sequence[T]: """Get entities with optional filtering. Returns: @@ -405,11 +430,16 @@ def get_list(self, **kwargs) -> Sequence[T]: items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', return_field=self.endpoint_base, + limit=limit, params=params) - return [self.entity_class(**item) for resp, items in items_gen for item in items] + all_items = [] + for resp, items in items_gen: + all_items.extend(items) + + return [self.entity_class(**item) for item in all_items] - def get_all(self) -> Sequence[T]: + def get_all(self, limit: int | None = None) -> Sequence[T]: """Get all entities with optional pagination and filtering. Returns: @@ -418,7 +448,7 @@ def get_all(self) -> Sequence[T]: Raises: httpx.HTTPStatusError: If the request fails """ - return self.get_list() + return self.get_list(limit=limit) def get_by_id(self, entity_id: str) -> T: """Get a specific entity by its ID. @@ -435,7 +465,7 @@ def get_by_id(self, entity_id: str) -> T: response = self._make_entity_request('GET', entity_id) return self.entity_class(**response.json()) - def _create(self, entity_data: dict[str, Any]) -> str: + def _create(self, entity_data: dict[str, Any]) -> str | list[str | dict]: """Create a new entity. Args: @@ -451,8 +481,35 @@ def _create(self, entity_data: dict[str, Any]) -> str: respdata = response.json() if isinstance(respdata, str): return respdata + if isinstance(respdata, list): + return respdata + if isinstance(respdata, dict): + return respdata.get('id') + return respdata - return respdata.get('id') + async def _create_async(self, entity_data: dict[str, Any]) -> str | list[str | dict]: + """Create a new entity. + + Args: + entity_data: Dictionary containing entity data for creation. + + Returns: + The id of the created entity. + + Raises: + httpx.HTTPStatusError: If creation fails. + """ + respdata = await self._make_request_async('POST', + f'/{self.endpoint_base}', + data_to_get='json', + json=entity_data) + if isinstance(respdata, str): + return respdata + if isinstance(respdata, list): + return respdata + if isinstance(respdata, dict): + return respdata.get('id') + return respdata def update(self, entity_id: str, entity_data: dict[str, Any]) -> T: """Update an existing entity. diff --git a/datamint/api/client.py b/datamint/api/client.py index 39787cf9..8cf0c9ab 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -1,17 +1,24 @@ from typing import Optional import httpx from .base_api import ApiConfig -from .endpoints import ProjectsApi, ResourcesApi +from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi +import datamint.configs +from datamint.exceptions import DatamintException +import asyncio class Api: """Main API client that provides access to all endpoint handlers.""" - - def __init__(self, base_url: str, api_key: Optional[str] = None, + DEFAULT_SERVER_URL = 'https://api.datamint.io' + DATAMINT_API_VENV_NAME = datamint.configs.ENV_VARS[datamint.configs.APIKEY_KEY] + + def __init__(self, + server_url: str | None = None, + api_key: Optional[str] = None, timeout: float = 30.0, max_retries: int = 3, - client: Optional[httpx.Client] = None) -> None: + check_connection: bool = True) -> None: """Initialize the API client. - + Args: base_url: Base URL for the API api_key: Optional API key for authentication @@ -19,51 +26,73 @@ def __init__(self, base_url: str, api_key: Optional[str] = None, max_retries: Maximum number of retry attempts client: Optional HTTP client instance """ + if server_url is None: + server_url = datamint.configs.get_value(datamint.configs.APIURL_KEY) + if server_url is None: + server_url = Api.DEFAULT_SERVER_URL + server_url = server_url.rstrip('/') + if api_key is None: + api_key = datamint.configs.get_value(datamint.configs.APIKEY_KEY) + if api_key is None: + msg = f"API key not provided! Use the environment variable " + \ + f"{Api.DATAMINT_API_VENV_NAME} or pass it as an argument." + raise DatamintException(msg) + # self.semaphore = asyncio.Semaphore(20) + self.config = ApiConfig( - base_url=base_url, + server_url=server_url, api_key=api_key, timeout=timeout, max_retries=max_retries ) - self._client = client - + self._client = None # Initialize endpoint handlers self._projects = None self._annotations = None self._resources = None - + + if check_connection: + self.check_connection() + + def check_connection(self): + try: + self.projects.get_list(limit=1) + except Exception as e: + raise DatamintException("Error connecting to the Datamint API." + + f" Please check your api_key and/or other configurations. {e}") + @property def projects(self) -> ProjectsApi: """Access to project-related endpoints.""" if self._projects is None: self._projects = ProjectsApi(self.config, self._client) return self._projects - + @property def resources(self) -> ResourcesApi: """Access to resource-related endpoints.""" if self._resources is None: self._resources = ResourcesApi(self.config, self._client) return self._resources - - # @property - # def annotations(self) -> AnnotationsApi: - # """Access to annotation-related endpoints.""" - # if self._annotations is None: - # self._annotations = AnnotationsApi(self.config, self._client) - # return self._annotations - + + @property + def annotations(self) -> AnnotationsApi: + """Access to annotation-related endpoints.""" + if self._annotations is None: + self._annotations = AnnotationsApi(self.config, self._client) + return self._annotations + # def close(self) -> None: # """Close the HTTP client connections.""" # if self._projects and self._projects.client: # self._projects.client.close() # if self._annotations and self._annotations.client: # self._annotations.client.close() - + # def __enter__(self): # """Context manager entry.""" # return self - + # def __exit__(self, exc_type, exc_val, exc_tb): # """Context manager exit.""" # self.close() diff --git a/datamint/api/dto/__init__.py b/datamint/api/dto/__init__.py new file mode 100644 index 00000000..af933b5d --- /dev/null +++ b/datamint/api/dto/__init__.py @@ -0,0 +1,2 @@ +from datamint.apihandler.dto import annotation_dto +from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, Geometry, BoxGeometry \ No newline at end of file diff --git a/datamint/api/endpoints/__init__.py b/datamint/api/endpoints/__init__.py new file mode 100644 index 00000000..2cc7957b --- /dev/null +++ b/datamint/api/endpoints/__init__.py @@ -0,0 +1,7 @@ +"""API endpoint handlers.""" + +from .projects_api import ProjectsApi +from .annotations_api import AnnotationsApi +from .resources_api import ResourcesApi + +__all__ = ['ProjectsApi', 'ResourcesApi', 'AnnotationsApi'] diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 8a240c83..da245564 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -1,11 +1,25 @@ -from typing import Any, Sequence, Literal +from typing import Any, Sequence, Literal, BinaryIO, Generator, IO import httpx from datetime import date - +import logging from ..base_api import EntityBaseApi, ApiConfig from datamint.entities.annotation import Annotation from datamint.entities.resource import Resource -from datamint.apihandler.dto.annotation_dto import AnnotationType +from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto +import numpy as np +import os +import aiohttp +import json +from datamint.exceptions import DatamintException, ResourceNotFoundError +from medimgkit.nifti_utils import DEFAULT_NIFTI_MIME +from medimgkit.format_detection import guess_type +import nibabel as nib +from PIL import Image +from io import BytesIO + +_LOGGER = logging.getLogger(__name__) +_USER_LOGGER = logging.getLogger('user_logger') +MAX_NUMBER_DISTINCT_COLORS = 2048 # Maximum number of distinct colors in a segmentation image class AnnotationsApi(EntityBaseApi[Annotation]): @@ -41,6 +55,7 @@ def get_list(self, worklist_id: str | None = None, status: Literal['new', 'published'] | None = None, load_ai_segmentations: bool | None = None, + limit: int | None = None ) -> Sequence[Annotation]: payload = { 'resource_id': resource.id if isinstance(resource, Resource) else resource, @@ -56,4 +71,552 @@ def get_list(self, # remove nones payload = {k: v for k, v in payload.items() if v is not None} - return super().get_list(**payload) + return super().get_list(limit=limit, **payload) + + async def _upload_segmentations_async(self, + resource_id: str, + frame_index: int | None, + file_path: str | np.ndarray, + name: dict[int, str] | dict[tuple, str], + imported_from: str | None = None, + author_email: str | None = None, + discard_empty_segmentations: bool = True, + worklist_id: str | None = None, + model_id: str | None = None, + transpose_segmentation: bool = False, + upload_volume: bool | str = 'auto' + ) -> list[str]: + """ + Upload segmentations asynchronously. + + Args: + resource_id: The resource unique id. + frame_index: The frame index or None for multiple frames. + file_path: Path to segmentation file or numpy array. + name: The name of the segmentation or mapping of pixel values to names. + imported_from: The imported from value. + author_email: The author email. + discard_empty_segmentations: Whether to discard empty segmentations. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + transpose_segmentation: Whether to transpose the segmentation. + upload_volume: Whether to upload the volume as a single file or split into frames. + + Returns: + List of annotation IDs created. + """ + if upload_volume == 'auto': + if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')): + upload_volume = True + else: + upload_volume = False + + # Handle volume upload + if upload_volume: + if frame_index is not None: + _LOGGER.warning("frame_index parameter ignored when upload_volume=True") + + return await self._upload_volume_segmentation_async( + resource_id=resource_id, + file_path=file_path, + name=name, + imported_from=imported_from, + author_email=author_email, + worklist_id=worklist_id, + model_id=model_id, + transpose_segmentation=transpose_segmentation + ) + + # Handle frame-by-frame upload (existing logic) + nframes, fios = AnnotationsApi._generate_segmentations_ios( + file_path, transpose_segmentation=transpose_segmentation + ) + if frame_index is None: + frames_indices = list(range(nframes)) + elif isinstance(frame_index, int): + frames_indices = [frame_index] + else: + raise ValueError("frame_index must be an int or None") + + annotids = [] + for fidx, f in zip(frames_indices, fios): + frame_annotids = await self._upload_single_frame_segmentation_async( + resource_id=resource_id, + frame_index=fidx, + fio=f, + name=name, + imported_from=imported_from, + author_email=author_email, + discard_empty_segmentations=discard_empty_segmentations, + worklist_id=worklist_id, + model_id=model_id + ) + annotids.extend(frame_annotids) + return annotids + + async def _upload_single_frame_segmentation_async(self, + resource_id: str, + frame_index: int | None, + fio: IO, + name: dict[int, str] | dict[tuple, str], + imported_from: str | None = None, + author_email: str | None = None, + discard_empty_segmentations: bool = True, + worklist_id: str | None = None, + model_id: str | None = None + ) -> list[str]: + """ + Upload a single frame segmentation asynchronously. + + Args: + resource_id: The resource unique id. + frame_index: The frame index for the segmentation. + fio: File-like object containing the segmentation image. + name: The name of the segmentation, a dictionary mapping pixel values to names, + or a dictionary mapping RGB tuples to names. + imported_from: The imported from value. + author_email: The author email. + discard_empty_segmentations: Whether to discard empty segmentations. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + + Returns: + List of annotation IDs created. + """ + try: + try: + img_pil = Image.open(fio) + img_array = np.array(img_pil) # shape: (height, width, channels) + # Returns a list of (count, color) tuples + unique_vals = img_pil.getcolors(maxcolors=MAX_NUMBER_DISTINCT_COLORS) + # convert to list of RGB tuples + if unique_vals is None: + raise ValueError(f'Number of unique colors exceeds {MAX_NUMBER_DISTINCT_COLORS}.') + unique_vals = [color for count, color in unique_vals] + # Remove black/transparent pixels + black_pixel = (0, 0, 0) + unique_vals = [rgb for rgb in unique_vals if rgb != black_pixel] + + if discard_empty_segmentations: + if len(unique_vals) == 0: + msg = f"Discarding empty RGB segmentation for frame {frame_index}" + _LOGGER.debug(msg) + _USER_LOGGER.debug(msg) + return [] + segnames = AnnotationsApi._get_segmentation_names_rgb(unique_vals, names=name) + segs_generator = AnnotationsApi._split_rgb_segmentations(img_array, unique_vals) + + fio.seek(0) + # TODO: Optimize this. It is not necessary to open the image twice. + + # Create annotations + annotations: list[CreateAnnotationDto] = [] + for segname in segnames: + ann = CreateAnnotationDto( + type='segmentation', + identifier=segname, + scope='frame', + frame_index=frame_index, + imported_from=imported_from, + import_author=author_email, + model_id=model_id, + annotation_worklist_id=worklist_id + ) + annotations.append(ann) + + # Validate unique identifiers + if len(annotations) != len(set([a.identifier for a in annotations])): + raise ValueError( + "Multiple annotations with the same identifier, frame_index, scope and author is not supported yet." + ) + + annotids = await self._create_async(resource_id=resource_id, annotations_dto=annotations) + + # Upload segmentation files + if len(annotids) != len(segnames): + _LOGGER.warning(f"Number of uploaded annotations ({len(annotids)})" + + f" does not match the number of annotations ({len(segnames)})") + + for annotid, segname, fio_seg in zip(annotids, segnames, segs_generator): + await self.upload_annotation_file_async(resource_id, annotid, fio_seg, + content_type='image/png', + filename=segname) + return annotids + finally: + fio.close() + except ResourceNotFoundError: + raise ResourceNotFoundError('resource', {'resource_id': resource_id}) + + def _prepare_upload_file(self, + file: str | IO, + filename: str | None = None, + content_type: str | None = None + ) -> tuple[IO, str, bool, str | None]: + if isinstance(file, str): + if filename is None: + filename = os.path.basename(file) + f = open(file, 'rb') + close_file = True + else: + f = file + if filename is None: + if hasattr(f, 'name') and isinstance(f.name, str): + filename = f.name + else: + filename = 'unnamed_file' + close_file = False + + if content_type is None: + content_type, _ = guess_type(filename, use_magic=False) + + return f, filename, close_file, content_type + + async def upload_annotation_file_async(self, + resource_id: str, + annotation_id: str, + file: str | IO, + content_type: str | None = None, + filename: str | None = None + ): + """ + Upload a file for an existing annotation asynchronously. + + Args: + resource_id: The resource unique id. + annotation_id: The annotation unique id. + file: Path to the file or a file-like object. + content_type: The MIME type of the file. + filename: Optional filename to use in the upload. If None and file is a path, + the basename of the path will be used. + + Raises: + DatamintException: If the upload fails. + + Example: + .. code-block:: python + + await ann_api.upload_annotation_file_async( + resource_id='your_resource_id', + annotation_id='your_annotation_id', + file='path/to/your/file.png', + content_type='image/png', + filename='custom_name.png' + ) + """ + f, filename, close_file, content_type = self._prepare_upload_file(file, + filename, + content_type=content_type) + + try: + form = aiohttp.FormData() + form.add_field('file', f, filename=filename, content_type=content_type) + respdata = await self._make_request_async(method='POST', + endpoint=f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file', + data=form) + if isinstance(respdata, dict) and 'error' in respdata: + raise DatamintException(respdata['error']) + finally: + if close_file: + f.close() + + def upload_annotation_file(self, + resource_id: str, + annotation_id: str, + file: str | IO, + content_type: str | None = None, + filename: str | None = None + ): + """ + Upload a file for an existing annotation. + + Args: + resource_id: The resource unique id. + annotation_id: The annotation unique id. + file: Path to the file or a file-like object. + content_type: The MIME type of the file. + filename: Optional filename to use in the upload. If None and file is a path, + the basename of the path will be used. + + Raises: + DatamintException: If the upload fails. + """ + f, filename, close_file, content_type = self._prepare_upload_file(file, + filename, + content_type=content_type) + try: + files = { + 'file': (filename, f, content_type) + } + resp = self._make_request(method='POST', + endpoint=f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file', + files=files) + respdata = resp.json() + if isinstance(respdata, dict) and 'error' in respdata: + raise DatamintException(respdata['error']) + finally: + if close_file: + f.close() + + def create(self, + resource: str | Resource, + annotation_dto: CreateAnnotationDto | Sequence[CreateAnnotationDto] + ) -> str | Sequence[str]: + """Create a new annotation. + + Args: + resource: The resource unique id or Resource instance. + annotation_dto: A CreateAnnotationDto instance or a list of such instances. + + Returns: + The id of the created annotation or a list of ids if multiple annotations were created. + """ + + annotations = [annotation_dto] if isinstance(annotation_dto, CreateAnnotationDto) else annotation_dto + annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations] + resource_id = resource.id if isinstance(resource, Resource) else resource + respdata = self._make_request('POST', + f'{self.endpoint_base}/{resource_id}/annotations', + json=annotations).json() + for r in respdata: + if isinstance(r, dict) and 'error' in r: + raise DatamintException(r['error']) + if isinstance(annotation_dto, CreateAnnotationDto): + return respdata[0] + return respdata + + async def _create_async(self, + resource_id: str, + annotations_dto: list[CreateAnnotationDto] | list[dict]) -> list[str]: + annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations_dto] + respdata = await self._make_request_async('POST', + f'{self.endpoint_base}/{resource_id}/annotations', + data_to_get='json', + json=annotations) + for r in respdata: + if isinstance(r, dict) and 'error' in r: + raise DatamintException(r['error']) + return respdata + + @staticmethod + def _get_segmentation_names_rgb(uniq_rgb_vals: list[tuple[int, int, int]], + names: dict[tuple[int, int, int], str] + ) -> list[str]: + """ + Generate segmentation names for RGB combinations. + + Args: + uniq_rgb_vals: List of unique RGB combinations as (R,G,B) tuples + names: Name mapping for RGB combinations + + Returns: + List of segmentation names + """ + result = [] + for rgb_tuple in uniq_rgb_vals: + seg_name = names.get(rgb_tuple, names.get('default', f'seg_{"_".join(map(str, rgb_tuple))}')) + if seg_name is None: + if rgb_tuple[0] == rgb_tuple[1] and rgb_tuple[1] == rgb_tuple[2]: + msg = f"Provide a name for {rgb_tuple} or {rgb_tuple[0]} or use 'default' key." + else: + msg = f"Provide a name for {rgb_tuple} or use 'default' key." + raise ValueError(f"RGB combination {rgb_tuple} not found in names dictionary. " + + msg) + # If using default prefix, append RGB values + # if rgb_tuple not in names and 'default' in names: + # seg_name = f"{seg_name}_{'_'.join(map(str, rgb_tuple))}" + result.append(seg_name) + return result + + @staticmethod + def _split_rgb_segmentations(img: np.ndarray, + uniq_rgb_vals: list[tuple[int, int, int]] + ) -> Generator[BytesIO, None, None]: + """ + Split RGB segmentations into individual binary masks. + + Args: + img: RGB image array of shape (height, width, channels) + uniq_rgb_vals: List of unique RGB combinations as (R,G,B) tuples + + Yields: + BytesIO objects containing individual segmentation masks + """ + for rgb_tuple in uniq_rgb_vals: + # Create binary mask for this RGB combination + rgb_array = np.array(rgb_tuple[:3]) # Ensure only R,G,B values + mask = np.all(img[:, :, :3] == rgb_array, axis=2) + + # Convert to uint8 and create PNG + mask_img = (mask * 255).astype(np.uint8) + + f_out = BytesIO() + Image.fromarray(mask_img).convert('L').save(f_out, format='PNG') + f_out.seek(0) + yield f_out + + async def _upload_volume_segmentation_async(self, + resource_id: str, + file_path: str | np.ndarray, + name: str | dict[int, str] | dict[tuple, str] | None, + imported_from: str | None = None, + author_email: str | None = None, + worklist_id: str | None = None, + model_id: str | None = None, + transpose_segmentation: bool = False + ) -> list[str]: + """ + Upload a volume segmentation as a single file asynchronously. + + Args: + resource_id: The resource unique id. + file_path: Path to segmentation file or numpy array. + name: The name of the segmentation (string only for volumes). + imported_from: The imported from value. + author_email: The author email. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + transpose_segmentation: Whether to transpose the segmentation. + + Returns: + List of annotation IDs created. + + Raises: + ValueError: If name is not a string or file format is unsupported for volume upload. + """ + + if isinstance(name, str): + raise NotImplementedError("`name=string` is not supported yet for volume segmentation.") + if isinstance(name, dict): + if any(isinstance(k, tuple) for k in name.keys()): + raise NotImplementedError( + "For volume segmentations, `name` must be a dictionary with integer keys only.") + + # Prepare file for upload + if isinstance(file_path, str): + if file_path.endswith('.nii') or file_path.endswith('.nii.gz'): + # Upload NIfTI file directly + with open(file_path, 'rb') as f: + filename = os.path.basename(file_path) + form = aiohttp.FormData() + form.add_field('file', f, filename=filename, content_type=DEFAULT_NIFTI_MIME) + if model_id is not None: + form.add_field('model_id', model_id) # Add model_id if provided + if worklist_id is not None: + form.add_field('annotation_worklist_id', worklist_id) + if name is not None: + form.add_field('segmentation_map', json.dumps(name), content_type='application/json') + + resp = await self._make_request_async(method='POST', + endpoint=f'{self.endpoint_base}/{resource_id}/segmentations/file', + data=form, + data_to_get='json') + if 'error' in resp: + raise DatamintException(resp['error']) + return resp + else: + raise ValueError(f"Volume upload not supported for file format: {file_path}") + elif isinstance(file_path, np.ndarray): + raise NotImplementedError + else: + raise ValueError(f"Unsupported file_path type for volume upload: {type(file_path)}") + + _USER_LOGGER.info(f'Volume segmentation uploaded for resource {resource_id}') + + @staticmethod + def _generate_segmentations_ios(file_path: str | np.ndarray, + transpose_segmentation: bool = False + ) -> tuple[int, Generator[BinaryIO, None, None]]: + if not isinstance(file_path, (str, np.ndarray)): + raise ValueError(f"Unsupported file type: {type(file_path)}") + + if isinstance(file_path, np.ndarray): + normalized_imgs = AnnotationsApi._normalize_segmentation_array(file_path) + # normalized_imgs shape: (3, height, width, #frames) + + # Apply transpose if requested + if transpose_segmentation: + # (channels, height, width, frames) -> (channels, width, height, frames) + normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3) + + nframes = normalized_imgs.shape[3] + fios = AnnotationsApi._numpy_to_bytesio_png(normalized_imgs) + + elif file_path.endswith('.nii') or file_path.endswith('.nii.gz'): + segs_imgs = nib.load(file_path).get_fdata() + if segs_imgs.ndim != 3 and segs_imgs.ndim != 2: + raise ValueError(f"Invalid segmentation shape: {segs_imgs.shape}") + + # Normalize and apply transpose + normalized_imgs = AnnotationsApi._normalize_segmentation_array(segs_imgs) + if not transpose_segmentation: + # Apply default NIfTI transpose + # (channels, width, height, frames) -> (channels, height, width, frames) + normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3) + + nframes = normalized_imgs.shape[3] + fios = AnnotationsApi._numpy_to_bytesio_png(normalized_imgs) + + elif file_path.endswith('.png'): + with Image.open(file_path) as img: + img_array = np.array(img) + normalized_imgs = AnnotationsApi._normalize_segmentation_array(img_array) + + if transpose_segmentation: + normalized_imgs = normalized_imgs.transpose(0, 2, 1, 3) + + fios = AnnotationsApi._numpy_to_bytesio_png(normalized_imgs) + nframes = 1 + else: + raise ValueError(f"Unsupported file format of '{file_path}'") + + return nframes, fios + + @staticmethod + def _normalize_segmentation_array(seg_imgs: np.ndarray) -> np.ndarray: + """ + Normalize segmentation array to a consistent format. + + Args: + seg_imgs: Input segmentation array in various formats: (height, width, #frames), (height, width), (3, height, width, #frames). + + Returns: + np.ndarray: Shape (#channels, height, width, #frames) + """ + if seg_imgs.ndim == 4: + return seg_imgs # .transpose(1, 2, 0, 3) + + # Handle grayscale segmentations + if seg_imgs.ndim == 2: + # Add frame dimension: (height, width) -> (height, width, 1) + seg_imgs = seg_imgs[..., None] + if seg_imgs.ndim == 3: + # (height, width, #frames) + seg_imgs = seg_imgs[np.newaxis, ...] # Add channel dimension: (1, height, width, #frames) + + return seg_imgs + + @staticmethod + def _numpy_to_bytesio_png(seg_imgs: np.ndarray) -> Generator[BinaryIO, None, None]: + """ + Convert normalized segmentation images to PNG BytesIO objects. + + Args: + seg_imgs: Normalized segmentation array in shape (channels, height, width, frames). + + Yields: + BinaryIO: PNG image data as BytesIO objects + """ + # PIL RGB format is: (height, width, channels) + if seg_imgs.shape[0] not in [1, 3, 4]: + raise ValueError(f"Unsupported number of channels: {seg_imgs.shape[0]}. Expected 1 or 3") + nframes = seg_imgs.shape[3] + for i in range(nframes): + img = seg_imgs[:, :, :, i].astype(np.uint8) + if img.shape[0] == 1: + pil_img = Image.fromarray(img[0]).convert('RGB') + else: + pil_img = Image.fromarray(img.transpose(1, 2, 0)) + img_bytes = BytesIO() + pil_img.save(img_bytes, format='PNG') + img_bytes.seek(0) + yield img_bytes diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index f13b1be6..01484267 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -74,6 +74,7 @@ def get_list(self, channel: Optional[str] = None, project_name: str | list[str] | None = None, filename: Optional[str] = None, + limit: int | None = None ) -> Sequence[Resource]: """Get resources with optional filtering. Args: @@ -131,7 +132,7 @@ def get_list(self, } payload['tags'] = json.dumps(tags_filter) - return super().get_list(**payload) + return super().get_list(limit=limit,**payload) def get_annotations(self, resource_id: str | Resource) -> Sequence[Annotation]: """Get annotations for a specific resource. @@ -430,12 +431,13 @@ async def __upload_single_resource(file_path, segfiles: dict[str, list | dict], desc=f"Uploading segmentations for {file_path}", total=len(fpaths)): if f is not None: - raise NotImplementedError("Uploading segmentations is not implemented yet.") - # await self._upload_segmentations_async(rid, - # file_path=f, - # name=name, - # frame_index=frame_index, - # transpose_segmentation=transpose_segmentation) + await self.annotations_api._upload_segmentations_async( + rid, + file_path=f, + name=name, + frame_index=frame_index, + transpose_segmentation=transpose_segmentation + ) return rid tasks = [__upload_single_resource(f, segfiles, metadata_file) diff --git a/datamint/apihandler/dto/__init__.py b/datamint/apihandler/dto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/datamint/apihandler/dto/annotation_dto.py b/datamint/apihandler/dto/annotation_dto.py index 9e3df144..285d4eb3 100644 --- a/datamint/apihandler/dto/annotation_dto.py +++ b/datamint/apihandler/dto/annotation_dto.py @@ -152,7 +152,7 @@ def __init__(self, type: AnnotationType | str, identifier: str, scope: str, - annotation_worklist_id: str, + annotation_worklist_id: str | None = None, value=None, imported_from: str | None = None, import_author: str | None = None, diff --git a/datamint/client_cmd_tools/datamint_upload.py b/datamint/client_cmd_tools/datamint_upload.py index 13fd80dd..f2663197 100644 --- a/datamint/client_cmd_tools/datamint_upload.py +++ b/datamint/client_cmd_tools/datamint_upload.py @@ -1,6 +1,7 @@ from datamint.exceptions import DatamintException import argparse -from datamint.apihandler.api_handler import APIHandler +# from datamint.apihandler.api_handler import APIHandler +from datamint import Api import os from humanize import naturalsize import logging @@ -780,45 +781,33 @@ def main(): has_a_dicom_file = any(is_dicom(f) for f in files_path) try: - api_handler = APIHandler(check_connection=True) + api = Api(check_connection=True) except DatamintException as e: _USER_LOGGER.error(f'❌ Connection failed: {e}') return try: - results = api_handler.upload_resources(channel=args.channel, - files_path=files_path, - tags=args.tag, - on_error='skip', - anonymize=args.retain_pii == False and has_a_dicom_file, - anonymize_retain_codes=args.retain_attribute, - mung_filename=args.mungfilename, - publish=args.publish, - segmentation_files=segfiles, - transpose_segmentation=args.transpose_segmentation, - assemble_dicoms=True, - metadata=metadata_files, - progress_bar=True - ) + print('>>>', segfiles) + results = api.resources.upload_resources(channel=args.channel, + files_path=files_path, + tags=args.tag, + on_error='skip', + anonymize=args.retain_pii == False and has_a_dicom_file, + anonymize_retain_codes=args.retain_attribute, + mung_filename=args.mungfilename, + publish=args.publish, + publish_to=args.project, + segmentation_files=segfiles, + transpose_segmentation=args.transpose_segmentation, + assemble_dicoms=True, + metadata=metadata_files, + progress_bar=True + ) except pydicom.errors.InvalidDicomError as e: _USER_LOGGER.error(f'❌ Invalid DICOM file: {e}') return _USER_LOGGER.info('Upload finished!') _LOGGER.debug(f"Number of results: {len(results)}") - # Add resources to project if specified - if args.project is not None: - _USER_LOGGER.info(f"Adding uploaded resources to project '{args.project}'...") - try: - # Filter successful uploads to get resource IDs - successful_resource_ids = [r for r in results if not isinstance(r, Exception)] - if successful_resource_ids: - api_handler.add_to_project(project_name=args.project, resource_ids=successful_resource_ids) - _USER_LOGGER.info(f"✅ Successfully added {len(successful_resource_ids)} resources to project '{args.project}'") - else: - _USER_LOGGER.warning("No successful uploads to add to project") - except Exception as e: - _USER_LOGGER.error(f"❌ Failed to add resources to project '{args.project}': {e}") - num_failures = print_results_summary(files_path, results) if num_failures > 0: sys.exit(1) diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py new file mode 100644 index 00000000..cc770b05 --- /dev/null +++ b/datamint/entities/__init__.py @@ -0,0 +1,6 @@ +"""DataMint entities package.""" + +from .project import Project +from .resource import Resource + +__all__ = ['Project', 'Resource'] diff --git a/datamint/entities/annotation.py b/datamint/entities/annotation.py index 44c89464..5ffb7ad3 100644 --- a/datamint/entities/annotation.py +++ b/datamint/entities/annotation.py @@ -7,7 +7,7 @@ from typing import Any import logging -from .base_entity import BaseEntity +from .base_entity import BaseEntity, MISSING_FIELD logger = logging.getLogger(__name__) @@ -49,31 +49,30 @@ class Annotation(BaseEntity): id: str identifier: str scope: str - frame_index: int + frame_index: int | None annotation_type: str - text_value: str - numeric_value: float | int - units: str - geometry: list + text_value: str | None + numeric_value: float | int | None + units: str | None + geometry: list | dict | None created_at: str # ISO timestamp string created_by: str - annotation_worklist_id: str + annotation_worklist_id: str | None status: str - approved_at: str # ISO timestamp string - approved_by: str + approved_at: str | None # ISO timestamp string + approved_by: str | None resource_id: str - associated_file: str + associated_file: str | None deleted: bool - deleted_at: str # ISO timestamp string - deleted_by: str - created_by_model: str - old_geometry: Any - set_name: str - resource_filename: str - resource_modality: str - annotation_worklist_name: str - user_info: dict - values: Any + deleted_at: str | None # ISO timestamp string + deleted_by: str | None + created_by_model: str | None + set_name: str | None + resource_filename: str | None + resource_modality: str | None + annotation_worklist_name: str | None + user_info: dict | None + values: list | None = MISSING_FIELD # TODO: Consider constraining some fields with Literal types and parsing timestamps to datetime # once the API schema is stable, to provide stronger validation. diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py index b90d620f..d857424b 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -11,6 +11,9 @@ MISSING_FIELD = 'MISSING_FIELD' # Used when a field is sometimes missing for one endpoint but not on another endpoint +# Track logged warnings to avoid duplicates +_LOGGED_WARNINGS: set[tuple[str, str]] = set() + class BaseEntity(BaseModel): """ @@ -31,7 +34,18 @@ def asjson(self) -> str: """Convert the entity to a JSON string, including unknown fields.""" return self.model_dump_json(warnings='none') - # def model_post_init(self, __context: Any) -> None: - # if self.__pydantic_extra__: - # _LOGGER.warning(f"Unknown fields found in {self.__class__.__name__} " - # f"fields: {self.__pydantic_extra__.keys()}. ") + def model_post_init(self, __context: Any) -> None: + """Handle unknown fields by logging a warning once per class/field combination in debug mode.""" + if self.__pydantic_extra__ and _LOGGER.isEnabledFor(logging.DEBUG): + class_name = self.__class__.__name__ + + have_to_log = False + for key in self.__pydantic_extra__.keys(): + warning_key = (class_name, key) + + if warning_key not in _LOGGED_WARNINGS: + _LOGGED_WARNINGS.add(warning_key) + have_to_log = True + + if have_to_log: + _LOGGER.warning(f"Unknown fields {list(self.__pydantic_extra__.keys())} found in {class_name}") From 838acf62228272f752c916b3fead6774f412d2e5 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 9 Sep 2025 16:05:23 -0300 Subject: [PATCH 06/13] feat: Enhance resource and annotation APIs with new download and conversion functionalities --- datamint/api/base_api.py | 65 ++++-- datamint/api/endpoints/annotations_api.py | 26 ++- datamint/api/endpoints/resources_api.py | 263 +++++++++++++++++++++- 3 files changed, 320 insertions(+), 34 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 70d680ff..139a55ef 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -6,6 +6,13 @@ from datamint.exceptions import DatamintException, ResourceNotFoundError import aiohttp import json +import pydicom.dataset +from PIL import Image +import cv2 +import nibabel as nib +from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage +from io import BytesIO +import gzip logger = logging.getLogger(__name__) @@ -205,7 +212,6 @@ async def _make_request_async(self, method: str, endpoint: str, session: aiohttp.ClientSession | None = None, - data_to_get: Literal['json', 'text', 'content'] = 'json', **kwargs): """Make asynchronous HTTP request with error handling. @@ -255,15 +261,7 @@ async def make_request(client_session: aiohttp.ClientSession) -> aiohttp.ClientR # error_text = await response.text() # logger.error(f"HTTP error {response.status} for {method} {endpoint}: {error_text}") self._check_errors_response(response, url=url) - - if data_to_get == 'json': - return await response.json() - elif data_to_get == 'text': - return await response.text() - elif data_to_get == 'content': - return await response.read() - else: - raise ValueError("data_to_get must be either 'json', 'text', or 'content'") + return response except aiohttp.ClientError as e: logger.error(f"Request error for {method} {endpoint}: {e}") @@ -359,6 +357,38 @@ def _convert_array_response(self, items = items[0][return_field] return items + @staticmethod + def convert_format(bytes_array: bytes, + mimetype: str, + file_path: str | None = None + ) -> pydicom.dataset.Dataset | Image.Image | cv2.VideoCapture | bytes | nib_FileBasedImage: + """ Convert the bytes array to the appropriate format based on the mimetype.""" + content_io = BytesIO(bytes_array) + if mimetype.endswith('/dicom'): + return pydicom.dcmread(content_io) + elif mimetype.startswith('image/'): + return Image.open(content_io) + elif mimetype.startswith('video/'): + if file_path is None: + raise NotImplementedError("file_path=None is not implemented yet for video/* mimetypes.") + return cv2.VideoCapture(file_path) + elif mimetype == 'application/json': + return json.loads(bytes_array) + elif mimetype == 'application/octet-stream': + return bytes_array + elif mimetype.endswith('nifti'): + try: + return nib.Nifti1Image.from_stream(content_io) + except Exception as e: + if file_path is not None: + return nib.load(file_path) + raise e + elif mimetype == 'application/gzip': + # let's hope it's a .nii.gz + with gzip.open(content_io, 'rb') as f: + return nib.Nifti1Image.from_stream(f) + + raise ValueError(f"Unsupported mimetype: {mimetype}") class EntityBaseApi(BaseApi, Generic[T]): """Base API handler for entity-related endpoints with CRUD operations. @@ -386,12 +416,17 @@ def __init__(self, config: ApiConfig, self.entity_class = entity_class self.endpoint_base = endpoint_base.strip('/') + @staticmethod + def _entid(entity: BaseEntity | str) -> str: + return entity if isinstance(entity, str) else entity.id + def _make_entity_request(self, method: str, - entity_id: str, + entity_id: str | BaseEntity, add_path: str = '', **kwargs) -> httpx.Response: try: + entity_id = self._entid(entity_id) add_path = '/'.join(add_path.strip().strip('/').split('/')) return self._make_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) except httpx.HTTPStatusError as e: @@ -499,10 +534,10 @@ async def _create_async(self, entity_data: dict[str, Any]) -> str | list[str | d Raises: httpx.HTTPStatusError: If creation fails. """ - respdata = await self._make_request_async('POST', - f'/{self.endpoint_base}', - data_to_get='json', - json=entity_data) + resp = await self._make_request_async('POST', + f'/{self.endpoint_base}', + json=entity_data) + respdata = await resp.json() if isinstance(respdata, str): return respdata if isinstance(respdata, list): diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index da245564..c7601f4b 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -310,9 +310,11 @@ async def upload_annotation_file_async(self, try: form = aiohttp.FormData() form.add_field('file', f, filename=filename, content_type=content_type) - respdata = await self._make_request_async(method='POST', - endpoint=f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file', - data=form) + endpoint = f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file' + resp = await self._make_request_async(method='POST', + endpoint=endpoint, + data=form) + respdata = await resp.json() if isinstance(respdata, dict) and 'error' in respdata: raise DatamintException(respdata['error']) finally: @@ -388,10 +390,10 @@ async def _create_async(self, resource_id: str, annotations_dto: list[CreateAnnotationDto] | list[dict]) -> list[str]: annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations_dto] - respdata = await self._make_request_async('POST', - f'{self.endpoint_base}/{resource_id}/annotations', - data_to_get='json', - json=annotations) + resp = await self._make_request_async('POST', + f'{self.endpoint_base}/{resource_id}/annotations', + json=annotations) + respdata = await resp.json() for r in respdata: if isinstance(r, dict) and 'error' in r: raise DatamintException(r['error']) @@ -508,11 +510,11 @@ async def _upload_volume_segmentation_async(self, resp = await self._make_request_async(method='POST', endpoint=f'{self.endpoint_base}/{resource_id}/segmentations/file', - data=form, - data_to_get='json') - if 'error' in resp: - raise DatamintException(resp['error']) - return resp + data=form) + respdata = await resp.json() + if 'error' in respdata: + raise DatamintException(respdata['error']) + return respdata else: raise ValueError(f"Volume upload not supported for file format: {file_path}") elif isinstance(file_path, np.ndarray): diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 01484267..80cedef6 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -1,10 +1,10 @@ from typing import Any, Optional, Sequence, TypeAlias, Literal, IO -from ..base_api import EntityBaseApi, ApiConfig +from ..base_api import EntityBaseApi, ApiConfig, BaseApi from .annotations_api import AnnotationsApi from .projects_api import ProjectsApi from datamint.entities.resource import Resource from datamint.entities.annotation import Annotation -from datamint.exceptions import DatamintException +from datamint.exceptions import DatamintException, ResourceNotFoundError import httpx from datetime import date import json @@ -23,6 +23,11 @@ import aiohttp from pathlib import Path import nest_asyncio # For running asyncio in jupyter notebooks +import cv2 +from PIL import Image +from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage +import io + _LOGGER = logging.getLogger(__name__) _USER_LOGGER = logging.getLogger('user_logger') @@ -132,7 +137,7 @@ def get_list(self, } payload['tags'] = json.dumps(tags_filter) - return super().get_list(limit=limit,**payload) + return super().get_list(limit=limit, **payload) def get_annotations(self, resource_id: str | Resource) -> Sequence[Annotation]: """Get annotations for a specific resource. @@ -355,10 +360,10 @@ async def _upload_single_resource_async(self, except Exception as e: _LOGGER.warning(f"Failed to add metadata to form: {e}") - resp_data = await self._make_request_async(method='POST', - endpoint=self.endpoint_base, - data=form, - data_to_get='json') + resp = await self._make_request_async(method='POST', + endpoint=self.endpoint_base, + data=form) + resp_data = await resp.json() if 'error' in resp_data: raise DatamintException(resp_data['error']) _LOGGER.debug(f"Response on uploading {name}: {resp_data}") @@ -612,3 +617,247 @@ def upload_resources(self, if is_multiple_resources: return resource_ids return resource_ids[0] + + def _determine_mimetype(self, + content, + resource: str | Resource) -> tuple[str | None, str | None]: + # Determine mimetype from file content + mimetype_list, ext = guess_typez(content, use_magic=True) + mimetype = mimetype_list[-1] + + # get mimetype from resource info if not detected + if mimetype is None or mimetype == DEFAULT_MIME_TYPE: + if not isinstance(resource, Resource): + resource = self.get_by_id(resource) + mimetype = resource.mimetype or mimetype + + return mimetype, ext + + async def _async_download_file(self, + resource: str | Resource, + save_path: str | Path, + session: aiohttp.ClientSession | None = None, + progress_bar: tqdm | None = None, + add_extension: bool = False) -> str: + """ + Asynchronously download a file from the server. + + Args: + resource: The resource unique id or Resource object. + save_path: The path to save the file. + session: The aiohttp session to use for the request. + progress_bar: Optional progress bar to update after download completion. + add_extension: Whether to add the appropriate file extension based on content type. + + Returns: + str: The actual path where the file was saved (important when add_extension=True). + """ + save_path = str(save_path) # Ensure save_path is a string for file operations + resource_id = self._entid(resource) + try: + resp = await self._make_request_async('GET', + f'{self.endpoint_base}/{resource_id}/file', + session=session, + headers={'accept': 'application/octet-stream'}) + data_bytes = await resp.read() + + final_save_path = save_path + if add_extension: + # Save to temporary file first to determine mimetype from content + temp_path = f"{save_path}.tmp" + with open(temp_path, 'wb') as f: + f.write(data_bytes) + + # Determine mimetype from file content + mimetype, ext = self._determine_mimetype(content=data_bytes, + resource=resource) + + # Generate final path with extension if needed + if mimetype is not None and mimetype != DEFAULT_MIME_TYPE: + if ext is None: + ext = guess_extension(mimetype) + if ext is not None and not save_path.endswith(ext): + final_save_path = save_path + ext + + # Move file to final location + os.rename(temp_path, final_save_path) + else: + # Standard save without extension detection + with open(final_save_path, 'wb') as f: + f.write(data_bytes) + + if progress_bar: + progress_bar.update(1) + + return final_save_path + + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': resource_id}) + raise e + + def download_multiple_resources(self, + resources: Sequence[str] | Sequence[Resource], + save_path: Sequence[str] | str, + add_extension: bool = False, + ) -> list[str]: + """ + Download multiple resources and save them to the specified paths. + This is faster than downloading them one by one. + + Args: + resources: A list of resource unique ids. + save_path : A list of paths to save the files or a directory path, of same length as resources. + If a directory path is provided, files will be saved in that directory. + add_extension: Whether to add the appropriate file extension to the save_path based on the content type. + + Returns: + list[str]: A list of paths where the files were saved. Important if `add_extension=True`. + """ + if isinstance(resources, str): + raise ValueError("resources must be a list of resources") + + async def _download_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._async_download_file( + resource=r, + save_path=path, + session=session, + progress_bar=progress_bar, + add_extension=add_extension + ) + for r, path in zip(resources, save_path) + ] + return await asyncio.gather(*tasks) + + if isinstance(save_path, str): + save_path = [os.path.join(save_path, self._entid(r)) for r in resources] + + with tqdm(total=len(resources), desc="Downloading resources", unit="file") as progress_bar: + loop = asyncio.get_event_loop() + final_save_paths = loop.run_until_complete(_download_all_async()) + + return final_save_paths + + def download_resource_file(self, + resource: str | Resource, + save_path: Optional[str] = None, + auto_convert: bool = True, + add_extension: bool = False + ) -> bytes | pydicom.dataset.Dataset | Image.Image | cv2.VideoCapture | nib_FileBasedImage | tuple[Any, str]: + """ + Download a resource file. + + Args: + resource: The resource unique id. + save_path: The path to save the file. + auto_convert: Whether to convert the file to a known format or not. + add_extension: Whether to add the appropriate file extension to the save_path based on the content type. + + Returns: + The resource content in bytes (if `auto_convert=False`) or the resource object (if `auto_convert=True`). + if `add_extension=True`, the function will return a tuple of (resource_data, save_path). + + Raises: + ResourceNotFoundError: If the resource does not exists. + + Example: + >>> api_handler.download_resource_file('resource_id', auto_convert=False) + returns the resource content in bytes. + >>> api_handler.download_resource_file('resource_id', auto_convert=True) + Assuming this resource is a dicom file, it will return a pydicom.dataset.Dataset object. + >>> api_handler.download_resource_file('resource_id', save_path='path/to/dicomfile.dcm') + saves the file in the specified path. + """ + if save_path is None and add_extension: + raise ValueError("If add_extension is True, save_path must be provided.") + + try: + response = self._make_entity_request('GET', + resource, + add_path='file', + headers={'accept': 'application/octet-stream'}) + + # Get mimetype if needed for auto_convert or add_extension + mimetype = None + ext = None + if auto_convert or add_extension: + mimetype, ext = self._determine_mimetype(content=response.content, + resource=resource) + if auto_convert: + if mimetype is None: + _LOGGER.warning("Could not determine mimetype. Returning a bytes array.") + resource_file = response.content + else: + try: + resource_file = BaseApi.convert_format(response.content, + mimetype, + save_path) + except ValueError as e: + _LOGGER.warning(f"Could not convert file to a known format: {e}") + resource_file = response.content + except NotImplementedError as e: + _LOGGER.warning(f"Conversion not implemented yet for {mimetype} and save_path=None." + + " Returning a bytes array. If you want the conversion for this mimetype, provide a save_path.") + resource_file = response.content + else: + resource_file = response.content + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': resource}) + raise e + + if save_path is not None: + if add_extension and mimetype is not None: + if ext is None: + ext = guess_extension(mimetype) + if ext is not None and not save_path.endswith(ext): + save_path += ext + with open(save_path, 'wb') as f: + f.write(response.content) + + if add_extension: + return resource_file, save_path + return resource_file + + + def download_resource_frame(self, + resource: str | Resource, + frame_index: int) -> Image.Image: + """ + Download a frame of a resource. + This is faster than downloading the whole resource and then extracting the frame. + + Args: + resource: The resource unique id or Resource object. + frame_index: The index of the frame to download. + + Returns: + Image.Image: The frame as a PIL image. + + Raises: + ResourceNotFoundError: If the resource does not exists. + DatamintException: If the resource is not a video or dicom. + """ + # check if the resource is an single frame image (png,jpeg,...) first. + # If so, download the whole resource file and return the image. + if not isinstance(resource, Resource): + resource = self.get_by_id(resource) + if resource.mimetype.startswith('image/') or resource.storage == 'ImageResource': + if frame_index != 0: + raise DatamintException(f"Resource {resource.id} is a single frame image, " + f"but frame_index is {frame_index}.") + return self.download_resource_file(resource, auto_convert=True) + + try: + response = self._make_entity_request('GET', + resource, + add_path=f'frames/{frame_index}', + headers={'accept': 'image/*'}) + if response.status_code == 200: + return Image.open(io.BytesIO(response.content)) + else: + raise DatamintException( + f"Error downloading frame {frame_index} of resource {resource.id}: {response.text}") + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': resource.id}) + raise e From ffdc06d6d804f8a868a268b7114cfcd16f3bdc74 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 9 Sep 2025 16:33:21 -0300 Subject: [PATCH 07/13] feat: Add asynchronous entity deletion and request handling in EntityBaseApi --- datamint/api/base_api.py | 65 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 139a55ef..8dadd7da 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -13,6 +13,7 @@ from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage from io import BytesIO import gzip +import asyncio logger = logging.getLogger(__name__) @@ -390,6 +391,7 @@ def convert_format(bytes_array: bytes, raise ValueError(f"Unsupported mimetype: {mimetype}") + class EntityBaseApi(BaseApi, Generic[T]): """Base API handler for entity-related endpoints with CRUD operations. @@ -434,6 +436,24 @@ def _make_entity_request(self, raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e raise + async def _make_entity_request_async(self, + method: str, + entity_id: str | BaseEntity, + add_path: str = '', + session: aiohttp.ClientSession | None = None, + **kwargs) -> aiohttp.ClientResponse: + try: + entity_id = self._entid(entity_id) + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return await self._make_request_async(method, + f'/{self.endpoint_base}/{entity_id}/{add_path}', + session=session, + **kwargs) + except aiohttp.ClientResponseError as e: + if e.status == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + def _stream_entity_request(self, method: str, entity_id: str, @@ -562,8 +582,41 @@ def update(self, entity_id: str, entity_data: dict[str, Any]) -> T: response = self._make_entity_request('PUT', entity_id, json=entity_data) return self.entity_class(**response.json()) - def delete(self, entity_id: str) -> None: - """Delete an entity by its ID. + def delete(self, entity: str | BaseEntity) -> None: + """Delete an entity. + + Args: + entity: Unique identifier for the entity to delete or the entity instance itself. + + Raises: + httpx.HTTPStatusError: If deletion fails or entity not found + """ + self._make_entity_request('DELETE', entity) + + def bulk_delete(self, entities: Sequence[str | BaseEntity]) -> None: + """Delete multiple entities. + + Args: + entities: Sequence of unique identifiers for the entities to delete or the entity instances themselves. + + Raises: + httpx.HTTPStatusError: If deletion fails or any entity not found + """ + async def _delete_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._delete_async(entity, session) + for entity in entities + ] + await asyncio.gather(*tasks) + + loop = asyncio.get_event_loop() + loop.run_until_complete(_delete_all_async()) + + async def _delete_async(self, + entity_id: str | BaseEntity, + session: aiohttp.ClientSession | None = None) -> None: + """Asynchronously delete an entity by its ID. Args: entity_id: Unique identifier for the entity to delete. @@ -571,14 +624,14 @@ def delete(self, entity_id: str) -> None: Raises: httpx.HTTPStatusError: If deletion fails or entity not found """ - self._make_entity_request('DELETE', entity_id) + await self._make_entity_request_async('DELETE', entity_id, + session=session) def _get_child_entities(self, parent_entity: BaseEntity | str, child_entity_name: str) -> httpx.Response: - entid = parent_entity if isinstance(parent_entity, str) else parent_entity.id - # response = self._make_request('GET', f'/{self.endpoint_base}/{entid}/{child_entity_name}') - response = self._make_entity_request('GET', entid, add_path=child_entity_name) + response = self._make_entity_request('GET', parent_entity, + add_path=child_entity_name) return response # def bulk_create(self, entities_data: list[dict[str, Any]]) -> list[T]: From 45d04d1f0b7936b206458c13b6c72d5752e043ee Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Tue, 9 Sep 2025 17:25:59 -0300 Subject: [PATCH 08/13] feat: Implement Channels API and enhance entity management with CRUD operations --- datamint/api/base_api.py | 287 +----------------- datamint/api/client.py | 10 +- datamint/api/endpoints/__init__.py | 10 +- datamint/api/endpoints/annotations_api.py | 4 +- datamint/api/endpoints/channels_api.py | 28 ++ datamint/api/endpoints/projects_api.py | 4 +- datamint/api/endpoints/resources_api.py | 38 ++- datamint/api/entity_base_api.py | 340 ++++++++++++++++++++++ datamint/entities/__init__.py | 12 +- datamint/entities/channel.py | 46 +++ 10 files changed, 483 insertions(+), 296 deletions(-) create mode 100644 datamint/api/endpoints/channels_api.py create mode 100644 datamint/api/entity_base_api.py create mode 100644 datamint/entities/channel.py diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 8dadd7da..a4bda3d6 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -1,8 +1,7 @@ import logging -from typing import Any, TypeVar, Generic, Type, Sequence, Generator, Literal +from typing import Any,Generator import httpx from dataclasses import dataclass -from datamint.entities.base_entity import BaseEntity from datamint.exceptions import DatamintException, ResourceNotFoundError import aiohttp import json @@ -13,12 +12,10 @@ from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage from io import BytesIO import gzip -import asyncio logger = logging.getLogger(__name__) # Generic type for entities -T = TypeVar('T', bound=BaseEntity) _PAGE_LIMIT = 5000 @@ -185,6 +182,10 @@ def get_status_code(e) -> int: if not hasattr(e, 'response') or e.response is None: return -1 return e.response.status_code + + @staticmethod + def _has_status_code(e, status_code: int) -> bool: + return BaseApi.get_status_code(e) == status_code def _check_errors_response(self, response, @@ -391,281 +392,3 @@ def convert_format(bytes_array: bytes, raise ValueError(f"Unsupported mimetype: {mimetype}") - -class EntityBaseApi(BaseApi, Generic[T]): - """Base API handler for entity-related endpoints with CRUD operations. - - This class provides a template for API handlers that work with specific - entity types, offering common CRUD operations with proper typing. - - Type Parameters: - T: The entity type this API handler manages (must extend BaseEntity) - """ - - def __init__(self, config: ApiConfig, - entity_class: Type[T], - endpoint_base: str, - client: httpx.Client | None = None) -> None: - """Initialize the entity API handler. - - Args: - config: API configuration containing base URL, API key, etc. - entity_class: The entity class this handler manages - endpoint_base: Base endpoint path (e.g., 'projects', 'annotations') - client: Optional HTTP client instance. If None, a new one will be created. - """ - super().__init__(config, client) - self.entity_class = entity_class - self.endpoint_base = endpoint_base.strip('/') - - @staticmethod - def _entid(entity: BaseEntity | str) -> str: - return entity if isinstance(entity, str) else entity.id - - def _make_entity_request(self, - method: str, - entity_id: str | BaseEntity, - add_path: str = '', - **kwargs) -> httpx.Response: - try: - entity_id = self._entid(entity_id) - add_path = '/'.join(add_path.strip().strip('/').split('/')) - return self._make_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e - raise - - async def _make_entity_request_async(self, - method: str, - entity_id: str | BaseEntity, - add_path: str = '', - session: aiohttp.ClientSession | None = None, - **kwargs) -> aiohttp.ClientResponse: - try: - entity_id = self._entid(entity_id) - add_path = '/'.join(add_path.strip().strip('/').split('/')) - return await self._make_request_async(method, - f'/{self.endpoint_base}/{entity_id}/{add_path}', - session=session, - **kwargs) - except aiohttp.ClientResponseError as e: - if e.status == 404: - raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e - raise - - def _stream_entity_request(self, - method: str, - entity_id: str, - add_path: str = '', - **kwargs): - try: - add_path = '/'.join(add_path.strip().strip('/').split('/')) - return self._stream_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) - except httpx.HTTPStatusError as e: - if e.response.status_code == 404: - raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e - raise - - def get_list(self, limit: int | None = None, **kwargs) -> Sequence[T]: - """Get entities with optional filtering. - - Returns: - List of entity instances. - - Raises: - httpx.HTTPStatusError: If the request fails. - """ - params = dict(kwargs) - - # Remove None values from the payload. - for k in list(params.keys()): - if params[k] is None: - del params[k] - - items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', - return_field=self.endpoint_base, - limit=limit, - params=params) - - all_items = [] - for resp, items in items_gen: - all_items.extend(items) - - return [self.entity_class(**item) for item in all_items] - - def get_all(self, limit: int | None = None) -> Sequence[T]: - """Get all entities with optional pagination and filtering. - - Returns: - List of entity instances - - Raises: - httpx.HTTPStatusError: If the request fails - """ - return self.get_list(limit=limit) - - def get_by_id(self, entity_id: str) -> T: - """Get a specific entity by its ID. - - Args: - entity_id: Unique identifier for the entity. - - Returns: - Entity instance. - - Raises: - httpx.HTTPStatusError: If the entity is not found or request fails. - """ - response = self._make_entity_request('GET', entity_id) - return self.entity_class(**response.json()) - - def _create(self, entity_data: dict[str, Any]) -> str | list[str | dict]: - """Create a new entity. - - Args: - entity_data: Dictionary containing entity data for creation. - - Returns: - The id of the created entity. - - Raises: - httpx.HTTPStatusError: If creation fails. - """ - response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) - respdata = response.json() - if isinstance(respdata, str): - return respdata - if isinstance(respdata, list): - return respdata - if isinstance(respdata, dict): - return respdata.get('id') - return respdata - - async def _create_async(self, entity_data: dict[str, Any]) -> str | list[str | dict]: - """Create a new entity. - - Args: - entity_data: Dictionary containing entity data for creation. - - Returns: - The id of the created entity. - - Raises: - httpx.HTTPStatusError: If creation fails. - """ - resp = await self._make_request_async('POST', - f'/{self.endpoint_base}', - json=entity_data) - respdata = await resp.json() - if isinstance(respdata, str): - return respdata - if isinstance(respdata, list): - return respdata - if isinstance(respdata, dict): - return respdata.get('id') - return respdata - - def update(self, entity_id: str, entity_data: dict[str, Any]) -> T: - """Update an existing entity. - - Args: - entity_id: Unique identifier for the entity. - entity_data: Dictionary containing updated entity data. - - Returns: - Updated entity instance. - - Raises: - httpx.HTTPStatusError: If update fails or entity not found. - """ - response = self._make_entity_request('PUT', entity_id, json=entity_data) - return self.entity_class(**response.json()) - - def delete(self, entity: str | BaseEntity) -> None: - """Delete an entity. - - Args: - entity: Unique identifier for the entity to delete or the entity instance itself. - - Raises: - httpx.HTTPStatusError: If deletion fails or entity not found - """ - self._make_entity_request('DELETE', entity) - - def bulk_delete(self, entities: Sequence[str | BaseEntity]) -> None: - """Delete multiple entities. - - Args: - entities: Sequence of unique identifiers for the entities to delete or the entity instances themselves. - - Raises: - httpx.HTTPStatusError: If deletion fails or any entity not found - """ - async def _delete_all_async(): - async with aiohttp.ClientSession() as session: - tasks = [ - self._delete_async(entity, session) - for entity in entities - ] - await asyncio.gather(*tasks) - - loop = asyncio.get_event_loop() - loop.run_until_complete(_delete_all_async()) - - async def _delete_async(self, - entity_id: str | BaseEntity, - session: aiohttp.ClientSession | None = None) -> None: - """Asynchronously delete an entity by its ID. - - Args: - entity_id: Unique identifier for the entity to delete. - - Raises: - httpx.HTTPStatusError: If deletion fails or entity not found - """ - await self._make_entity_request_async('DELETE', entity_id, - session=session) - - def _get_child_entities(self, - parent_entity: BaseEntity | str, - child_entity_name: str) -> httpx.Response: - response = self._make_entity_request('GET', parent_entity, - add_path=child_entity_name) - return response - - # def bulk_create(self, entities_data: list[dict[str, Any]]) -> list[T]: - # """Create multiple entities in a single request. - - # Args: - # entities_data: List of dictionaries containing entity data - - # Returns: - # List of created entity instances - - # Raises: - # httpx.HTTPStatusError: If bulk creation fails - # """ - # payload = {'items': entities_data} # Common bulk API format - # response = self._make_request('POST', f'/{self.endpoint_base}/bulk', json=payload) - # data = response.json() - - # # Handle response format - may be direct list or wrapped - # items = data if isinstance(data, list) else data.get('items', []) - # return [self.entity_class(**item) for item in items] - - # def count(self, **params: Any) -> int: - # """Get the total count of entities matching the given filters. - - # Args: - # **params: Query parameters for filtering - - # Returns: - # Total count of matching entities - - # Raises: - # httpx.HTTPStatusError: If the request fails - # """ - # response = self._make_request('GET', f'/{self.endpoint_base}/count', params=params) - # data = response.json() - # return data.get('count', 0) if isinstance(data, dict) else data diff --git a/datamint/api/client.py b/datamint/api/client.py index 8cf0c9ab..60fc2ec3 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -1,7 +1,7 @@ from typing import Optional import httpx from .base_api import ApiConfig -from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi +from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi import datamint.configs from datamint.exceptions import DatamintException import asyncio @@ -50,6 +50,7 @@ def __init__(self, self._projects = None self._annotations = None self._resources = None + self._channels = None if check_connection: self.check_connection() @@ -82,6 +83,13 @@ def annotations(self) -> AnnotationsApi: self._annotations = AnnotationsApi(self.config, self._client) return self._annotations + @property + def channels(self) -> ChannelsApi: + """Access to channel-related endpoints.""" + if self._channels is None: + self._channels = ChannelsApi(self.config, self._client) + return self._channels + # def close(self) -> None: # """Close the HTTP client connections.""" # if self._projects and self._projects.client: diff --git a/datamint/api/endpoints/__init__.py b/datamint/api/endpoints/__init__.py index 2cc7957b..f6f21469 100644 --- a/datamint/api/endpoints/__init__.py +++ b/datamint/api/endpoints/__init__.py @@ -1,7 +1,13 @@ """API endpoint handlers.""" -from .projects_api import ProjectsApi from .annotations_api import AnnotationsApi +from .channels_api import ChannelsApi +from .projects_api import ProjectsApi from .resources_api import ResourcesApi -__all__ = ['ProjectsApi', 'ResourcesApi', 'AnnotationsApi'] +__all__ = [ + 'AnnotationsApi', + 'ChannelsApi', + 'ProjectsApi', + 'ResourcesApi', +] diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index c7601f4b..c485f96b 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -2,7 +2,7 @@ import httpx from datetime import date import logging -from ..base_api import EntityBaseApi, ApiConfig +from ..entity_base_api import ApiConfig, CreatableEntityApi, DeletableEntityApi from datamint.entities.annotation import Annotation from datamint.entities.resource import Resource from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto @@ -22,7 +22,7 @@ MAX_NUMBER_DISTINCT_COLORS = 2048 # Maximum number of distinct colors in a segmentation image -class AnnotationsApi(EntityBaseApi[Annotation]): +class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotation]): """API handler for annotation-related endpoints.""" def __init__(self, config: ApiConfig, client: httpx.Client | None = None) -> None: diff --git a/datamint/api/endpoints/channels_api.py b/datamint/api/endpoints/channels_api.py new file mode 100644 index 00000000..44cdf5d7 --- /dev/null +++ b/datamint/api/endpoints/channels_api.py @@ -0,0 +1,28 @@ +""" +Channels API endpoint for managing channel resources. + +This module provides functionality to interact with channels, +which are collections of resources grouped together for +batch processing or organization purposes. +""" + +import logging +import httpx +from ..entity_base_api import EntityBaseApi +from datamint.entities.channel import Channel + +logger = logging.getLogger(__name__) + + +class ChannelsApi(EntityBaseApi[Channel]): + """API client for channel-related operations. + """ + + def __init__(self, config, client: httpx.Client | None = None) -> None: + """Initialize the Channels API client. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, Channel, 'resources/channels', client) diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index 88ff3e1f..1608c3a8 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -1,11 +1,11 @@ from typing import Sequence -from ..base_api import EntityBaseApi, ApiConfig +from ..entity_base_api import ApiConfig, CRUDEntityApi from datamint.entities.project import Project from datamint.entities.resource import Resource import httpx -class ProjectsApi(EntityBaseApi[Project]): +class ProjectsApi(CRUDEntityApi[Project]): """API handler for project-related endpoints.""" def __init__(self, diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 80cedef6..5f78cd30 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -1,5 +1,6 @@ from typing import Any, Optional, Sequence, TypeAlias, Literal, IO -from ..base_api import EntityBaseApi, ApiConfig, BaseApi +from ..base_api import ApiConfig, BaseApi +from ..entity_base_api import EntityBaseApi, CreatableEntityApi, DeletableEntityApi from .annotations_api import AnnotationsApi from .projects_api import ProjectsApi from datamint.entities.resource import Resource @@ -51,7 +52,7 @@ def _open_io(file_path: str | Path | IO, mode: str = 'rb') -> IO: return file_path -class ResourcesApi(EntityBaseApi[Resource]): +class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]): """API handler for resource-related endpoints.""" def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None: @@ -791,8 +792,8 @@ def download_resource_file(self, else: try: resource_file = BaseApi.convert_format(response.content, - mimetype, - save_path) + mimetype, + save_path) except ValueError as e: _LOGGER.warning(f"Could not convert file to a known format: {e}") resource_file = response.content @@ -819,7 +820,6 @@ def download_resource_file(self, return resource_file, save_path return resource_file - def download_resource_frame(self, resource: str | Resource, frame_index: int) -> Image.Image: @@ -849,7 +849,7 @@ def download_resource_frame(self, return self.download_resource_file(resource, auto_convert=True) try: - response = self._make_entity_request('GET', + response = self._make_entity_request('GET', resource, add_path=f'frames/{frame_index}', headers={'accept': 'image/*'}) @@ -861,3 +861,29 @@ def download_resource_frame(self, except ResourceNotFoundError as e: e.set_params('resource', {'resource_id': resource.id}) raise e + + def publish_resources(self, + resources: str | Resource | Sequence[str | Resource]) -> None: + """ + Publish resources, changing their status to 'published'. + + Args: + resources: The resources to publish. Can be a Resource object (instead of a list) + + Raises: + ResourceNotFoundError: If the resource does not exists or the project does not exists. + """ + if isinstance(resources, (Resource, str)): + resources = [resources] + + for resource in resources: + try: + self._make_entity_request('POST', resource, add_path='publish') + except ResourceNotFoundError as e: + e.set_params('resource', {'resource_id': resource}) + raise e + except Exception as e: + if BaseApi._has_status_code(e, 400) and 'Resource must be in inbox status to be approved' in e.response.text: + _LOGGER.warning(f"Resource {resource} is not in inbox status. Skipping publishing") + else: + raise e \ No newline at end of file diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py new file mode 100644 index 00000000..15d616f8 --- /dev/null +++ b/datamint/api/entity_base_api.py @@ -0,0 +1,340 @@ +from typing import Any, TypeVar, Generic, Type, Sequence +import logging +import httpx +from dataclasses import dataclass +from datamint.entities.base_entity import BaseEntity +from datamint.exceptions import DatamintException, ResourceNotFoundError +import aiohttp +import asyncio +from .base_api import ApiConfig, BaseApi + +logger = logging.getLogger(__name__) +T = TypeVar('T', bound=BaseEntity) + + +class EntityBaseApi(BaseApi, Generic[T]): + """Base API handler for entity-related endpoints with CRUD operations. + + This class provides a template for API handlers that work with specific + entity types, offering common CRUD operations with proper typing. + + Type Parameters: + T: The entity type this API handler manages (must extend BaseEntity) + """ + + def __init__(self, config: ApiConfig, + entity_class: Type[T], + endpoint_base: str, + client: httpx.Client | None = None) -> None: + """Initialize the entity API handler. + + Args: + config: API configuration containing base URL, API key, etc. + entity_class: The entity class this handler manages + endpoint_base: Base endpoint path (e.g., 'projects', 'annotations') + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, client) + self.entity_class = entity_class + self.endpoint_base = endpoint_base.strip('/') + + @staticmethod + def _entid(entity: BaseEntity | str) -> str: + return entity if isinstance(entity, str) else entity.id + + def _make_entity_request(self, + method: str, + entity_id: str | BaseEntity, + add_path: str = '', + **kwargs) -> httpx.Response: + try: + entity_id = self._entid(entity_id) + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return self._make_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + async def _make_entity_request_async(self, + method: str, + entity_id: str | BaseEntity, + add_path: str = '', + session: aiohttp.ClientSession | None = None, + **kwargs) -> aiohttp.ClientResponse: + try: + entity_id = self._entid(entity_id) + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return await self._make_request_async(method, + f'/{self.endpoint_base}/{entity_id}/{add_path}', + session=session, + **kwargs) + except aiohttp.ClientResponseError as e: + if e.status == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + def _stream_entity_request(self, + method: str, + entity_id: str, + add_path: str = '', + **kwargs): + try: + add_path = '/'.join(add_path.strip().strip('/').split('/')) + return self._stream_request(method, f'/{self.endpoint_base}/{entity_id}/{add_path}', **kwargs) + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e + raise + + def get_list(self, limit: int | None = None, **kwargs) -> Sequence[T]: + """Get entities with optional filtering. + + Returns: + List of entity instances. + + Raises: + httpx.HTTPStatusError: If the request fails. + """ + params = dict(kwargs) + + # Remove None values from the payload. + for k in list(params.keys()): + if params[k] is None: + del params[k] + + items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', + return_field=self.endpoint_base, + limit=limit, + params=params) + + all_items = [] + for resp, items in items_gen: + all_items.extend(items) + + return [self.entity_class(**item) for item in all_items] + + def get_all(self, limit: int | None = None) -> Sequence[T]: + """Get all entities with optional pagination and filtering. + + Returns: + List of entity instances + + Raises: + httpx.HTTPStatusError: If the request fails + """ + return self.get_list(limit=limit) + + def get_by_id(self, entity_id: str) -> T: + """Get a specific entity by its ID. + + Args: + entity_id: Unique identifier for the entity. + + Returns: + Entity instance. + + Raises: + httpx.HTTPStatusError: If the entity is not found or request fails. + """ + response = self._make_entity_request('GET', entity_id) + return self.entity_class(**response.json()) + + async def _create_async(self, entity_data: dict[str, Any]) -> str | list[str | dict]: + """Create a new entity. + + Args: + entity_data: Dictionary containing entity data for creation. + + Returns: + The id of the created entity. + + Raises: + httpx.HTTPStatusError: If creation fails. + """ + resp = await self._make_request_async('POST', + f'/{self.endpoint_base}', + json=entity_data) + respdata = await resp.json() + if isinstance(respdata, str): + return respdata + if isinstance(respdata, list): + return respdata + if isinstance(respdata, dict): + return respdata.get('id') + return respdata + + def _get_child_entities(self, + parent_entity: BaseEntity | str, + child_entity_name: str) -> httpx.Response: + response = self._make_entity_request('GET', parent_entity, + add_path=child_entity_name) + return response + + # def bulk_create(self, entities_data: list[dict[str, Any]]) -> list[T]: + # """Create multiple entities in a single request. + + # Args: + # entities_data: List of dictionaries containing entity data + + # Returns: + # List of created entity instances + + # Raises: + # httpx.HTTPStatusError: If bulk creation fails + # """ + # payload = {'items': entities_data} # Common bulk API format + # response = self._make_request('POST', f'/{self.endpoint_base}/bulk', json=payload) + # data = response.json() + + # # Handle response format - may be direct list or wrapped + # items = data if isinstance(data, list) else data.get('items', []) + # return [self.entity_class(**item) for item in items] + + # def count(self, **params: Any) -> int: + # """Get the total count of entities matching the given filters. + + # Args: + # **params: Query parameters for filtering + + # Returns: + # Total count of matching entities + + # Raises: + # httpx.HTTPStatusError: If the request fails + # """ + # response = self._make_request('GET', f'/{self.endpoint_base}/count', params=params) + # data = response.json() + # return data.get('count', 0) if isinstance(data, dict) else data + + +class DeletableEntityApi(EntityBaseApi[T]): + """Extension of EntityBaseApi for entities that support soft deletion. + + This class adds methods to handle soft-deleted entities, allowing + retrieval and restoration of such entities. + """ + + def delete(self, entity: str | BaseEntity) -> None: + """Delete an entity. + + Args: + entity: Unique identifier for the entity to delete or the entity instance itself. + + Raises: + httpx.HTTPStatusError: If deletion fails or entity not found + """ + self._make_entity_request('DELETE', entity) + + def bulk_delete(self, entities: Sequence[str | BaseEntity]) -> None: + """Delete multiple entities. + + Args: + entities: Sequence of unique identifiers for the entities to delete or the entity instances themselves. + + Raises: + httpx.HTTPStatusError: If deletion fails or any entity not found + """ + async def _delete_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._delete_async(entity, session) + for entity in entities + ] + await asyncio.gather(*tasks) + + loop = asyncio.get_event_loop() + loop.run_until_complete(_delete_all_async()) + + async def _delete_async(self, + entity: str | BaseEntity, + session: aiohttp.ClientSession | None = None) -> None: + """Asynchronously delete an entity by its ID. + + Args: + entity: Unique identifier for the entity to delete or the entity instance itself. + + Raises: + httpx.HTTPStatusError: If deletion fails or entity not found + """ + await self._make_entity_request_async('DELETE', entity, + session=session) + + # def get_deleted(self, **kwargs) -> Sequence[T]: + # pass + + # def restore(self, entity_id: str | BaseEntity) -> T: + # pass + + +class CreatableEntityApi(EntityBaseApi[T]): + """Extension of EntityBaseApi for entities that support creation. + + This class adds methods to handle creation of new entities. + """ + + def _create(self, entity_data: dict[str, Any]) -> str | list[str | dict]: + """Create a new entity. + + Args: + entity_data: Dictionary containing entity data for creation. + + Returns: + The id of the created entity. + + Raises: + httpx.HTTPStatusError: If creation fails. + """ + response = self._make_request('POST', f'/{self.endpoint_base}', json=entity_data) + respdata = response.json() + if isinstance(respdata, str): + return respdata + if isinstance(respdata, list): + return respdata + if isinstance(respdata, dict): + return respdata.get('id') + return respdata + + def create(self, *args, **kwargs) -> str | T: + raise NotImplementedError("Subclasses must implement the create method with their own custom parameters") + + +class UpdatableEntityApi(EntityBaseApi[T]): + # def update(self, entity_id: str, entity_data: dict[str, Any]): + # """Update an existing entity. + + # Args: + # entity_id: Unique identifier for the entity. + # entity_data: Dictionary containing updated entity data. + + # Returns: + # Updated entity instance. + + # Raises: + # httpx.HTTPStatusError: If update fails or entity not found. + # """ + # self._make_entity_request('PUT', entity_id, json=entity_data) + + def patch(self, entity: str | T, entity_data: dict[str, Any]): + """Partially update an existing entity. + + Args: + entity: Unique identifier for the entity or the entity instance. + entity_data: Dictionary containing fields to update. Only provided fields will be updated. + + Returns: + Updated entity instance. + + Raises: + httpx.HTTPStatusError: If update fails or entity not found. + """ + self._make_entity_request('PATCH', entity, json=entity_data) + + def partial_update(self, entity: str | T, entity_data: dict[str, Any]): + """Alias for :py:meth:`patch` to partially update an entity.""" + return self.patch(entity, entity_data) + + +class CRUDEntityApi(CreatableEntityApi[T], UpdatableEntityApi[T], DeletableEntityApi[T]): + """Full CRUD API handler for entities supporting create, read, update, delete operations.""" + pass diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py index cc770b05..6449cde4 100644 --- a/datamint/entities/__init__.py +++ b/datamint/entities/__init__.py @@ -1,6 +1,16 @@ """DataMint entities package.""" +from .annotation import Annotation +from .base_entity import BaseEntity +from .channel import Channel, ChannelResourceData from .project import Project from .resource import Resource -__all__ = ['Project', 'Resource'] +__all__ = [ + 'Annotation', + 'BaseEntity', + 'Channel', + 'ChannelResourceData', + 'Project', + 'Resource', +] diff --git a/datamint/entities/channel.py b/datamint/entities/channel.py new file mode 100644 index 00000000..9b6637bb --- /dev/null +++ b/datamint/entities/channel.py @@ -0,0 +1,46 @@ +from pydantic import ConfigDict, BaseModel +from datetime import datetime +from datamint.entities.base_entity import BaseEntity + + +class ChannelResourceData(BaseModel): + """Represents resource data within a channel. + + Attributes: + created_by: Email of the user who created the resource. + customer_id: UUID of the customer. + resource_id: UUID of the resource. + resource_file_name: Original filename of the resource. + resource_mimetype: MIME type of the resource. + """ + model_config = ConfigDict(extra='allow') + + created_by: str + customer_id: str + resource_id: str + resource_file_name: str + resource_mimetype: str + + +class Channel(BaseEntity): + """Represents a channel containing multiple resources. + + A channel is a collection of resources grouped together, + typically for batch processing or organization purposes. + + Attributes: + channel_name: Name identifier for the channel. + resource_data: List of resources contained in this channel. + deleted: Whether the channel has been marked as deleted. + created_at: Timestamp when the channel was created. + updated_at: Timestamp when the channel was last updated. + """ + channel_name: str + resource_data: list[ChannelResourceData] + deleted: bool = False + created_at: str | None = None + updated_at: str | None = None + + def get_resource_ids(self) -> list[str]: + """Get list of all resource IDs in this channel.""" + return [resource.resource_id for resource in self.resource_data] if self.resource_data else [] \ No newline at end of file From 6e1a68d4c26d4c9a7ec08e79d7caa8ef962e359a Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 10 Sep 2025 17:18:39 -0300 Subject: [PATCH 09/13] feat: Add Users API --- datamint/api/client.py | 65 ++++------ datamint/api/endpoints/__init__.py | 2 + datamint/api/endpoints/annotations_api.py | 143 ++++++++++++++++++++-- datamint/api/endpoints/projects_api.py | 5 +- datamint/api/endpoints/resources_api.py | 16 +-- datamint/api/endpoints/users_api.py | 38 ++++++ datamint/entities/__init__.py | 2 + datamint/entities/user.py | 21 ++++ 8 files changed, 232 insertions(+), 60 deletions(-) create mode 100644 datamint/api/endpoints/users_api.py create mode 100644 datamint/entities/user.py diff --git a/datamint/api/client.py b/datamint/api/client.py index 60fc2ec3..422a194b 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -1,7 +1,7 @@ from typing import Optional import httpx from .base_api import ApiConfig -from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi +from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi, UsersApi import datamint.configs from datamint.exceptions import DatamintException import asyncio @@ -12,6 +12,14 @@ class Api: DEFAULT_SERVER_URL = 'https://api.datamint.io' DATAMINT_API_VENV_NAME = datamint.configs.ENV_VARS[datamint.configs.APIKEY_KEY] + _API_MAP = { + 'projects': ProjectsApi, + 'resources': ResourcesApi, + 'annotations': AnnotationsApi, + 'channels': ChannelsApi, + 'users': UsersApi, + } + def __init__(self, server_url: str | None = None, api_key: Optional[str] = None, @@ -37,8 +45,6 @@ def __init__(self, msg = f"API key not provided! Use the environment variable " + \ f"{Api.DATAMINT_API_VENV_NAME} or pass it as an argument." raise DatamintException(msg) - # self.semaphore = asyncio.Semaphore(20) - self.config = ApiConfig( server_url=server_url, api_key=api_key, @@ -46,12 +52,7 @@ def __init__(self, max_retries=max_retries ) self._client = None - # Initialize endpoint handlers - self._projects = None - self._annotations = None - self._resources = None - self._channels = None - + self._endpoints = {} if check_connection: self.check_connection() @@ -62,45 +63,25 @@ def check_connection(self): raise DatamintException("Error connecting to the Datamint API." + f" Please check your api_key and/or other configurations. {e}") + def _get_endpoint(self, name: str): + if name not in self._endpoints: + api_class = self._API_MAP[name] + self._endpoints[name] = api_class(self.config, self._client) + return self._endpoints[name] + @property def projects(self) -> ProjectsApi: - """Access to project-related endpoints.""" - if self._projects is None: - self._projects = ProjectsApi(self.config, self._client) - return self._projects - + return self._get_endpoint('projects') @property def resources(self) -> ResourcesApi: - """Access to resource-related endpoints.""" - if self._resources is None: - self._resources = ResourcesApi(self.config, self._client) - return self._resources - + return self._get_endpoint('resources') @property def annotations(self) -> AnnotationsApi: - """Access to annotation-related endpoints.""" - if self._annotations is None: - self._annotations = AnnotationsApi(self.config, self._client) - return self._annotations - + return self._get_endpoint('annotations') @property def channels(self) -> ChannelsApi: - """Access to channel-related endpoints.""" - if self._channels is None: - self._channels = ChannelsApi(self.config, self._client) - return self._channels - - # def close(self) -> None: - # """Close the HTTP client connections.""" - # if self._projects and self._projects.client: - # self._projects.client.close() - # if self._annotations and self._annotations.client: - # self._annotations.client.close() - - # def __enter__(self): - # """Context manager entry.""" - # return self + return self._get_endpoint('channels') + @property + def users(self) -> UsersApi: + return self._get_endpoint('users') - # def __exit__(self, exc_type, exc_val, exc_tb): - # """Context manager exit.""" - # self.close() diff --git a/datamint/api/endpoints/__init__.py b/datamint/api/endpoints/__init__.py index f6f21469..ce05a4c2 100644 --- a/datamint/api/endpoints/__init__.py +++ b/datamint/api/endpoints/__init__.py @@ -4,10 +4,12 @@ from .channels_api import ChannelsApi from .projects_api import ProjectsApi from .resources_api import ResourcesApi +from .users_api import UsersApi __all__ = [ 'AnnotationsApi', 'ChannelsApi', 'ProjectsApi', 'ResourcesApi', + 'UsersApi' ] diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index c485f96b..c22f4dd7 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -5,7 +5,7 @@ from ..entity_base_api import ApiConfig, CreatableEntityApi, DeletableEntityApi from datamint.entities.annotation import Annotation from datamint.entities.resource import Resource -from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto +from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry import numpy as np import os import aiohttp @@ -16,6 +16,7 @@ import nibabel as nib from PIL import Image from io import BytesIO +import pydicom _LOGGER = logging.getLogger(__name__) _USER_LOGGER = logging.getLogger('user_logger') @@ -74,7 +75,7 @@ def get_list(self, return super().get_list(limit=limit, **payload) async def _upload_segmentations_async(self, - resource_id: str, + resource: str | Resource, frame_index: int | None, file_path: str | np.ndarray, name: dict[int, str] | dict[tuple, str], @@ -90,7 +91,7 @@ async def _upload_segmentations_async(self, Upload segmentations asynchronously. Args: - resource_id: The resource unique id. + resource: The resource unique id or Resource instance. frame_index: The frame index or None for multiple frames. file_path: Path to segmentation file or numpy array. name: The name of the segmentation or mapping of pixel values to names. @@ -111,6 +112,8 @@ async def _upload_segmentations_async(self, else: upload_volume = False + + resource_id = self._entid(resource) # Handle volume upload if upload_volume: if frame_index is not None: @@ -272,7 +275,7 @@ def _prepare_upload_file(self, return f, filename, close_file, content_type async def upload_annotation_file_async(self, - resource_id: str, + resource: str | Resource, annotation_id: str, file: str | IO, content_type: str | None = None, @@ -282,7 +285,7 @@ async def upload_annotation_file_async(self, Upload a file for an existing annotation asynchronously. Args: - resource_id: The resource unique id. + resource: The resource unique id or Resource instance. annotation_id: The annotation unique id. file: Path to the file or a file-like object. content_type: The MIME type of the file. @@ -296,7 +299,7 @@ async def upload_annotation_file_async(self, .. code-block:: python await ann_api.upload_annotation_file_async( - resource_id='your_resource_id', + resource='your_resource_id', annotation_id='your_annotation_id', file='path/to/your/file.png', content_type='image/png', @@ -310,6 +313,7 @@ async def upload_annotation_file_async(self, try: form = aiohttp.FormData() form.add_field('file', f, filename=filename, content_type=content_type) + resource_id = self._entid(resource) endpoint = f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file' resp = await self._make_request_async(method='POST', endpoint=endpoint, @@ -322,7 +326,7 @@ async def upload_annotation_file_async(self, f.close() def upload_annotation_file(self, - resource_id: str, + resource: str | Resource, annotation_id: str, file: str | IO, content_type: str | None = None, @@ -332,7 +336,7 @@ def upload_annotation_file(self, Upload a file for an existing annotation. Args: - resource_id: The resource unique id. + resource: The resource unique id or Resource instance. annotation_id: The annotation unique id. file: Path to the file or a file-like object. content_type: The MIME type of the file. @@ -349,6 +353,7 @@ def upload_annotation_file(self, files = { 'file': (filename, f, content_type) } + resource_id = self._entid(resource) resp = self._make_request(method='POST', endpoint=f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file', files=files) @@ -622,3 +627,125 @@ def _numpy_to_bytesio_png(seg_imgs: np.ndarray) -> Generator[BinaryIO, None, Non pil_img.save(img_bytes, format='PNG') img_bytes.seek(0) yield img_bytes + + def add_line_annotation(self, + point1: tuple[int, int] | tuple[float, float, float], + point2: tuple[int, int] | tuple[float, float, float], + resource_id: str, + identifier: str, + frame_index: int | None = None, + dicom_metadata: pydicom.Dataset | str | None = None, + coords_system: CoordinateSystem = 'pixel', + project: str | None = None, + worklist_id: str | None = None, + imported_from: str | None = None, + author_email: str | None = None, + model_id: str | None = None) -> list[str]: + """ + Add a line annotation to a resource. + + Args: + point1: The first point of the line. Can be a 2d or 3d point. + If `coords_system` is 'pixel', it must be a 2d point and it represents the pixel coordinates of the image. + If `coords_system` is 'patient', it must be a 3d point and it represents the patient coordinates of the image, relative + to the DICOM metadata. + If `coords_system` is 'patient', it must be a 3d point. + point2: The second point of the line. See `point1` for more details. + resource_id: The resource unique id. + identifier: The annotation identifier, also as known as the annotation's label. + frame_index: The frame index of the annotation. + dicom_metadata: The DICOM metadata of the image. If provided, the coordinates will be converted to the + correct coordinates automatically using the DICOM metadata. + coords_system: The coordinate system of the points. Can be 'pixel', or 'patient'. + If 'pixel', the points are in pixel coordinates. If 'patient', the points are in patient coordinates (see DICOM patient coordinates). + project: The project unique id or name. + worklist_id: The annotation worklist unique id. Optional. + imported_from: The imported from source value. + author_email: The email to consider as the author of the annotation. If None, use the customer of the api key. + model_id: The model unique id. Optional. + + Example: + .. code-block:: python + + res_id = 'aa93813c-cef0-4edd-a45c-85d4a8f1ad0d' + api.add_line_annotation([0, 0], (10, 30), + resource_id=res_id, + identifier='Line1', + frame_index=2, + project='Example Project') + """ + + if project is not None and worklist_id is not None: + raise ValueError('Only one of project or worklist_id can be provided.') + + if coords_system == 'pixel': + if dicom_metadata is None: + point1 = (point1[0], point1[1], frame_index) + point2 = (point2[0], point2[1], frame_index) + geom = LineGeometry(point1, point2) + else: + if isinstance(dicom_metadata, str): + dicom_metadata = pydicom.dcmread(dicom_metadata) + geom = LineGeometry.from_dicom(dicom_metadata, point1, point2, slice_index=frame_index) + elif coords_system == 'patient': + geom = LineGeometry(point1, point2) + else: + raise ValueError(f"Unknown coordinate system: {coords_system}") + + return self._create_geometry_annotation( + geometry=geom, + resource_id=resource_id, + identifier=identifier, + frame_index=frame_index, + project=project, + worklist_id=worklist_id, + imported_from=imported_from, + author_email=author_email, + model_id=model_id + ) + + + def _create_geometry_annotation(self, + geometry: Geometry, + resource_id: str, + identifier: str, + frame_index: int | None = None, + project: str | None = None, + worklist_id: str | None = None, + imported_from: str | None = None, + author_email: str | None = None, + model_id: str | None = None) -> list[str]: + """ + Create an annotation with the given geometry. + + Args: + geometry: The geometry object (e.g., LineGeometry, BoxGeometry). + resource_id: The resource unique id. + identifier: The annotation identifier/label. + frame_index: The frame index of the annotation. + project: The project unique id or name. + worklist_id: The annotation worklist unique id. + imported_from: The imported from source value. + author_email: The email to consider as the author. + model_id: The model unique id. + + Returns: + List of created annotation IDs. + """ + if project is not None and worklist_id is not None: + raise ValueError('Only one of project or worklist_id can be provided.') + + scope = 'frame' if frame_index is not None else 'image' + annotation_dto = CreateAnnotationDto( + type=geometry.type, + identifier=identifier, + scope=scope, + frame_index=frame_index, + geometry=geometry, + imported_from=imported_from, + import_author=author_email, + model_id=model_id, + annotation_worklist_id=worklist_id + ) + + return self.create(resource_id, annotation_dto) \ No newline at end of file diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index 1608c3a8..67011e7f 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -131,7 +131,7 @@ def add_resources(self, self._make_entity_request('POST', project_id, add_path='resources', json={'resource_ids_to_add': resources_ids, 'all_files_selected': False}) - def download(self, project_id: str, + def download(self, project: str | Project, outpath: str, all_annotations: bool = False, include_unannotated: bool = False, @@ -139,7 +139,7 @@ def download(self, project_id: str, """Download a project by its id. Args: - project_id: The project id. + project: The project id or Project instance. outpath: The path to save the project zip file. all_annotations: Whether to include all annotations in the downloaded dataset, even those not made by the provided project. @@ -150,6 +150,7 @@ def download(self, project_id: str, if include_unannotated: params['include_unannotated'] = include_unannotated + project_id = self._entid(project) with self._stream_entity_request('GET', project_id, add_path='annotated_dataset', params=params) as response: diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 5f78cd30..3fc5fe08 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -140,16 +140,16 @@ def get_list(self, return super().get_list(limit=limit, **payload) - def get_annotations(self, resource_id: str | Resource) -> Sequence[Annotation]: + def get_annotations(self, resource: str | Resource) -> Sequence[Annotation]: """Get annotations for a specific resource. Args: - resource_id: The ID of the resource to fetch annotations for. + resource: The resource ID or Resource instance to fetch annotations for. Returns: A sequence of Annotation objects associated with the specified resource. """ - return self.annotations_api.get_list(resource=resource_id) + return self.annotations_api.get_list(resource=resource) @staticmethod def __process_files_parameter(file_path: str | IO | Sequence[str | IO] | pydicom.dataset.Dataset @@ -750,7 +750,7 @@ def download_resource_file(self, Download a resource file. Args: - resource: The resource unique id. + resource: The resource unique id or Resource instance. save_path: The path to save the file. auto_convert: Whether to convert the file to a known format or not. add_extension: Whether to add the appropriate file extension to the save_path based on the content type. @@ -804,7 +804,7 @@ def download_resource_file(self, else: resource_file = response.content except ResourceNotFoundError as e: - e.set_params('resource', {'resource_id': resource}) + e.set_params('resource', {'resource_id': self._entid(resource)}) raise e if save_path is not None: @@ -857,9 +857,9 @@ def download_resource_frame(self, return Image.open(io.BytesIO(response.content)) else: raise DatamintException( - f"Error downloading frame {frame_index} of resource {resource.id}: {response.text}") + f"Error downloading frame {frame_index} of resource {self._entid(resource)}: {response.text}") except ResourceNotFoundError as e: - e.set_params('resource', {'resource_id': resource.id}) + e.set_params('resource', {'resource_id': self._entid(resource)}) raise e def publish_resources(self, @@ -880,7 +880,7 @@ def publish_resources(self, try: self._make_entity_request('POST', resource, add_path='publish') except ResourceNotFoundError as e: - e.set_params('resource', {'resource_id': resource}) + e.set_params('resource', {'resource_id': self._entid(resource)}) raise e except Exception as e: if BaseApi._has_status_code(e, 400) and 'Resource must be in inbox status to be approved' in e.response.text: diff --git a/datamint/api/endpoints/users_api.py b/datamint/api/endpoints/users_api.py new file mode 100644 index 00000000..0df4053f --- /dev/null +++ b/datamint/api/endpoints/users_api.py @@ -0,0 +1,38 @@ +from ..entity_base_api import CreatableEntityApi, ApiConfig +from datamint.entities import User +import httpx + + +class UsersApi(CreatableEntityApi[User]): + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + super().__init__(config, User, 'users', client) + + def create(self, + email: str, + password: str | None = None, + firstname: str | None = None, + lastname: str | None = None, + roles: list[str] | None = None + ) -> str: + """Create a new user. + + Args: + email: The user's email address. + password: The user's password. If None, a random password will be generated. + firstname: The user's first name. + lastname: The user's last name. + roles: List of roles to assign to the user. + + Returns: + The id of the created user. + """ + data = dict( + email=email, + password=password, + firstname=firstname, + lastname=lastname, + roles=roles + ) + return self._create(data) diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py index 6449cde4..4aed132d 100644 --- a/datamint/entities/__init__.py +++ b/datamint/entities/__init__.py @@ -5,6 +5,7 @@ from .channel import Channel, ChannelResourceData from .project import Project from .resource import Resource +from .user import User # new export __all__ = [ 'Annotation', @@ -13,4 +14,5 @@ 'ChannelResourceData', 'Project', 'Resource', + "User" ] diff --git a/datamint/entities/user.py b/datamint/entities/user.py new file mode 100644 index 00000000..e7cfa508 --- /dev/null +++ b/datamint/entities/user.py @@ -0,0 +1,21 @@ +from .base_entity import BaseEntity + +class User(BaseEntity): + """User entity model. + + Attributes: + email: User email address (unique identifier in most cases). + firstname: First name. + lastname: Last name. + roles: List of role strings assigned to the user. + customer_id: UUID of the owning customer/tenant. + created_at: ISO 8601 timestamp of creation. + """ + email: str + firstname: str | None + lastname: str | None + roles: list[str] + customer_id: str + created_at: str + + # Potential improvement: convert created_at to datetime for easier comparisons. \ No newline at end of file From 74a17bb2663fdf15412e3a43314d5f47c9e4d071 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 11 Sep 2025 16:17:38 -0300 Subject: [PATCH 10/13] feat: Enhance Annotations and Resources APIs with new upload methods and improved error handling - Added `upload_segmentations` method to `AnnotationsApi` for uploading segmentations with detailed parameter handling. - Refactored existing upload methods in `AnnotationsApi` to use a unified async request method. - Introduced `upload_resource` method in `ResourcesApi` for single file uploads, simplifying the interface. - Updated `upload_resources` to enforce multiple file uploads and improved parameter processing. - Enhanced error handling across API methods, ensuring exceptions are raised appropriately. - Improved documentation and examples in the API and notebooks for clarity and usability. - Bumped version to 2.0.0 to reflect significant changes and enhancements. --- datamint/api/base_api.py | 249 ++++++++++++---------- datamint/api/endpoints/annotations_api.py | 168 +++++++++++++-- datamint/api/endpoints/resources_api.py | 211 ++++++++++++------ datamint/api/entity_base_api.py | 25 ++- datamint/entities/annotation.py | 7 +- notebooks/upload_data.ipynb | 85 +++++--- pyproject.toml | 2 +- 7 files changed, 517 insertions(+), 230 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index a4bda3d6..e99b2906 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -1,5 +1,5 @@ import logging -from typing import Any,Generator +from typing import Any, Generator, AsyncGenerator, Sequence import httpx from dataclasses import dataclass from datamint.exceptions import DatamintException, ResourceNotFoundError @@ -12,6 +12,7 @@ from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage from io import BytesIO import gzip +import contextlib logger = logging.getLogger(__name__) @@ -109,7 +110,7 @@ def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: curl_command = self._generate_curl_command({"method": method, "url": url, "headers": self.client.headers, - **kwargs}) + **kwargs}, fail_silently=True) logger.debug(f'Equivalent curl command: "{curl_command}"') response = self.client.request(method, url, **kwargs) response.raise_for_status() @@ -121,7 +122,9 @@ def _make_request(self, method: str, endpoint: str, **kwargs) -> httpx.Response: logger.error(f"Request error for {method} {endpoint}: {e}") raise - def _generate_curl_command(self, request_args: dict) -> str: + def _generate_curl_command(self, + request_args: dict, + fail_silently: bool = False) -> str: """ Generate a curl command for debugging purposes. @@ -131,91 +134,105 @@ def _generate_curl_command(self, request_args: dict) -> str: Returns: str: Equivalent curl command """ - method = request_args.get('method', 'GET').upper() - url = request_args['url'] - headers = request_args.get('headers', {}) - data = request_args.get('json') or request_args.get('data') - params = request_args.get('params') - - curl_command = ['curl'] - - # Add method if not GET - if method != 'GET': - curl_command.extend(['-X', method]) - - # Add headers - for key, value in headers.items(): - if key.lower() == 'apikey': - value = '' # Mask API key for security - curl_command.extend(['-H', f"'{key}: {value}'"]) - - # Add query parameters - if params: - param_str = '&'.join([f"{k}={v}" for k, v in params.items()]) - url = f"{url}?{param_str}" - # Add URL - curl_command.append(f"'{url}'") - - # Add data - if data: - if isinstance(data, aiohttp.FormData): # Check if it's aiohttp.FormData - # Handle FormData by extracting fields - form_parts = [] - for options, headers, value in data._fields: - # get the name from options - name = options.get('name', 'file') - if hasattr(value, 'read'): # File-like object - filename = getattr(value, 'name', 'file') - form_parts.extend(['-F', f"'{name}=@{filename}'"]) - else: - form_parts.extend(['-F', f"'{name}={value}'"]) - curl_command.extend(form_parts) - elif isinstance(data, dict): - curl_command.extend(['-d', f"'{json.dumps(data)}'"]) - else: - curl_command.extend(['-d', f"'{data}'"]) + try: + method = request_args.get('method', 'GET').upper() + url = request_args['url'] + headers = request_args.get('headers', {}) + data = request_args.get('json') or request_args.get('data') + params = request_args.get('params') + + curl_command = ['curl'] + + # Add method if not GET + if method != 'GET': + curl_command.extend(['-X', method]) + + # Add headers + for key, value in headers.items(): + if key.lower() == 'apikey': + value = '' # Mask API key for security + curl_command.extend(['-H', f"'{key}: {value}'"]) + + # Add query parameters + if params: + param_str = '&'.join([f"{k}={v}" for k, v in params.items()]) + url = f"{url}?{param_str}" + # Add URL + curl_command.append(f"'{url}'") + + # Add data + if data: + if isinstance(data, aiohttp.FormData): # Check if it's aiohttp.FormData + # Handle FormData by extracting fields + form_parts = [] + for options, headers, value in data._fields: + # get the name from options + name = options.get('name', 'file') + if hasattr(value, 'read'): # File-like object + filename = getattr(value, 'name', 'file') + form_parts.extend(['-F', f"'{name}=@{filename}'"]) + else: + form_parts.extend(['-F', f"'{name}={value}'"]) + curl_command.extend(form_parts) + elif isinstance(data, dict): + curl_command.extend(['-d', f"'{json.dumps(data)}'"]) + else: + curl_command.extend(['-d', f"'{data}'"]) - return ' '.join(curl_command) + return ' '.join(curl_command) + except Exception as e: + if fail_silently: + logger.debug(f"Error generating curl command: {e}") + return "" + raise @staticmethod - def get_status_code(e) -> int: - if not hasattr(e, 'response') or e.response is None: - return -1 - return e.response.status_code - + def get_status_code(e: httpx.HTTPStatusError | aiohttp.ClientResponseError) -> int: + if hasattr(e, 'response') and e.response is not None: + # httpx.HTTPStatusError + return e.response.status_code + if hasattr(e, 'status'): + # aiohttp.ClientResponseError + return e.status + if hasattr(e, 'status_code'): + return e.status_code + logger.debug(f"Unable to get status code from exception of type {type(e)}") + return -1 + @staticmethod - def _has_status_code(e, status_code: int) -> bool: + def _has_status_code(e: httpx.HTTPError | aiohttp.ClientResponseError, + status_code: int) -> bool: return BaseApi.get_status_code(e) == status_code def _check_errors_response(self, - response, + response: httpx.Response | aiohttp.ClientResponse, url: str): try: - if hasattr(response, 'raise_for_status'): - response.raise_for_status() - except Exception as e: + response.raise_for_status() + except (httpx.HTTPStatusError, aiohttp.ClientResponseError) as e: + logger.error(f"HTTP error occurred: {e}") status_code = BaseApi.get_status_code(e) if status_code >= 500 and status_code < 600: logger.error(f"Error in request to {url}: {e}") if status_code >= 400 and status_code < 500: - try: - logger.info(f"Error response: {response.text}") - error_data = response.json() - except Exception as e2: - logger.info(f"Error parsing the response. {e2}") + if isinstance(e, aiohttp.ClientResponseError): + # aiohttp.ClientResponse does not have .text or .json() methods directly + error_msg = e.message else: - if isinstance(error_data['message'], str) and ' not found' in error_data['message'].lower(): - # Will be caught by the caller and properly initialized: - raise ResourceNotFoundError('unknown', {}) - + error_msg = e.response.text + logger.info(f"Error response: {error_msg}") + if ' not found' in error_msg.lower(): + # Will be caught by the caller and properly initialized: + raise ResourceNotFoundError('unknown', {}) raise + @contextlib.asynccontextmanager async def _make_request_async(self, method: str, endpoint: str, session: aiohttp.ClientSession | None = None, - **kwargs): - """Make asynchronous HTTP request with error handling. + **kwargs) -> AsyncGenerator[aiohttp.ClientResponse, None]: + """Make asynchronous HTTP request with error handling as an async context manager. Args: method: HTTP method (GET, POST, PUT, DELETE) @@ -223,57 +240,74 @@ async def _make_request_async(self, session: Optional aiohttp session. If None, a new one will be created. **kwargs: Additional arguments for the request - Returns: - HTTP response object + Yields: + An aiohttp.ClientResponse object. Raises: aiohttp.ClientError: If the request fails + + Example: + .. code-block:: python + + async with api._make_request_async('GET', '/data') as response: + data = await response.json() """ + + if session is None: + async with aiohttp.ClientSession() as temp_session: + async with self._make_request_async(method, endpoint, temp_session, **kwargs) as resp: + yield resp + return + url = f"{self.config.server_url.rstrip('/')}/{endpoint.lstrip('/')}" - # Prepare headers headers = kwargs.pop('headers', {}) if self.config.api_key: headers['apikey'] = self.config.api_key - # Set timeout timeout = aiohttp.ClientTimeout(total=self.config.timeout) - async def make_request(client_session: aiohttp.ClientSession) -> aiohttp.ClientResponse | Any: - try: - # logger.debug(f"Making async {method} request to {url} with headers {headers} and kwargs:\n {kwargs}") - try: - logger.debug(f"Running request to {url}") - logger.debug(f'Equivalent curl command: "{self._generate_curl_command({"method": method, - "url": url, - "headers": headers, - **kwargs})}"' - ) - except Exception as e: - logger.debug(f"Error generating curl command: {e}") - async with client_session.request( - method=method, - url=url, - headers=headers, - timeout=timeout, - **kwargs - ) as response: - # Check for HTTP errors - # if response.status >= 400: - # error_text = await response.text() - # logger.error(f"HTTP error {response.status} for {method} {endpoint}: {error_text}") - self._check_errors_response(response, url=url) - return response - - except aiohttp.ClientError as e: - logger.error(f"Request error for {method} {endpoint}: {e}") - raise - - if session is not None: - return await make_request(session) - else: - async with aiohttp.ClientSession() as temp_session: - return await make_request(temp_session) + response = None + curl_cmd = self._generate_curl_command( + {"method": method, "url": url, "headers": headers, **kwargs}, + fail_silently=True + ) + logger.debug(f'Equivalent curl command: "{curl_cmd}"') + try: + response = await session.request( + method=method, + url=url, + headers=headers, + timeout=timeout, + **kwargs + ) + self._check_errors_response(response, url=url) + yield response + except aiohttp.ClientError as e: + logger.error(f"Request error for {method} {endpoint}: {e}") + raise + finally: + if response is not None: + response.release() + + async def _make_request_async_json(self, + method: str, + endpoint: str, + session: aiohttp.ClientSession | None = None, + **kwargs): + """Make asynchronous HTTP request and parse JSON response. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint path + session: Optional aiohttp session. If None, a new one will be created. + **kwargs: Additional arguments for the request + + Returns: + Parsed JSON response or error information. + """ + async with self._make_request_async(method, endpoint, session=session, **kwargs) as resp: + return await resp.json() def _make_request_with_pagination(self, method: str, @@ -391,4 +425,3 @@ def convert_format(bytes_array: bytes, return nib.Nifti1Image.from_stream(f) raise ValueError(f"Unsupported mimetype: {mimetype}") - diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index c22f4dd7..731a0ef3 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -86,7 +86,7 @@ async def _upload_segmentations_async(self, model_id: str | None = None, transpose_segmentation: bool = False, upload_volume: bool | str = 'auto' - ) -> list[str]: + ) -> Sequence[str]: """ Upload segmentations asynchronously. @@ -112,7 +112,6 @@ async def _upload_segmentations_async(self, else: upload_volume = False - resource_id = self._entid(resource) # Handle volume upload if upload_volume: @@ -315,10 +314,9 @@ async def upload_annotation_file_async(self, form.add_field('file', f, filename=filename, content_type=content_type) resource_id = self._entid(resource) endpoint = f'{self.endpoint_base}/{resource_id}/annotations/{annotation_id}/file' - resp = await self._make_request_async(method='POST', - endpoint=endpoint, - data=form) - respdata = await resp.json() + respdata = await self._make_request_async_json('POST', + endpoint=endpoint, + data=form) if isinstance(respdata, dict) and 'error' in respdata: raise DatamintException(respdata['error']) finally: @@ -391,14 +389,150 @@ def create(self, return respdata[0] return respdata + def upload_segmentations(self, + resource: str | Resource, + file_path: str | np.ndarray, + name: str | dict[int, str] | dict[tuple, str] | None = None, + frame_index: int | list[int] | None = None, + imported_from: str | None = None, + author_email: str | None = None, + discard_empty_segmentations: bool = True, + worklist_id: str | None = None, + model_id: str | None = None, + transpose_segmentation: bool = False, + ) -> list[str]: + """ + Upload segmentations to a resource. + + Args: + resource: The resource unique ID or Resource instance. + file_path: The path to the segmentation file or a numpy array. + If a numpy array is provided, it can have the shape: + - (height, width, #frames) or (height, width) for grayscale segmentations + - (3, height, width, #frames) for RGB segmentations + For NIfTI files (.nii/.nii.gz), the entire volume is uploaded as a single segmentation. + name: The name of the segmentation. + Can be: + - str: Single name for all segmentations + - dict[int, str]: Mapping pixel values to names for grayscale segmentations + - dict[tuple[int, int, int], str]: Mapping RGB tuples to names for RGB segmentations + Use 'default' as a key for a unnamed classes. + Example: {(255, 0, 0): 'Red_Region', (0, 255, 0): 'Green_Region'} + frame_index: The frame index of the segmentation. + If a list, it must have the same length as the number of frames in the segmentation. + If None, it is assumed that the segmentations are in sequential order starting from 0. + This parameter is ignored for NIfTI files as they are treated as volume segmentations. + imported_from: The imported from value. + author_email: The author email. + discard_empty_segmentations: Whether to discard empty segmentations or not. + worklist_id: The annotation worklist unique id. + model_id: The model unique id. + transpose_segmentation: Whether to transpose the segmentation or not. + + Returns: + List of segmentation unique ids. + + Raises: + ResourceNotFoundError: If the resource does not exist or the segmentation is invalid. + FileNotFoundError: If the file path does not exist. + ValueError: If frame_index is provided for NIfTI files or invalid parameters. + + Example: + .. code-block:: python + + # Grayscale segmentation + api.annotations.upload_segmentations(resource_id, 'path/to/segmentation.png', 'SegmentationName') + + # RGB segmentation with numpy array + seg_data = np.random.randint(0, 3, size=(3, 2140, 1760, 1), dtype=np.uint8) + rgb_names = {(1, 0, 0): 'Red_Region', (0, 1, 0): 'Green_Region', (0, 0, 1): 'Blue_Region'} + api.annotations.upload_segmentations(resource_id, seg_data, rgb_names) + + # Volume segmentation + api.annotations.upload_segmentations(resource_id, 'path/to/segmentation.nii.gz', 'VolumeSegmentation') + """ + import asyncio + import nest_asyncio + + if isinstance(file_path, str) and not os.path.exists(file_path): + raise FileNotFoundError(f"File {file_path} not found.") + + # Handle NIfTI files specially - upload as single volume + if isinstance(file_path, str) and (file_path.endswith('.nii') or file_path.endswith('.nii.gz')): + _LOGGER.info(f"Uploading NIfTI segmentation file: {file_path}") + if frame_index is not None: + raise ValueError("Do not provide frame_index for NIfTI segmentations.") + + # Ensure nest_asyncio is applied for Jupyter compatibility + nest_asyncio.apply() + loop = asyncio.get_event_loop() + task = self._upload_segmentations_async( + resource=resource, + frame_index=None, + file_path=file_path, + name=name, + imported_from=imported_from, + author_email=author_email, + worklist_id=worklist_id, + model_id=model_id, + transpose_segmentation=transpose_segmentation, + upload_volume=True + ) + return loop.run_until_complete(task) + + # All other file types are converted to multiple PNGs and uploaded frame by frame + standardized_name = self.standardize_segmentation_names(name) + + # Handle frame_index parameter + if isinstance(frame_index, list): + if len(set(frame_index)) != len(frame_index): + raise ValueError("frame_index list contains duplicate values.") + + nest_asyncio.apply() + loop = asyncio.get_event_loop() + task = self._upload_segmentations_async( + resource=resource, + frame_index=frame_index[0] if isinstance(frame_index, list) and len(frame_index) == 1 else None, + file_path=file_path, + name=standardized_name, + imported_from=imported_from, + author_email=author_email, + discard_empty_segmentations=discard_empty_segmentations, + worklist_id=worklist_id, + model_id=model_id, + transpose_segmentation=transpose_segmentation, + upload_volume=False + ) + return loop.run_until_complete(task) + + @staticmethod + def standardize_segmentation_names(name: str | dict[int, str] | dict[tuple, str] | None) -> dict[int, str] | dict[tuple, str]: + """ + Standardize segmentation names to a consistent format. + + Args: + name: The name input in various formats. + + Returns: + Standardized name dictionary. + """ + if name is None: + return {0: 'default'} # Return a dict with integer key for compatibility + elif isinstance(name, str): + return {0: name} # Use integer key for single string names + elif isinstance(name, dict): + # Return the dict as-is since it's already in the correct format + return name + else: + raise ValueError("Invalid name format. Must be str, dict[int, str], dict[tuple, str], or None.") + async def _create_async(self, resource_id: str, annotations_dto: list[CreateAnnotationDto] | list[dict]) -> list[str]: annotations = [ann.to_dict() if isinstance(ann, CreateAnnotationDto) else ann for ann in annotations_dto] - resp = await self._make_request_async('POST', - f'{self.endpoint_base}/{resource_id}/annotations', - json=annotations) - respdata = await resp.json() + respdata = await self._make_request_async_json('POST', + f'{self.endpoint_base}/{resource_id}/annotations', + json=annotations) for r in respdata: if isinstance(r, dict) and 'error' in r: raise DatamintException(r['error']) @@ -470,7 +604,7 @@ async def _upload_volume_segmentation_async(self, worklist_id: str | None = None, model_id: str | None = None, transpose_segmentation: bool = False - ) -> list[str]: + ) -> Sequence[str]: """ Upload a volume segmentation as a single file asynchronously. @@ -497,6 +631,8 @@ async def _upload_volume_segmentation_async(self, if any(isinstance(k, tuple) for k in name.keys()): raise NotImplementedError( "For volume segmentations, `name` must be a dictionary with integer keys only.") + if 'default' in name: + _LOGGER.warning("Ignoring 'default' key in name dictionary for volume segmentation. Not supported yet.") # Prepare file for upload if isinstance(file_path, str): @@ -513,10 +649,9 @@ async def _upload_volume_segmentation_async(self, if name is not None: form.add_field('segmentation_map', json.dumps(name), content_type='application/json') - resp = await self._make_request_async(method='POST', - endpoint=f'{self.endpoint_base}/{resource_id}/segmentations/file', - data=form) - respdata = await resp.json() + respdata = await self._make_request_async_json('POST', + f'{self.endpoint_base}/{resource_id}/segmentations/file', + data=form) if 'error' in respdata: raise DatamintException(respdata['error']) return respdata @@ -703,7 +838,6 @@ def add_line_annotation(self, author_email=author_email, model_id=model_id ) - def _create_geometry_annotation(self, geometry: Geometry, @@ -748,4 +882,4 @@ def _create_geometry_annotation(self, annotation_worklist_id=worklist_id ) - return self.create(resource_id, annotation_dto) \ No newline at end of file + return self.create(resource_id, annotation_dto) diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 3fc5fe08..971d05ec 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -11,7 +11,6 @@ import json import logging import pydicom -import pydicom.dataset from medimgkit.dicom_utils import anonymize_dicom, to_bytesio, is_dicom, is_dicom_report, GeneratorWithLength from medimgkit import dicom_utils, standardize_mimetype from medimgkit.io_utils import is_io_object, peek @@ -152,36 +151,18 @@ def get_annotations(self, resource: str | Resource) -> Sequence[Annotation]: return self.annotations_api.get_list(resource=resource) @staticmethod - def __process_files_parameter(file_path: str | IO | Sequence[str | IO] | pydicom.dataset.Dataset - ) -> tuple[Sequence[str | IO], bool]: + def __process_files_parameter(file_path: Sequence[str | IO | pydicom.Dataset] + ) -> Sequence[str | IO]: """ Process the file_path parameter to ensure it is a list of file paths or IO objects. """ - if isinstance(file_path, pydicom.dataset.Dataset): - file_path = to_bytesio(file_path, file_path.filename) - - if isinstance(file_path, str): - if os.path.isdir(file_path): - is_list = True - new_file_path = [f'{file_path}/{f}' for f in os.listdir(file_path)] - else: - is_list = False - new_file_path = [file_path] - # Check if is an IO object - elif is_io_object(file_path): - is_list = False - new_file_path = [file_path] - elif not hasattr(file_path, '__len__'): - if hasattr(file_path, '__iter__'): - is_list = True - new_file_path = list(file_path) + processed_files = [] + for item in file_path: + if isinstance(item, pydicom.Dataset): + processed_files.append(to_bytesio(item, item.filename)) else: - is_list = False - new_file_path = [file_path] - else: - is_list = True - new_file_path = file_path - return new_file_path, is_list + processed_files.append(item) + return processed_files def _assemble_dicoms(self, files_path: Sequence[str | IO] ) -> tuple[Sequence[str | IO], bool, Sequence[int]]: @@ -361,10 +342,9 @@ async def _upload_single_resource_async(self, except Exception as e: _LOGGER.warning(f"Failed to add metadata to form: {e}") - resp = await self._make_request_async(method='POST', - endpoint=self.endpoint_base, - data=form) - resp_data = await resp.json() + resp_data = await self._make_request_async_json('POST', + endpoint=self.endpoint_base, + data=form) if 'error' in resp_data: raise DatamintException(resp_data['error']) _LOGGER.debug(f"Response on uploading {name}: {resp_data}") @@ -374,7 +354,7 @@ async def _upload_single_resource_async(self, _LOGGER.error(f"Error uploading {name}: {e}") else: _LOGGER.error(f"Error uploading {file_path}: {e}") - raise e + raise finally: f.close() @@ -451,29 +431,32 @@ async def __upload_single_resource(file_path, segfiles: dict[str, list | dict], return await asyncio.gather(*tasks, return_exceptions=on_error == 'skip') def upload_resources(self, - files_path: str | IO | Sequence[str | IO] | pydicom.dataset.Dataset, - mimetype: Optional[str] = None, + files_path: Sequence[str | IO | pydicom.Dataset], + mimetype: str | None = None, anonymize: bool = False, anonymize_retain_codes: Sequence[tuple] = [], on_error: Literal['raise', 'skip'] = 'raise', - tags: Optional[Sequence[str]] = None, + tags: Sequence[str] | None = None, mung_filename: Sequence[int] | Literal['all'] | None = None, - channel: Optional[str] = None, + channel: str | None = None, publish: bool = False, - publish_to: Optional[str] = None, - segmentation_files: Optional[list[list[str] | dict]] = None, + publish_to: str | None = None, + segmentation_files: Sequence[Sequence[str] | dict] | None = None, transpose_segmentation: bool = False, - modality: Optional[str] = None, + modality: str | None = None, assemble_dicoms: bool = True, - metadata: list[str | dict | None] | dict | str | None = None, + metadata: Sequence[str | dict | None] | None = None, discard_dicom_reports: bool = True, progress_bar: bool = False - ) -> list[str | Exception] | str | Exception: + ) -> Sequence[str | Exception]: """ - Upload resources. + Upload multiple resources. + + Note: For uploading a single resource, use `upload_resource()` instead. Args: - files_path (str | IO | Sequence[str | IO]): The path to the resource file or a list of paths to resources files. + files_path: A sequence of paths to resource files, IO objects, or pydicom.Dataset objects. + Must contain at least 2 items. Supports mixed types within the sequence. mimetype (str): The mimetype of the resources. If None, it will be guessed. anonymize (bool): Whether to anonymize the dicoms or not. anonymize_retain_codes (Sequence[tuple]): The tags to retain when anonymizing the dicoms. @@ -493,11 +476,12 @@ def upload_resources(self, transpose_segmentation (bool): Whether to transpose the segmentation files or not. modality (Optional[str]): The modality of the resources. assemble_dicoms (bool): Whether to assemble the dicom files or not based on the SeriesInstanceUID and InstanceNumber attributes. - metadatas (Optional[list[str | dict | None]]): JSON metadata to include with each resource. + metadata (Optional[list[str | dict | None]]): JSON metadata to include with each resource. Must have the same length as `files_path`. Can be file paths (str) or already loaded dictionaries (dict). Raises: + ValueError: If a single resource is provided instead of multiple resources. ResourceNotFoundError: If `publish_to` is supplied, and the project does not exists. Returns: @@ -507,7 +491,12 @@ def upload_resources(self, if on_error not in ['raise', 'skip']: raise ValueError("on_error must be either 'raise' or 'skip'") - files_path, is_multiple_resources = ResourcesApi.__process_files_parameter(files_path) + # Check if single resource provided and raise error + if isinstance(files_path, (str, IO)) or isinstance(files_path, pydicom.Dataset): + raise ValueError( + "upload_resources() only accepts multiple resources. For single resource upload, use upload_resource() instead.") + + files_path = ResourcesApi.__process_files_parameter(files_path) # Discard DICOM reports if discard_dicom_reports: @@ -549,9 +538,8 @@ def upload_resources(self, if segmentation_files is not None: if assemble_dicoms: raise NotImplementedError("Segmentation files cannot be uploaded when assembling dicoms yet.") - if is_multiple_resources: - if len(segmentation_files) != len(files_path): - raise ValueError("The number of segmentation files must match the number of resources.") + if len(segmentation_files) != len(files_path): + raise ValueError("The number of segmentation files must match the number of resources.") else: if isinstance(segmentation_files, list) and isinstance(segmentation_files[0], list): raise ValueError("segmentation_files should not be a list of lists if files_path is not a list.") @@ -615,9 +603,110 @@ def upload_resources(self, _LOGGER.debug(f"Mapping indices for DICOM files: {mapping_idx}") resource_ids = [resource_ids[idx] for idx in mapping_idx] - if is_multiple_resources: - return resource_ids - return resource_ids[0] + return resource_ids + + def upload_resource(self, + file_path: str | IO | pydicom.Dataset, + mimetype: str | None = None, + anonymize: bool = False, + anonymize_retain_codes: Sequence[tuple] = [], + tags: Sequence[str] | None = None, + mung_filename: Sequence[int] | Literal['all'] | None = None, + channel: str | None = None, + publish: bool = False, + publish_to: str | None = None, + segmentation_files: dict | None = None, + transpose_segmentation: bool = False, + modality: str | None = None, + metadata: dict | str | None = None, + discard_dicom_reports: bool = True + ) -> str: + """ + Upload a single resource. + + This is a convenience method that wraps upload_resources for single file uploads. + It provides a cleaner interface when uploading just one file. + + Args: + file_path: The path to the resource file or IO object. + mimetype: The mimetype of the resource. If None, it will be guessed. + anonymize: Whether to anonymize the DICOM or not. + anonymize_retain_codes: The tags to retain when anonymizing the DICOM. + tags: The tags to add to the resource. + mung_filename: The parts of the filepath to keep when renaming the resource file. + 'all' keeps all parts. + channel: The channel to upload the resource to. An arbitrary name to group the resources. + publish: Whether to directly publish the resource or not. It will have the 'published' status. + publish_to: The project name or id to publish the resource to. + It will have the 'published' status and will be added to the project. + If this is set, `publish` parameter is ignored. + segmentation_files: The segmentation files to upload. Should be a dict with: + - 'files': A list of paths to the segmentation files. Example: ['seg1.nii.gz', 'seg2.nii.gz']. + - 'names': A dict mapping pixel values to class names. Example: {1: 'Brain', 2: 'Lung'}. + transpose_segmentation: Whether to transpose the segmentation files or not. + modality: The modality of the resource. + metadata: JSON metadata to include with the resource. + Can be a file path (str) or already loaded dictionary (dict). + discard_dicom_reports: Whether to discard DICOM reports or not. + + Returns: + str: The resource ID of the uploaded resource. + + Raises: + ResourceNotFoundError: If `publish_to` is supplied, and the project does not exist. + DatamintException: If the upload fails. + + Example: + .. code-block:: python + + # Simple upload + resource_id = api.resources.upload_resource('path/to/file.dcm') + + # Upload with metadata and segmentation + resource_id = api.resources.upload_resource( + 'path/to/file.dcm', + tags=['tutorial', 'case1'], + channel='study_channel', + segmentation_files={ + 'files': ['path/to/segmentation.nii.gz'], + 'names': {1: 'Bone', 2: 'Tissue'} + }, + metadata={'patient_age': 45, 'modality': 'CT'} + ) + """ + # Convert segmentation_files to the format expected by upload_resources + segmentation_files_list: Optional[list[list[str] | dict]] = None + if segmentation_files is not None: + segmentation_files_list = [segmentation_files] + + # Call upload_resources with single file + result = self.upload_resources( + files_path=[file_path], + mimetype=mimetype, + anonymize=anonymize, + anonymize_retain_codes=anonymize_retain_codes, + tags=tags, + mung_filename=mung_filename, + channel=channel, + publish=publish, + publish_to=publish_to, + segmentation_files=segmentation_files_list, + transpose_segmentation=transpose_segmentation, + modality=modality, + metadata=[metadata], + discard_dicom_reports=discard_dicom_reports, + progress_bar=False # Disable progress bar for single uploads + ) + + # upload_resources returns a list, so we extract the first element + if isinstance(result, Sequence) and len(result) == 1: + r = result[0] + if isinstance(r, Exception): + raise r + return r + else: + # This should not happen with single file uploads, but handle it just in case + raise DatamintException(f"Unexpected return from upload_resources: {type(result)} | {result}") def _determine_mimetype(self, content, @@ -656,11 +745,11 @@ async def _async_download_file(self, save_path = str(save_path) # Ensure save_path is a string for file operations resource_id = self._entid(resource) try: - resp = await self._make_request_async('GET', - f'{self.endpoint_base}/{resource_id}/file', - session=session, - headers={'accept': 'application/octet-stream'}) - data_bytes = await resp.read() + async with self._make_request_async('GET', + f'{self.endpoint_base}/{resource_id}/file', + session=session, + headers={'accept': 'application/octet-stream'}) as resp: + data_bytes = await resp.read() final_save_path = save_path if add_extension: @@ -745,7 +834,7 @@ def download_resource_file(self, save_path: Optional[str] = None, auto_convert: bool = True, add_extension: bool = False - ) -> bytes | pydicom.dataset.Dataset | Image.Image | cv2.VideoCapture | nib_FileBasedImage | tuple[Any, str]: + ) -> bytes | pydicom.Dataset | Image.Image | cv2.VideoCapture | nib_FileBasedImage | tuple[Any, str]: """ Download a resource file. @@ -766,7 +855,7 @@ def download_resource_file(self, >>> api_handler.download_resource_file('resource_id', auto_convert=False) returns the resource content in bytes. >>> api_handler.download_resource_file('resource_id', auto_convert=True) - Assuming this resource is a dicom file, it will return a pydicom.dataset.Dataset object. + Assuming this resource is a dicom file, it will return a pydicom.Dataset object. >>> api_handler.download_resource_file('resource_id', save_path='path/to/dicomfile.dcm') saves the file in the specified path. """ @@ -881,9 +970,9 @@ def publish_resources(self, self._make_entity_request('POST', resource, add_path='publish') except ResourceNotFoundError as e: e.set_params('resource', {'resource_id': self._entid(resource)}) - raise e - except Exception as e: + raise + except httpx.HTTPError as e: if BaseApi._has_status_code(e, 400) and 'Resource must be in inbox status to be approved' in e.response.text: _LOGGER.warning(f"Resource {resource} is not in inbox status. Skipping publishing") else: - raise e \ No newline at end of file + raise diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index 15d616f8..98ad5fd2 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -7,6 +7,8 @@ import aiohttp import asyncio from .base_api import ApiConfig, BaseApi +import contextlib +from typing import AsyncGenerator logger = logging.getLogger(__name__) T = TypeVar('T', bound=BaseEntity) @@ -56,19 +58,21 @@ def _make_entity_request(self, raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e raise + @contextlib.asynccontextmanager async def _make_entity_request_async(self, method: str, entity_id: str | BaseEntity, add_path: str = '', session: aiohttp.ClientSession | None = None, - **kwargs) -> aiohttp.ClientResponse: + **kwargs) -> AsyncGenerator[aiohttp.ClientResponse, None]: try: entity_id = self._entid(entity_id) add_path = '/'.join(add_path.strip().strip('/').split('/')) - return await self._make_request_async(method, - f'/{self.endpoint_base}/{entity_id}/{add_path}', - session=session, - **kwargs) + async with self._make_request_async(method, + f'/{self.endpoint_base}/{entity_id}/{add_path}', + session=session, + **kwargs) as resp: + yield resp except aiohttp.ClientResponseError as e: if e.status == 404: raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e @@ -140,7 +144,7 @@ def get_by_id(self, entity_id: str) -> T: response = self._make_entity_request('GET', entity_id) return self.entity_class(**response.json()) - async def _create_async(self, entity_data: dict[str, Any]) -> str | list[str | dict]: + async def _create_async(self, entity_data: dict[str, Any]) -> str | Sequence[str | dict]: """Create a new entity. Args: @@ -152,10 +156,11 @@ async def _create_async(self, entity_data: dict[str, Any]) -> str | list[str | d Raises: httpx.HTTPStatusError: If creation fails. """ - resp = await self._make_request_async('POST', - f'/{self.endpoint_base}', - json=entity_data) - respdata = await resp.json() + respdata = await self._make_request_async_json('POST', + f'/{self.endpoint_base}', + json=entity_data) + if 'error' in respdata: + raise DatamintException(respdata['error']) if isinstance(respdata, str): return respdata if isinstance(respdata, list): diff --git a/datamint/entities/annotation.py b/datamint/entities/annotation.py index 5ffb7ad3..fec7337e 100644 --- a/datamint/entities/annotation.py +++ b/datamint/entities/annotation.py @@ -8,6 +8,7 @@ from typing import Any import logging from .base_entity import BaseEntity, MISSING_FIELD +from pydantic import Field logger = logging.getLogger(__name__) @@ -74,5 +75,7 @@ class Annotation(BaseEntity): user_info: dict | None values: list | None = MISSING_FIELD - # TODO: Consider constraining some fields with Literal types and parsing timestamps to datetime - # once the API schema is stable, to provide stronger validation. + @property + def type(self) -> str: + """Alias for :attr:`annotation_type`.""" + return self.annotation_type \ No newline at end of file diff --git a/notebooks/upload_data.ipynb b/notebooks/upload_data.ipynb index 3aeaf810..ac83d9c5 100644 --- a/notebooks/upload_data.ipynb +++ b/notebooks/upload_data.ipynb @@ -32,7 +32,15 @@ "source": [ "# Setup & Connection\n", "\n", - "Initialize the Datamint API connection. Make sure you've run `datamint-config` in your terminal first." + "Initialize the Datamint API connection using the new **modular API client**. This provides a cleaner, more organized interface with dedicated modules for different operations:\n", + "\n", + "- `api.resources` - Resource management (upload, download, etc.)\n", + "- `api.projects` - Project management and collaboration\n", + "- `api.annotations` - Annotation handling and segmentations\n", + "- `api.channels` - Channel organization\n", + "- `api.users` - User management\n", + "\n", + "Make sure you've run `datamint-config` in your terminal first." ] }, { @@ -41,14 +49,13 @@ "metadata": {}, "outputs": [], "source": [ - "from datamint import APIHandler\n", - "import json\n", + "from datamint import Api\n", "from pathlib import Path\n", - "\n", - "# Creates a connection with the server\n", + "_debug('datamint')\n", + "# Creates a connection with the server.\n", "# Don't forget to run `datamint-config` in a terminal, if you haven't already.\n", - "# Or use api_key parameter in APIHandler\n", - "api = APIHandler()" + "# Or use api_key parameter in Api()\n", + "api = Api()" ] }, { @@ -68,7 +75,7 @@ "source": [ "# Single file upload with comprehensive options\n", "dicom_file = '../data/Case14.dcm'\n", - "new_resource_id = api.upload_resource(\n", + "new_resource_id = api.resources.upload_resource(\n", " dicom_file,\n", " channel='tutorial_channel', # arbitrary channel name for organization\n", " tags=['tutorial', 'case14'], # tags for easy searching later\n", @@ -79,6 +86,20 @@ "print(f\"Uploaded resource ID: {new_resource_id}\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## API Method Comparison\n", + "\n", + "| Method | Use Case | Example |\n", + "|--------|----------|---------|\n", + "| `upload_resource()` | Single file upload | `api.resources.upload_resource('file.dcm')` |\n", + "| `upload_resources()` | Batch upload | `api.resources.upload_resources(['file1.dcm', 'file2.png'])` |\n", + "\n", + "Both methods support the same parameters (tags, channels, segmentations, metadata, etc.)." + ] + }, { "cell_type": "code", "execution_count": null, @@ -86,7 +107,7 @@ "outputs": [], "source": [ "# Get all the resources with specific tags\n", - "all_resources = list(api.get_resources(\n", + "all_resources = list(api.resources.get_list(\n", " status='inbox',\n", " tags=['tutorial']\n", "))\n", @@ -140,7 +161,7 @@ " '../data/sample_video.mp4' # Replace with actual video file\n", "]\n", "\n", - "resource_ids = api.upload_resources(\n", + "resource_ids = api.resources.upload_resources(\n", " files_to_upload,\n", " channel='batch_upload_demo',\n", " tags=['batch', 'mixed_types'],\n", @@ -184,11 +205,12 @@ "# Define pixel value to anatomical region mapping\n", "class_names = {\n", " 1: \"Femur\", # Pixel value 1 represents femur\n", - " 2: \"Tibia\" # Pixel value 2 represents tibia\n", + " 2: \"Tibia\", # Pixel value 2 represents tibia\n", "}\n", "\n", - "segmentation_ids = api.upload_segmentations(\n", - " resource_id=resource_id,\n", + "# Use the new annotations API with the upload_segmentations method\n", + "segmentation_ids = api.annotations.upload_segmentations(\n", + " resource=resource_id,\n", " file_path=seg_file,\n", " name=class_names,\n", " imported_from='manual_annotation' # Track the source of annotations\n", @@ -228,7 +250,7 @@ " 'names': class_names # mapping pixel values to class names\n", "}\n", "\n", - "new_resource_id = api.upload_resource(\n", + "new_resource_id = api.resources.upload_resource(\n", " dicom_file,\n", " segmentation_files=segfiles,\n", " channel='with_segmentation',\n", @@ -271,18 +293,18 @@ " \"modality\": \"CT\"\n", "}\n", "# Upload with metadata\n", - "resource_with_metadata = api.upload_resource(\n", + "resource_with_metadata = api.resources.upload_resource(\n", " nifti_file,\n", " channel='with_metadata',\n", " tags=['tutorial', 'metadata_example'],\n", - " metadata=metadata_example # List of metadata files\n", + " metadata=metadata_example\n", ")\n", "\n", "print(f\"Uploaded resource with metadata: {resource_with_metadata}\")\n", "\n", "# Verify the metadata was included\n", - "resource_info = api.get_resources_by_ids(resource_with_metadata)\n", - "print(\"Resource modality:\", resource_info.get('modality', 'Not specified'))\n" + "resource_info = api.resources.get_by_id(resource_with_metadata)\n", + "print(\"Resource modality:\", resource_info.modality)" ] }, { @@ -307,37 +329,38 @@ "outputs": [], "source": [ "# Get some resources to add to a project\n", - "tutorial_resources = list(api.get_resources(\n", + "tutorial_resources = list(api.resources.get_list(\n", " tags=['tutorial'],\n", " status='inbox'\n", "))\n", "\n", "if tutorial_resources:\n", - " resource_ids_for_project = [r['id'] for r in tutorial_resources[:3]] # Take first 3 resources\n", + " resource_ids_for_project = [r.id for r in tutorial_resources[:3]] # Take first 3 resources\n", "\n", " # Create a new project\n", " try:\n", - " project = api.create_project(\n", + " project_id = api.projects.create(\n", " name=\"Tutorial Project\",\n", " description=\"A project created for demonstration purposes\",\n", " resources_ids=resource_ids_for_project\n", " )\n", + " project = api.projects.get_by_id(project_id)\n", "\n", - " print(f\"Created project: {project['name']} (ID: {project['id']})\")\n", + " print(f\"Created project: {project.name} (ID: {project.id})\")\n", "\n", " # List all projects\n", - " all_projects = api.get_projects()\n", + " all_projects = api.projects.get_list()\n", " print(f\"\\nAll projects ({len(all_projects)}):\")\n", " for proj in all_projects:\n", - " print(f\" - {proj['name']} (ID: {proj['id']})\")\n", + " print(f\" - {proj.name} (ID: {proj.id})\")\n", "\n", " except Exception as e:\n", " print(f\"Error creating project (may already exist): {e}\")\n", "\n", " # Try to find existing project\n", - " existing_projects = [p for p in api.get_projects() if p['name'] == \"Tutorial Project\"]\n", - " if existing_projects:\n", - " print(f\"Found existing project: {existing_projects[0]['name']}\")\n", + " existing_project = api.projects.get_by_name(\"Tutorial Project\")\n", + " if existing_project:\n", + " print(f\"Found existing project: {existing_project.name}\")\n", "else:\n", " print(\"No tutorial resources found to add to project\")" ] @@ -363,23 +386,23 @@ "outputs": [], "source": [ "# Download a resource file\n", - "api.download_resource_file(\n", + "api.resources.download_resource_file(\n", " new_resource_id,\n", " auto_convert=False,\n", " save_path='downloaded_resource.dcm' # Save to a specific file\n", ")\n", "\n", "# Download and auto-convert (for DICOM files, returns pydicom Dataset)\n", - "resource_object = api.download_resource_file(\n", + "resource_object = api.resources.download_resource_file(\n", " new_resource_id,\n", " auto_convert=True\n", ")\n", "print(f\"Auto-converted to: {type(resource_object)}\") # `pydicom.Dataset` object\n", "\n", "# Get annotations for this resource\n", - "annotations = list(api.get_annotations(resource_id=new_resource_id))\n", + "annotations = list(api.annotations.get_list(resource=new_resource_id))\n", "for ann in annotations:\n", - " print(f\" - {ann.get('identifier', 'Unknown')}: {ann.get('type', 'Unknown type')}\")" + " print(f\" - {ann.identifier}: {ann.type}\")" ] }, { diff --git a/pyproject.toml b/pyproject.toml index 0c84431a..61d4bbf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "datamint" description = "A library for interacting with the Datamint API, designed for efficient data management, processing and Deep Learning workflows." -version = "1.9.3" +version = "2.0.0" dynamic = ["dependencies"] requires-python = ">=3.10" readme = "README.md" From f08b71bb32722d3f78d4b13dd1a09de209df3548 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Fri, 12 Sep 2025 11:02:26 -0300 Subject: [PATCH 11/13] feat: Mark APIHandler and BaseAPIHandler as deprecated with updated warning messages --- datamint/apihandler/api_handler.py | 9 +++------ datamint/apihandler/base_api_handler.py | 5 +++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/datamint/apihandler/api_handler.py b/datamint/apihandler/api_handler.py index 63bb2c6a..1ec9d3ad 100644 --- a/datamint/apihandler/api_handler.py +++ b/datamint/apihandler/api_handler.py @@ -1,15 +1,12 @@ from .root_api_handler import RootAPIHandler from .annotation_api_handler import AnnotationAPIHandler from .exp_api_handler import ExperimentAPIHandler +from deprecated.sphinx import deprecated +@deprecated(reason="Please use `from datamint import Api` instead.", version="2.0.0") class APIHandler(RootAPIHandler, ExperimentAPIHandler, AnnotationAPIHandler): """ - Import using this code: - - .. code-block:: python - - from datamint import APIHandler - api = APIHandler() + Deprecated. Use `from datamint import Api` instead. """ pass \ No newline at end of file diff --git a/datamint/apihandler/base_api_handler.py b/datamint/apihandler/base_api_handler.py index a6dfd1d0..887311c0 100644 --- a/datamint/apihandler/base_api_handler.py +++ b/datamint/apihandler/base_api_handler.py @@ -16,6 +16,7 @@ from datamint import configs import gzip from datamint.exceptions import DatamintException, ResourceNotFoundError +from deprecated.sphinx import deprecated _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,7 @@ _PAGE_LIMIT = 5000 +@deprecated(reason="Please use `from datamint import Api` instead.", version="2.0.0") class BaseAPIHandler: """ Class to handle the API requests to the Datamint API @@ -41,6 +43,9 @@ def __init__(self, root_url: Optional[str] = None, api_key: Optional[str] = None, check_connection: bool = True): + # deprecated + _LOGGER.warning("The class APIHandler is deprecated and will be removed in future versions. " + "Please use `from datamint import Api` instead.") nest_asyncio.apply() # For running asyncio in jupyter notebooks self.root_url = root_url if root_url is not None else configs.get_value(configs.APIURL_KEY) if self.root_url is None: From 73ee42b06f14b7c7b967eccfd33e40ffb6bae3a6 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 18 Sep 2025 13:35:08 -0300 Subject: [PATCH 12/13] Refactor API structure and enhance Annotation model - Updated import paths for Annotation and DatasetInfo entities. - Modified the DatamintDataset class to use new image resizing method. - Added new DatasetInfo model for dataset representation. - Enhanced Annotation model with additional properties and methods for better usability. - Updated documentation to reflect changes in API class names and structure. - Removed deprecated Experiment class documentation and related examples. - Improved resource upload and management methods in the API client. - Adjusted API key setup instructions to align with new class structure. --- README.md | 13 +- datamint/api/__init__.py | 3 + datamint/api/base_api.py | 35 ++-- datamint/api/client.py | 10 +- datamint/api/dto/__init__.py | 10 +- datamint/api/endpoints/__init__.py | 4 +- datamint/api/endpoints/annotations_api.py | 105 +++++++++- datamint/api/endpoints/datasetsinfo_api.py | 16 ++ datamint/api/endpoints/projects_api.py | 2 +- datamint/api/endpoints/resources_api.py | 69 +++++-- datamint/api/entity_base_api.py | 5 +- datamint/dataset/base_dataset.py | 109 ++++------ datamint/dataset/dataset.py | 4 +- datamint/entities/__init__.py | 4 +- datamint/entities/annotation.py | 99 ++++++++- datamint/entities/datasetinfo.py | 22 ++ datamint/entities/resource.py | 2 +- docs/source/client_api.rst | 4 +- docs/source/client_api_content.rst | 229 +++++++++++---------- docs/source/conf.py | 16 +- docs/source/datamint.api.base_classes.rst | 20 ++ docs/source/datamint.api.client.rst | 10 + docs/source/datamint.api.dto.rst | 7 + docs/source/datamint.api.endpoints.rst | 44 ++++ docs/source/datamint.apihandler.rst | 19 +- docs/source/datamint.entities.rst | 11 + docs/source/datamint.exceptions.rst | 7 + docs/source/datamint.experiment.rst | 5 - docs/source/index.rst | 5 +- docs/source/running_experiments.rst | 179 ---------------- docs/source/setup_api_key.rst | 10 +- notebooks/upload_data.ipynb | 1 - 32 files changed, 639 insertions(+), 440 deletions(-) create mode 100644 datamint/api/__init__.py create mode 100644 datamint/api/endpoints/datasetsinfo_api.py create mode 100644 datamint/entities/datasetinfo.py create mode 100644 docs/source/datamint.api.base_classes.rst create mode 100644 docs/source/datamint.api.client.rst create mode 100644 docs/source/datamint.api.dto.rst create mode 100644 docs/source/datamint.api.endpoints.rst create mode 100644 docs/source/datamint.entities.rst create mode 100644 docs/source/datamint.exceptions.rst delete mode 100644 docs/source/datamint.experiment.rst delete mode 100644 docs/source/running_experiments.rst diff --git a/README.md b/README.md index 506a1f51..438c2493 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ import os os.environ["DATAMINT_API_KEY"] = "my_api_key" ``` -### Method 3: APIHandler constructor +### Method 3: Api constructor -Specify API key in the |APIHandlerClass| constructor: +Specify API key in the Api constructor: ```python -from datamint import APIHandler -api = APIHandler(api_key='my_api_key') +from datamint import Api +api = Api(api_key='my_api_key') ``` ## Tutorials @@ -64,8 +64,9 @@ You can find example notebooks in the `notebooks` folder: and example scripts in [examples](examples) folder: -- [Running an experiment for classification](examples/experiment_traintest_classifier.py) -- [Running an experiment for segmentation](examples/experiment_traintest_segmentation.py) +- [API usage examples](examples/api_usage.ipynb) +- [Project and entity usage](examples/project_entity_usage.ipynb) +- [Channels example](examples/channels_example.ipynb) ## Full documentation diff --git a/datamint/api/__init__.py b/datamint/api/__init__.py new file mode 100644 index 00000000..5d93a1de --- /dev/null +++ b/datamint/api/__init__.py @@ -0,0 +1,3 @@ +from .client import Api + +__all__ = ['Api'] diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index e99b2906..810b6953 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -13,6 +13,7 @@ from io import BytesIO import gzip import contextlib +import asyncio logger = logging.getLogger(__name__) @@ -50,6 +51,7 @@ def __init__(self, """ self.config = config self.client = client or self._create_client() + self.semaphore = asyncio.Semaphore(20) def _create_client(self) -> httpx.Client: """Create and configure HTTP client with authentication and timeouts.""" @@ -273,22 +275,23 @@ async def _make_request_async(self, fail_silently=True ) logger.debug(f'Equivalent curl command: "{curl_cmd}"') - try: - response = await session.request( - method=method, - url=url, - headers=headers, - timeout=timeout, - **kwargs - ) - self._check_errors_response(response, url=url) - yield response - except aiohttp.ClientError as e: - logger.error(f"Request error for {method} {endpoint}: {e}") - raise - finally: - if response is not None: - response.release() + async with self.semaphore: + try: + response = await session.request( + method=method, + url=url, + headers=headers, + timeout=timeout, + **kwargs + ) + self._check_errors_response(response, url=url) + yield response + except aiohttp.ClientError as e: + logger.error(f"Request error for {method} {endpoint}: {e}") + raise + finally: + if response is not None: + response.release() async def _make_request_async_json(self, method: str, diff --git a/datamint/api/client.py b/datamint/api/client.py index 422a194b..76676f66 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -1,7 +1,7 @@ from typing import Optional import httpx from .base_api import ApiConfig -from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi, UsersApi +from .endpoints import ProjectsApi, ResourcesApi, AnnotationsApi, ChannelsApi, UsersApi, DatasetsInfoApi import datamint.configs from datamint.exceptions import DatamintException import asyncio @@ -18,12 +18,13 @@ class Api: 'annotations': AnnotationsApi, 'channels': ChannelsApi, 'users': UsersApi, + 'datasets': DatasetsInfoApi } def __init__(self, server_url: str | None = None, api_key: Optional[str] = None, - timeout: float = 30.0, max_retries: int = 3, + timeout: float = 60.0, max_retries: int = 2, check_connection: bool = True) -> None: """Initialize the API client. @@ -84,4 +85,7 @@ def channels(self) -> ChannelsApi: @property def users(self) -> UsersApi: return self._get_endpoint('users') - + @property + def _datasetsinfo(self) -> DatasetsInfoApi: + """Internal property to access DatasetsInfoApi.""" + return self._get_endpoint('datasets') \ No newline at end of file diff --git a/datamint/api/dto/__init__.py b/datamint/api/dto/__init__.py index af933b5d..b789a550 100644 --- a/datamint/api/dto/__init__.py +++ b/datamint/api/dto/__init__.py @@ -1,2 +1,10 @@ from datamint.apihandler.dto import annotation_dto -from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, Geometry, BoxGeometry \ No newline at end of file +from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, Geometry, BoxGeometry + +__all__ = [ + "annotation_dto", + "AnnotationType", + "CreateAnnotationDto", + "Geometry", + "BoxGeometry", +] \ No newline at end of file diff --git a/datamint/api/endpoints/__init__.py b/datamint/api/endpoints/__init__.py index ce05a4c2..b19d79f9 100644 --- a/datamint/api/endpoints/__init__.py +++ b/datamint/api/endpoints/__init__.py @@ -5,11 +5,13 @@ from .projects_api import ProjectsApi from .resources_api import ResourcesApi from .users_api import UsersApi +from .datasetsinfo_api import DatasetsInfoApi __all__ = [ 'AnnotationsApi', 'ChannelsApi', 'ProjectsApi', 'ResourcesApi', - 'UsersApi' + 'UsersApi', + 'DatasetsInfoApi' ] diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 731a0ef3..7097c685 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -17,6 +17,9 @@ from PIL import Image from io import BytesIO import pydicom +from pathlib import Path +from tqdm.auto import tqdm +import asyncio _LOGGER = logging.getLogger(__name__) _USER_LOGGER = logging.getLogger('user_logger') @@ -451,7 +454,6 @@ def upload_segmentations(self, # Volume segmentation api.annotations.upload_segmentations(resource_id, 'path/to/segmentation.nii.gz', 'VolumeSegmentation') """ - import asyncio import nest_asyncio if isinstance(file_path, str) and not os.path.exists(file_path): @@ -775,7 +777,7 @@ def add_line_annotation(self, worklist_id: str | None = None, imported_from: str | None = None, author_email: str | None = None, - model_id: str | None = None) -> list[str]: + model_id: str | None = None) -> Sequence[str]: """ Add a line annotation to a resource. @@ -848,7 +850,7 @@ def _create_geometry_annotation(self, worklist_id: str | None = None, imported_from: str | None = None, author_email: str | None = None, - model_id: str | None = None) -> list[str]: + model_id: str | None = None) -> Sequence[str]: """ Create an annotation with the given geometry. @@ -883,3 +885,100 @@ def _create_geometry_annotation(self, ) return self.create(resource_id, annotation_dto) + + def download_file(self, + annotation: str | Annotation, + fpath_out: str | Path | None = None) -> bytes: + """ + Download the segmentation file for a given resource and annotation. + + Args: + annotation: The annotation unique id or an annotation object. + fpath_out: (Optional) The file path to save the downloaded segmentation file. + + Returns: + bytes: The content of the downloaded segmentation file in bytes format. + """ + if isinstance(annotation, Annotation): + annotation_id = annotation.id + resource_id = annotation.resource_id + else: + annotation_id = annotation + resource_id = self.get_by_id(annotation_id).resource_id + + resp = self._make_request('GET', f'/annotations/{resource_id}/annotations/{annotation_id}/file') + if fpath_out: + with open(str(fpath_out), 'wb') as f: + f.write(resp.content) + return resp.content + + async def _async_download_segmentation_file(self, + annotation: str | Annotation, + save_path: str | Path, + session: aiohttp.ClientSession | None = None, + progress_bar: tqdm | None = None): + """ + Asynchronously download a segmentation file. + + Args: + annotation (str | dict): The annotation unique id or an annotation object. + save_path (str | Path): The path to save the file. + session (aiohttp.ClientSession): The aiohttp session to use for the request. + progress_bar (tqdm | None): Optional progress bar to update after download completion. + """ + if isinstance(annotation, Annotation): + annotation_id = annotation.id + resource_id = annotation.resource_id + else: + annotation_id = annotation + resource_id = self.get_by_id(annotation_id).resource_id + + try: + async with self._make_request_async('GET', + f'/annotations/{resource_id}/annotations/{annotation_id}/file', + session=session) as resp: + data_bytes = await resp.read() + with open(save_path, 'wb') as f: + f.write(data_bytes) + if progress_bar: + progress_bar.update(1) + except ResourceNotFoundError as e: + e.set_params('annotation', {'annotation_id': annotation_id}) + raise e + + def download_multiple_files(self, + annotations: Sequence[str | Annotation], + save_paths: Sequence[str | Path] | str + ) -> None: + """ + Download multiple segmentation files and save them to the specified paths. + + Args: + annotations: A list of annotation unique ids or annotation objects. + save_paths: A list of paths to save the files or a directory path. + """ + import nest_asyncio + nest_asyncio.apply() + async def _download_all_async(): + async with aiohttp.ClientSession() as session: + tasks = [ + self._async_download_segmentation_file( + annotation, save_path=path, session=session, progress_bar=progress_bar) + for annotation, path in zip(annotations, save_paths) + ] + await asyncio.gather(*tasks) + + if isinstance(save_paths, str): + save_paths = [os.path.join(save_paths, self._entid(ann)) + for ann in annotations] + + with tqdm(total=len(annotations), desc="Downloading segmentations", unit="file") as progress_bar: + loop = asyncio.get_event_loop() + loop.run_until_complete(_download_all_async()) + + def bulk_download_file(self, + annotations: Sequence[str | Annotation], + save_paths: Sequence[str | Path] | str + ) -> None: + """Alias for :py:meth:`download_multiple_files`""" + return self.download_multiple_files(annotations, save_paths) diff --git a/datamint/api/endpoints/datasetsinfo_api.py b/datamint/api/endpoints/datasetsinfo_api.py new file mode 100644 index 00000000..0625fb0f --- /dev/null +++ b/datamint/api/endpoints/datasetsinfo_api.py @@ -0,0 +1,16 @@ +from ..entity_base_api import ApiConfig, EntityBaseApi +from datamint.entities.datasetinfo import DatasetInfo +import httpx + + +class DatasetsInfoApi(EntityBaseApi[DatasetInfo]): + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + """Initialize the datasets API handler. + + Args: + config: API configuration containing base URL, API key, etc. + client: Optional HTTP client instance. If None, a new one will be created. + """ + super().__init__(config, DatasetInfo, 'datasets', client) diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index 67011e7f..a6ee2495 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -19,7 +19,7 @@ def __init__(self, """ super().__init__(config, Project, 'projects', client) - def get_project_resources(self, project: Project | str) -> Sequence[Resource]: + def get_project_resources(self, project: Project | str) -> list[Resource]: """Get resources associated with a specific project. Args: diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 971d05ec..2a0b53fc 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -82,19 +82,18 @@ def get_list(self, limit: int | None = None ) -> Sequence[Resource]: """Get resources with optional filtering. + Args: - status (ResourceStatus): The resource status. Possible values: 'inbox', 'published', 'archived' or None. If None, it will return all resources. - from_date (date | str | None): The start date. - to_date (date | str | None): The end date. - tags (Optional[list[str]]): The tags to filter the resources. - modality (Optional[str]): The modality of the resources. - mimetype (Optional[str]): The mimetype of the resources. - # return_ids_only (bool): Whether to return only the ids of the resources. - order_field (Optional[ResourceFields]): The field to order the resources. See :data:`~.base_api_handler.ResourceFields`. - order_ascending (Optional[bool]): Whether to order the resources in ascending order. - project_name (str | list[str] | None): The project name or a list of project names to filter resources by project. + status: The resource status. Possible values: 'inbox', 'published', 'archived' or None. If None, it will return all resources. + from_date : The start date. + to_date: The end date. + tags: The tags to filter the resources. + modality: The modality of the resources. + mimetype: The mimetype of the resources. + order_field: The field to order the resources. See :data:`~ResourceFields`. + order_ascending: Whether to order the resources in ascending order. + project_name: The project name or a list of project names to filter resources by project. If multiple projects are provided, resources will be filtered to include only those belonging to ALL of the specified projects. - """ # Convert datetime objects to ISO format @@ -151,11 +150,14 @@ def get_annotations(self, resource: str | Resource) -> Sequence[Annotation]: return self.annotations_api.get_list(resource=resource) @staticmethod - def __process_files_parameter(file_path: Sequence[str | IO | pydicom.Dataset] + def __process_files_parameter(file_path: str | Sequence[str | IO | pydicom.Dataset] ) -> Sequence[str | IO]: """ Process the file_path parameter to ensure it is a list of file paths or IO objects. """ + if isinstance(file_path, str) and os.path.isdir(file_path): + return [f'{file_path}/{f}' for f in os.listdir(file_path) if os.path.isfile(f'{file_path}/{f}')] + processed_files = [] for item in file_path: if isinstance(item, pydicom.Dataset): @@ -164,7 +166,8 @@ def __process_files_parameter(file_path: Sequence[str | IO | pydicom.Dataset] processed_files.append(item) return processed_files - def _assemble_dicoms(self, files_path: Sequence[str | IO] + def _assemble_dicoms(self, files_path: Sequence[str | IO], + progress_bar: bool = False ) -> tuple[Sequence[str | IO], bool, Sequence[int]]: """ Assembles DICOM files into a single file. @@ -175,7 +178,7 @@ def _assemble_dicoms(self, files_path: Sequence[str | IO] Returns: A tuple containing: - The paths to the assembled DICOM files. - - A boolean indicating whether the assembly was successful. + - A boolean indicating if the assembly was necessary. - same length as the output assembled DICOMs, mapping assembled DICOM to original DICOMs. """ dicoms_files_path = [] @@ -194,7 +197,9 @@ def _assemble_dicoms(self, files_path: Sequence[str | IO] if orig_len == 0: _LOGGER.debug("No DICOM files found to assemble.") return files_path, False, [] - dicoms_files_path = dicom_utils.assemble_dicoms(dicoms_files_path, return_as_IO=True) + dicoms_files_path = dicom_utils.assemble_dicoms(dicoms_files_path, + return_as_IO=True, + progress_bar=progress_bar) new_len = len(dicoms_files_path) if new_len != orig_len: @@ -491,8 +496,8 @@ def upload_resources(self, if on_error not in ['raise', 'skip']: raise ValueError("on_error must be either 'raise' or 'skip'") - # Check if single resource provided and raise error - if isinstance(files_path, (str, IO)) or isinstance(files_path, pydicom.Dataset): + # Check if single resource provided and raise error (list of 1 item is allowed) + if isinstance(files_path, IO) or isinstance(files_path, pydicom.Dataset) or (isinstance(files_path, str) and not os.path.isdir(files_path)): raise ValueError( "upload_resources() only accepts multiple resources. For single resource upload, use upload_resource() instead.") @@ -525,7 +530,7 @@ def upload_resources(self, if metadata is not None and len(metadata) != len(files_path): raise ValueError("The number of metadata files must match the number of resources.") if assemble_dicoms: - files_path, assembled, mapping_idx = self._assemble_dicoms(files_path) + files_path, assembled, mapping_idx = self._assemble_dicoms(files_path, progress_bar=progress_bar) assemble_dicoms = assembled else: mapping_idx = [i for i in range(len(files_path))] @@ -695,6 +700,7 @@ def upload_resource(self, modality=modality, metadata=[metadata], discard_dicom_reports=discard_dicom_reports, + assemble_dicoms=False, # No need to assemble for single file progress_bar=False # Disable progress bar for single uploads ) @@ -789,6 +795,7 @@ def download_multiple_resources(self, resources: Sequence[str] | Sequence[Resource], save_path: Sequence[str] | str, add_extension: bool = False, + overwrite: bool = True ) -> list[str]: """ Download multiple resources and save them to the specified paths. @@ -823,6 +830,19 @@ async def _download_all_async(): if isinstance(save_path, str): save_path = [os.path.join(save_path, self._entid(r)) for r in resources] + if len(save_path) != len(resources): + raise ValueError("The number of save paths must match the number of resources.") + + if not overwrite: + new_resources = [] + new_save_path = [] + for i in range(len(resources)): + if not os.path.exists(save_path[i]): + new_resources.append(resources[i]) + new_save_path.append(save_path[i]) + resources = new_resources + save_path = new_save_path + with tqdm(total=len(resources), desc="Downloading resources", unit="file") as progress_bar: loop = asyncio.get_event_loop() final_save_paths = loop.run_until_complete(_download_all_async()) @@ -976,3 +996,16 @@ def publish_resources(self, _LOGGER.warning(f"Resource {resource} is not in inbox status. Skipping publishing") else: raise + + def set_tags(self, + resource: str | Resource, + tags: Sequence[str], + ): + data = {'tags': tags} + resource_id = self._entid(resource) + + response = self._make_entity_request('PUT', + resource_id, + add_path='tags', + json=data) + return response diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index 98ad5fd2..a1fb3dcb 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -262,8 +262,9 @@ async def _delete_async(self, Raises: httpx.HTTPStatusError: If deletion fails or entity not found """ - await self._make_entity_request_async('DELETE', entity, - session=session) + async with self._make_entity_request_async('DELETE', entity, + session=session) as resp: + await resp.text() # Consume response to complete request # def get_deleted(self, **kwargs) -> Sequence[T]: # pass diff --git a/datamint/dataset/base_dataset.py b/datamint/dataset/base_dataset.py index 671d96dc..f0985db7 100644 --- a/datamint/dataset/base_dataset.py +++ b/datamint/dataset/base_dataset.py @@ -13,14 +13,15 @@ from torch.utils.data import DataLoader import torch from torch import Tensor -from datamint.apihandler.base_api_handler import DatamintException +from datamint.exceptions import DatamintException from medimgkit.dicom_utils import is_dicom from medimgkit.readers import read_array_normalized from medimgkit.format_detection import guess_extension from datetime import datetime from pathlib import Path -from datamint.dataset.annotation import Annotation +from datamint.entities import Annotation, DatasetInfo import cv2 +from datamint.entities import Resource _LOGGER = logging.getLogger(__name__) @@ -174,23 +175,12 @@ def _initialize_config( def _setup_api_handler(self, server_url: Optional[str], api_key: Optional[str], auto_update: bool) -> None: """Setup API handler and validate connection.""" - from datamint.apihandler.api_handler import APIHandler - - self.api_handler = APIHandler( - root_url=server_url, + from datamint import Api + self.api = Api( + server_url=server_url, api_key=api_key, - check_connection=auto_update + check_connection=self.auto_update ) - self.server_url = self.api_handler.root_url - self.api_key = self.api_handler.api_key - - if self.api_key is None: - _LOGGER.warning( - "API key not provided. If you want to download data, please provide an API key, " - f"either by passing it as an argument, " - f"setting environment variable {configs.ENV_VARS[configs.APIKEY_KEY]} or " - "using datamint-config command line tool." - ) def _setup_directories(self, root: str | None) -> None: """Setup root and dataset directories.""" @@ -242,7 +232,7 @@ def _load_metadata(self) -> bool: if not os.path.isfile(metadata_path): # get the server info self.project_info = self.get_info() - self.metainfo = self._get_datasetinfo().copy() + self.metainfo = self._get_datasetinfo().asdict().copy() self.metainfo['updated_at'] = None self.metainfo['resources'] = [] self.metainfo['all_annotations'] = self.all_annotations @@ -526,18 +516,18 @@ def _check_integrity(self) -> None: if missing_files: raise DatamintDatasetException(f"Image files not found: {missing_files}") - def _get_datasetinfo(self) -> dict: + def _get_datasetinfo(self) -> DatasetInfo: """Get dataset information from API.""" if self._server_dataset_info is not None: return self._server_dataset_info - all_datasets = self.api_handler.get_datasets() + all_datasets = self.api._datasetsinfo.get_all() for dataset in all_datasets: - if dataset['id'] == self.dataset_id: + if dataset.id == self.dataset_id: self._server_dataset_info = dataset return dataset - available_datasets = [(d['name'], d['id']) for d in all_datasets] + available_datasets = [(d.name, d.id) for d in all_datasets] raise DatamintDatasetException( f"Dataset with id '{self.dataset_id}' not found. " f"Available datasets: {available_datasets}" @@ -547,7 +537,7 @@ def get_info(self) -> dict: """Get project information from API.""" if hasattr(self, 'project_info') and self.project_info is not None: return self.project_info - project = self.api_handler.get_project_by_name(self.project_name) + project = self.api.projects.get_by_name(self.project_name).asdict() if 'error' in project: available_projects = project['all_projects'] raise DatamintDatasetException( @@ -592,31 +582,10 @@ def __repr__(self) -> str: lines = [head] + [" " * 4 + line for line in body] return "\n".join(lines) - def download_project(self) -> None: - """Download project data from API.""" - - dataset_info = self._get_datasetinfo() - self.dataset_id = dataset_info['id'] - self.last_updaded_at = dataset_info['updated_at'] - - self.api_handler.download_project( - self.project_info['id'], - self.dataset_zippath, - all_annotations=self.all_annotations, - include_unannotated=self.include_unannotated - ) - - _LOGGER.debug("Downloaded dataset") - - if os.path.getsize(self.dataset_zippath) == 0: - raise DatamintDatasetException("Download failed.") - - self._extract_and_update_metadata() - def _get_dataset_id(self) -> str: if self.dataset_id is None: dataset_info = self._get_datasetinfo() - self.dataset_id = dataset_info['id'] + self.dataset_id = dataset_info.id return self.dataset_id def _extract_and_update_metadata(self) -> None: @@ -638,7 +607,7 @@ def _extract_and_update_metadata(self) -> None: # Save updated metadata with open(datasetjson_path, 'w') as file: - json.dump(self.metainfo, file, default=lambda o: o.to_dict() if hasattr(o, 'to_dict') else o) + json.dump(self.metainfo, file, default=lambda o: o.asdict() if hasattr(o, 'asdict') else o) self.images_metainfo = self.metainfo['resources'] # self._convert_metainfo_to_clsobj() @@ -646,19 +615,19 @@ def _extract_and_update_metadata(self) -> None: def _update_metadata_timestamps(self) -> None: """Update metadata with correct timestamps.""" if 'updated_at' not in self.metainfo: - self.metainfo['updated_at'] = self.last_updaded_at + self.metainfo['updated_at'] = self.last_updated_at else: try: local_time = datetime.fromisoformat(self.metainfo['updated_at']) - server_time = datetime.fromisoformat(self.last_updaded_at) + server_time = datetime.fromisoformat(self.last_updated_at) if local_time < server_time: _LOGGER.warning( f"Inconsistent updated_at dates detected " - f"({self.metainfo['updated_at']} < {self.last_updaded_at}). " - f"Fixing it to {self.last_updaded_at}" + f"({self.metainfo['updated_at']} < {self.last_updated_at}). " + f"Fixing it to {self.last_updated_at}" ) - self.metainfo['updated_at'] = self.last_updaded_at + self.metainfo['updated_at'] = self.last_updated_at except Exception as e: _LOGGER.warning(f"Failed to parse updated_at date: {e}") @@ -829,7 +798,7 @@ def _check_version(self) -> None: try: external_metadata_info = self._get_datasetinfo() - server_updated_at = external_metadata_info['updated_at'] + server_updated_at = external_metadata_info.updated_at except Exception as e: _LOGGER.warning(f"Failed to check for updates in {self.project_name}: {e}") return @@ -856,20 +825,21 @@ def _check_version(self) -> None: _LOGGER.info('Local version is up to date with the latest version.') def _fetch_new_resources(self, - all_uptodate_resources: list[dict]) -> list[dict]: + all_uptodate_resources: list[Resource]) -> list[dict]: local_resources = self.images_metainfo local_resources_ids = [res['id'] for res in local_resources] new_resources = [] for resource in all_uptodate_resources: + resource = resource.asdict() if resource['id'] not in local_resources_ids: resource['file'] = str(self._get_resource_file_path(resource)) resource['annotations'] = [] new_resources.append(resource) return new_resources - def _fetch_deleted_resources(self, all_uptodate_resources: list[dict]) -> list[dict]: + def _fetch_deleted_resources(self, all_uptodate_resources: list[Resource]) -> list[dict]: local_resources = self.images_metainfo - all_uptodate_resources_ids = [res['id'] for res in all_uptodate_resources] + all_uptodate_resources_ids = [res.id for res in all_uptodate_resources] deleted_resources = [] for resource in local_resources: try: @@ -888,7 +858,7 @@ def _incremental_update(self) -> None: # server_updated_at = external_metadata_info['updated_at'] ### RESOURCES ### - all_uptodate_resources = self.api_handler.get_project_resources(self.get_info()['id']) + all_uptodate_resources = self.api.projects.get_project_resources(self.get_info()['id']) new_resources = self._fetch_new_resources(all_uptodate_resources) deleted_resources = self._fetch_deleted_resources(all_uptodate_resources) @@ -898,9 +868,9 @@ def _incremental_update(self) -> None: new_resources_path = [Path(self.dataset_dir) / r['file'] for r in new_resources] new_resources_ids = [r['id'] for r in new_resources] _LOGGER.info(f"Downloading {len(new_resources)} new resources...") - new_res_paths = self.api_handler.download_multiple_resources(new_resources_ids, - save_path=new_resources_path, - add_extension=True) + new_res_paths = self.api.resources.download_multiple_resources(new_resources_ids, + save_path=new_resources_path, + add_extension=True) for new_rpath, r in zip(new_res_paths, new_resources): r['file'] = str(Path(new_rpath).relative_to(self.dataset_dir)) _LOGGER.info(f"Downloaded {len(new_resources)} new resources.") @@ -910,16 +880,17 @@ def _incremental_update(self) -> None: ################ ### ANNOTATIONS ### - all_annotations = self.api_handler.get_annotations(worklist_id=self.project_info['worklist_id'], - status='published' if self.all_annotations else None) + all_annotations = self.api.annotations.get_list(worklist_id=self.project_info['worklist_id'], + status='published' if self.all_annotations else None) + # group annotations by resource ID - annotations_by_resource = {} + annotations_by_resource: dict[str, list[Annotation]] = {} for ann in all_annotations: # add the local filepath filepath = self._get_annotation_file_path(ann) if filepath is not None: - ann['file'] = str(filepath) - resource_id = ann['resource_id'] + ann.file = str(filepath) + resource_id = ann.resource_id if resource_id not in annotations_by_resource: annotations_by_resource[resource_id] = [] annotations_by_resource[resource_id].append(ann) @@ -937,11 +908,11 @@ def _incremental_update(self) -> None: # check if segmentation annotations need to be downloaded # Also check if annotations need to be deleted old_ann_ids = set([ann.id for ann in old_resource_annotations if hasattr(ann, 'id')]) - new_ann_ids = set([ann['id'] for ann in new_resource_annotations]) + new_ann_ids = set([ann.id for ann in new_resource_annotations]) # Find annotations to add, update, or remove annotations_to_add = [ann for ann in new_resource_annotations - if ann['id'] not in old_ann_ids] + if ann.id not in old_ann_ids] annotations_to_remove = [ann for ann in old_resource_annotations if getattr(ann, 'id', 'NA') not in new_ann_ids] @@ -975,17 +946,17 @@ def _incremental_update(self) -> None: # Batch download all segmentation files if segmentations_to_download: _LOGGER.info(f"Downloading {len(segmentations_to_download)} segmentation files...") - self.api_handler.download_multiple_segmentations(segmentations_to_download, segmentation_paths) + self.api.annotations.download_multiple_files(segmentations_to_download, segmentation_paths) _LOGGER.info(f"Downloaded {len(segmentations_to_download)} segmentation files.") ################### # update metadata - self.metainfo['updated_at'] = self._get_datasetinfo()['updated_at'] + self.metainfo['updated_at'] = self._get_datasetinfo().updated_at self.metainfo['all_annotations'] = self.all_annotations # save updated metadata datasetjson_path = os.path.join(self.dataset_dir, 'dataset.json') with open(datasetjson_path, 'w') as file: - json.dump(self.metainfo, file, default=lambda o: o.to_dict() if hasattr(o, 'to_dict') else o) + json.dump(self.metainfo, file, default=lambda o: o.asdict() if hasattr(o, 'asdict') else o) def _get_resource_file_path(self, resource: dict) -> Path: """Get the local file path for a resource.""" diff --git a/datamint/dataset/dataset.py b/datamint/dataset/dataset.py index b580b1da..737c657b 100644 --- a/datamint/dataset/dataset.py +++ b/datamint/dataset/dataset.py @@ -7,7 +7,7 @@ import logging from PIL import Image import albumentations -from datamint.dataset.annotation import Annotation +from datamint.entities.annotation import Annotation _LOGGER = logging.getLogger(__name__) @@ -155,7 +155,7 @@ def _load_segmentations(self, annotations: list[Annotation], img_shape) -> tuple # FIXME: avoid enforcing resizing the mask seg = (Image.open(segfilepath) .convert('L') - .resize((w, h), Image.NEAREST) + .resize((w, h), Image.Resampling.NEAREST) ) seg = np.array(seg) diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py index 4aed132d..3162ff4b 100644 --- a/datamint/entities/__init__.py +++ b/datamint/entities/__init__.py @@ -6,6 +6,7 @@ from .project import Project from .resource import Resource from .user import User # new export +from .datasetinfo import DatasetInfo __all__ = [ 'Annotation', @@ -14,5 +15,6 @@ 'ChannelResourceData', 'Project', 'Resource', - "User" + "User", + 'DatasetInfo', ] diff --git a/datamint/entities/annotation.py b/datamint/entities/annotation.py index fec7337e..c5a69406 100644 --- a/datamint/entities/annotation.py +++ b/datamint/entities/annotation.py @@ -9,9 +9,18 @@ import logging from .base_entity import BaseEntity, MISSING_FIELD from pydantic import Field +from datetime import datetime logger = logging.getLogger(__name__) +# Map API field names to class attributes +_FIELD_MAPPING = { + 'type': 'annotation_type', + 'name': 'identifier', + 'added_by': 'created_by', + 'index': 'frame_index', +} + class Annotation(BaseEntity): """Pydantic Model representing a DataMint annotation. @@ -74,8 +83,96 @@ class Annotation(BaseEntity): annotation_worklist_name: str | None user_info: dict | None values: list | None = MISSING_FIELD + file: str | None = None # Add file field for segmentations + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'Annotation': + """Create an Annotation instance from a dictionary. + + Args: + data: Dictionary containing annotation data from API + + Returns: + Annotation instance + """ + # Convert field names and filter valid fields + converted_data = {} + for key, value in data.items(): + # Map field names if needed + mapped_key = _FIELD_MAPPING.get(key, key) + converted_data[mapped_key] = value + + if 'scope' not in converted_data: + converted_data['scope'] = 'image' if converted_data.get('frame_index') is None else 'frame' + + if converted_data['annotation_type'] in ['segmentation']: + if converted_data.get('file') is None: + raise ValueError(f"Segmentation annotations must have an associated file. {data}") + + # Create instance with only valid fields + valid_fields = {f for f in cls.model_fields.keys()} + filtered_data = {k: v for k, v in converted_data.items() if k in valid_fields} + + return cls(**filtered_data) @property def type(self) -> str: """Alias for :attr:`annotation_type`.""" - return self.annotation_type \ No newline at end of file + return self.annotation_type + + @property + def name(self) -> str: + """Get the annotation name (alias for identifier).""" + return self.identifier + + @property + def index(self) -> int | None: + """Get the frame index (alias for frame_index).""" + return self.frame_index + + @property + def value(self) -> str | None: + """Get the annotation value (for category annotations).""" + return self.text_value + + @property + def added_by(self) -> str: + """Get the creator email (alias for created_by).""" + return self.created_by + + def is_segmentation(self) -> bool: + """Check if this is a segmentation annotation.""" + return self.annotation_type == 'segmentation' + + def is_label(self) -> bool: + """Check if this is a label annotation.""" + return self.annotation_type == 'label' + + def is_category(self) -> bool: + """Check if this is a category annotation.""" + return self.annotation_type == 'category' + + def is_frame_scoped(self) -> bool: + """Check if this annotation is frame-scoped.""" + return self.scope == 'frame' + + def is_image_scoped(self) -> bool: + """Check if this annotation is image-scoped.""" + return self.scope == 'image' + + def get_created_datetime(self) -> datetime | None: + """ + Get the creation datetime as a datetime object. + + Returns: + datetime object or None if created_at is not set + """ + if isinstance(self.created_at, datetime): + return self.created_at + + if self.created_at: + try: + return datetime.fromisoformat(self.created_at.replace('Z', '+00:00')) + except ValueError: + logger.warning(f"Could not parse created_at datetime: {self.created_at}") + return None diff --git a/datamint/entities/datasetinfo.py b/datamint/entities/datasetinfo.py new file mode 100644 index 00000000..470574c2 --- /dev/null +++ b/datamint/entities/datasetinfo.py @@ -0,0 +1,22 @@ +"""Project entity module for DataMint API.""" + +from datetime import datetime +import logging +from .base_entity import BaseEntity, MISSING_FIELD + +logger = logging.getLogger(__name__) + + +class DatasetInfo(BaseEntity): + """Pydantic Model representing a DataMint dataset. + """ + + id: str + name: str + created_at: str # ISO timestamp string + created_by: str + description: str + customer_id: str + updated_at: str | None + total_resource: int + resource_ids: list[str] diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index 42a4b8d3..84eb53de 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -66,7 +66,7 @@ class Resource(BaseEntity): created_by: str published: bool deleted: bool - source_filepath: str + source_filepath: str | None metadata: dict projects: list[dict] = MISSING_FIELD published_on: str | None diff --git a/docs/source/client_api.rst b/docs/source/client_api.rst index 33ab691a..a0e71247 100644 --- a/docs/source/client_api.rst +++ b/docs/source/client_api.rst @@ -2,8 +2,8 @@ Client Python API ================= -This chapter describes how to use the |APIHandlerClass| class in Python, -in order to interact with the Datamint API. +This chapter describes how to use the |ApiClass| class in Python, +to interact with the Datamint API. Before continuing, you may want to check the :ref:`setup_api_key` section to easily set up your API key, if you haven't done so yet. .. toctree:: diff --git a/docs/source/client_api_content.rst b/docs/source/client_api_content.rst index eeeaca94..d4ba373e 100644 --- a/docs/source/client_api_content.rst +++ b/docs/source/client_api_content.rst @@ -1,174 +1,185 @@ -Upload DICOMs or other resources ----------------------------------- +Getting Started with the API Client +------------------------------------ -First, import the |APIHandlerClass| class and create an instance: ``api_handler = APIHandler(...)``. -This class is responsible for interacting with the Datamint server. - -Upload resource files -++++++++++++++++++++++++++++++++ - -Use the :py:meth:`upload_resources() ` method to upload any resource type, such as DICOMs, videos, and image files: +First, import the |ApiClass| class and create an instance: .. code-block:: python - # Upload a single file - resource_id = api_handler.upload_resources("/path/to/dicom.dcm") + from datamint import Api + api = Api() # Uses API key from environment or config - # Upload multiple files at once - resoures_ids = api_handler.upload_resources(["/path/to/dicom.dcm", - "/path/to/video.mp4"] - ) +The |ApiClass| class provides access to different endpoint handlers: -You can see the list of all uploaded resources by calling the :py:meth:`get_resources() ` method: +- ``api.resources`` - For uploading, downloading, and managing resources +- ``api.annotations`` - For creating and managing annotations/segmentations +- ``api.projects`` - For creating and managing projects +- ``api.channels`` - For organizing resources into channels +- ``api.users`` - For user management operations -.. code-block:: python +Working with Resources +---------------------- - resources = api_handler.get_resources(status='inbox') # status can be any of {'inbox', 'published', 'archived'} - for res in resources: - print(res) - # Alternatively, you can use apihandler.get_resources_by_ids(resources_ids) - -Group up resources using channels -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Upload resource files +++++++++++++++++++++++++++++++++ -For a better organization of resources, you can group them into channels: +Use :py:meth:`api.resources.upload_resource() ` to upload any resource type, such as DICOMs, videos, and image files: .. code-block:: python - # Uploads a resource and creates a new channel named 'CT scans': - resource_id = api_handler.upload_resources("/path/to/dicom.dcm", - channel='CT scans' - ) + # Upload a single file + resource_id = api.resources.upload_resource("/path/to/dicom.dcm") - # This uploads a new resource to the same channel: - resource_id = api_handler.upload_resources("/path/to/dicom2.dcm", - channel='CT scans' - ) - - # Get all resources from channel 'CT scans': - resources = api_handler.get_resources(channel='CT scans') - + # Upload multiple files at once + resource_ids = api.resources.upload_resources(["/path/to/dicom.dcm", + "/path/to/video.mp4"]) -Upload, anonymize and add a label -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +List and filter resources +++++++++++++++++++++++++++++++++ -To anonymize and add labels to a DICOM file, use the parameters `anonymize` -and `labels` of :py:meth:`upload_resources() `. -Adding labels is useful for searching and filtering resources in the Datamint platform later. +You can see the list of all uploaded resources by calling :py:meth:`api.resources.get_list() `: .. code-block:: python - dicom_id = api_handler.upload_resources(files_path='/path/to/dicom.dcm', - anonymize=True, - labels=['label1', 'label2'] - ) - - + # Get resources with different filters + resources = api.resources.get_list(status='inbox') # status: 'inbox', 'published', 'archived' + resources = api.resources.get_list(mimetype='application/dicom') # filter by mimetype + resources = api.resources.get_list(channel='CT scans') # filter by channel + + for resource in resources: + print(f"Resource {resource.id}: {resource.filename}") -Changing the uploaded filename +Upload with options ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -By default, the filename that is uploaded is the basename of the file. -For instance, if you upload a file named 'path/to/dicom.dcm', the filename will be 'dicom.dcm'. -To include the path into the filename, use the `mung_filename` parameter: +You can customize the upload with various parameters: .. code-block:: python - # filename='dicom.dcm' (DEFAULT) - resource_ids = api_handler.upload_resources(files_path='path/to/dicom.dcm', - mung_filename=None, - ) - - # filename='path_to_dicom.dcm' - resource_ids = api_handler.upload_resources(files_path='path/to/dicom.dcm', - mung_filename='all', - ) - - # filename='to_dicom.dcm' - resource_ids = api_handler.upload_resources(files_path='path/to/dicom.dcm', - mung_filename=1, - ) + # Upload with channel organization + resource_id = api.resources.upload_resource("/path/to/dicom.dcm", + channel='CT scans') + # Upload with anonymization and labels + resource_id = api.resources.upload_resource("/path/to/dicom.dcm", + anonymize=True, + tags=['label1', 'label2']) + # Upload and publish directly to a project + resource_id = api.resources.upload_resource("/path/to/dicom.dcm", + publish=True, + publish_to='ProjectName') Download resources ------------------ -To download a resource, use the :py:meth:`~datamint.apihandler.api_handler.APIHandler.download_resource_file` method: +To download a resource, use :py:meth:`api.resources.download_resource_file() `: .. code-block:: python - resources = api_handler.get_resources(status='inbox', mimetype='application/dicom') - resource_id = resources[0]['id'] + # Get a resource + resources = api.resources.get_list(status='inbox', mimetype='application/dicom') + resource = resources[0] - # returns the resource content in bytes: - bytes_obj = api_handler.download_resource_file(resource_id, auto_convert=False) + # Download as bytes + bytes_obj = api.resources.download_resource_file(resource.id, auto_convert=False) - # Assuming this resource is a dicom file, it will return a pydicom.dataset.Dataset object. - dicom_obj = api_handler.download_resource_file(resource_id, auto_convert=True) + # Auto-convert to appropriate object (e.g., pydicom.Dataset for DICOM files) + dicom_obj = api.resources.download_resource_file(resource.id, auto_convert=True) - # saves the file in the specified path. - api_handler.download_resource_file(resource_id, save_path='path/to/dicomfile.dcm') - -With ``auto_convert=True``, the function uses the resource mimetype to automatically convert to a proper object type (`pydicom.dataset.Dataset`, in this case.) -If you do not want this, but the bytes itself, use the ``auto_convert=False``. + # Save directly to file + api.resources.download_resource_file(resource.id, save_path='path/to/dicomfile.dcm') +With ``auto_convert=True``, the function uses the resource mimetype to automatically convert to the appropriate object type (``pydicom.Dataset`` for DICOM, etc.). Publishing resources --------------------- -To publish a resource, use :py:meth:`~datamint.apihandler.api_handler.APIHandler.publish_resources`: +To publish a resource, use :py:meth:`api.resources.publish_resources() `: .. code-block:: python - resources = api_handler.get_resources(status='inbox') - resource_id = resources[0]['id'] # assuming there is at least one resource in the inbox + resources = api.resources.get_list(status='inbox') + resource = resources[0] # assuming there is at least one resource in the inbox # Change status from 'inbox' to 'published' - api_handler.publish_resources(resource_id) + api.resources.publish_resources(resource.id) + + # Publish to a specific project + api.resources.publish_resources(resource.id, project_name='ProjectName') -To publish to a project, pass the project name or id as an argument: +Working with Annotations +------------------------ + +Upload segmentations +++++++++++++++++++++++++++++++++ + +To upload a segmentation, use :py:meth:`api.annotations.upload_segmentations() `: .. code-block:: python + + # Upload a resource first (or use an existing resource_id) + resource_id = api.resources.upload_resources("/path/to/dicom.dcm") + + # Upload segmentation + api.annotations.upload_segmentations(resource_id, + 'path/to/segmentation.nii.gz', # NIfTI or PNG file + name='SegmentationName') - api_handler.publish_resources(resource_id, project_name='ProjectName') +Multi-class segmentations +++++++++++++++++++++++++++++++++ -You can also publish resources while uploading them: +If your segmentation has multiple classes, you can pass a dictionary mapping pixel values to class names: .. code-block:: python - resource_id = api_handler.upload_resources(files_path='/path/to/video_data.mp4', - publish=True, - # publish_to='ProjectName' # optional - ) + class_names = { + # Background (0) is automatic, don't specify it + 1: "tumor", + 2: "metal", + } + + api.annotations.upload_segmentations(resource_id, + 'path/to/segmentation.nii.gz', + name=class_names) -Upload segmentation -------------------- +Working with Projects +--------------------- -To upload a segmentation, use :py:meth:`upload_segmentations() `: +Create and manage projects +++++++++++++++++++++++++++++++++ .. code-block:: python + + # Create a new project + project_id = api.projects.create( + name='My Project', + description='Project description', + resources_ids=[resource_id1, resource_id2] # optional + ) + + # Get project details + project = api.projects.get_by_id(project_id) + + # List all projects + projects = api.projects.get_list() - resource_id = api_handler.upload_resources("/path/to/dicom1.dcm") # or use an existing resource_id - api_handler.upload_segmentations(resource_id, - 'path/to/segmentation.nii.gz', # Can be a nifti file or an png file - name='SegmentationName') + # Get resources in a project + project_resources = api.projects.get_project_resources(project_id) +Use :py:meth:`api.projects.create() ` to create projects, ``api.projects.get_by_id()`` to retrieve them, and :py:meth:`api.projects.get_project_resources() ` to get associated resources. -If your segmentation has multiple classes, you can pass a dictionary mapping pixel values to class names. -Let's say you have a segmentation with 2 classes, where pixel value 0 is background, 1 is 'tumor', and 2 is 'metal': +Working with Channels +--------------------- -.. code-block:: python +Organize resources with channels +++++++++++++++++++++++++++++++++ - class_names = { - # Do not specify the background class, it is always 0 - 1: "tumor", - 2: "metal", - } +.. code-block:: python - api_handler.upload_segmentations(resource_id, - 'path/to/segmentation.nii.gz', # Can be a nifti file or an png file - name=class_names - ) + # List all channels + channels = api.channels.get_list() + + # Create a new channel + channel_id = api.channels.create(name='CT Scans', description='CT scan images') -See also the tutorial notebook on uploading data: `upload_data.ipynb `_ +See also the tutorial notebooks: `upload_data.ipynb `_ diff --git a/docs/source/conf.py b/docs/source/conf.py index 10df391e..15871d27 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -20,11 +20,17 @@ nitpick_ignore = { ("py:class", "pydicom.dataset.Dataset"), ("py:class", "PIL.Image.Image"), + ("py:class", "Image.Image"), ("py:class", "np.ndarray"), ("py:class", "numpy.ndarray"), ("py:class", "torch.nn.Module"), ("py:class", "cv2.VideoCapture"), ("py:class", "nibabel.filebasedimages.FileBasedImage"), + ("py:class", "pydantic.main.BaseModel"), + ("py:class", "httpx.HTTPStatusError"), + ("py:class", "httpx.Response"), + ("py:class", "httpx.Client"), + ("py:class", "aiohttp.client_exceptions.ClientResponseError"), } # -- General configuration --------------------------------------------------- @@ -42,9 +48,8 @@ ] rst_prolog = """ -.. |ExperimentClass| replace:: :py:class:`~datamint.experiment.experiment.Experiment` .. |DatamintDatasetClass| replace:: :py:class:`~datamint.dataset.dataset.DatamintDataset` -.. |APIHandlerClass| replace:: :py:class:`~datamint.apihandler.api_handler.APIHandler` +.. |ApiClass| replace:: :py:class:`~datamint.api.client.Api` """ napoleon_google_docstring = True @@ -84,3 +89,10 @@ ] html_favicon = "favicon.png" + +# Ensure all modules are discoverable +autodoc_mock_imports = [] + +# Add type hints support +autodoc_typehints = 'description' +autodoc_typehints_description_target = 'documented' diff --git a/docs/source/datamint.api.base_classes.rst b/docs/source/datamint.api.base_classes.rst new file mode 100644 index 00000000..febd72de --- /dev/null +++ b/docs/source/datamint.api.base_classes.rst @@ -0,0 +1,20 @@ +Base API Classes +================ + +This section covers the foundational classes that provide common functionality for all API endpoints. + +Base API +-------- + +.. automodule:: datamint.api.base_api + :members: + :undoc-members: + :show-inheritance: + +Entity Base API +--------------- + +.. automodule:: datamint.api.entity_base_api + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.api.client.rst b/docs/source/datamint.api.client.rst new file mode 100644 index 00000000..33d01598 --- /dev/null +++ b/docs/source/datamint.api.client.rst @@ -0,0 +1,10 @@ +Main API Module +=============== + +This is the main API module that provides the core functionality for interacting with the DataMint API. + + +.. automodule:: datamint.api.client + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.api.dto.rst b/docs/source/datamint.api.dto.rst new file mode 100644 index 00000000..17e06260 --- /dev/null +++ b/docs/source/datamint.api.dto.rst @@ -0,0 +1,7 @@ +datamint.api.dto +============ + +.. automodule:: datamint.api.dto + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/datamint.api.endpoints.rst b/docs/source/datamint.api.endpoints.rst new file mode 100644 index 00000000..69928177 --- /dev/null +++ b/docs/source/datamint.api.endpoints.rst @@ -0,0 +1,44 @@ +API Endpoints +============= + +This section documents all available API endpoints for interacting with DataMint resources. + +Projects API +------------ + +.. automodule:: datamint.api.endpoints.projects_api + :members: + :undoc-members: + :show-inheritance: + +Resources API +------------- + +.. automodule:: datamint.api.endpoints.resources_api + :members: + :undoc-members: + :show-inheritance: + +Annotations API +--------------- + +.. automodule:: datamint.api.endpoints.annotations_api + :members: + :undoc-members: + :show-inheritance: + +Channels API +------------ + +.. automodule:: datamint.api.endpoints.channels_api + :members: + :undoc-members: + :show-inheritance: + +Users API +--------- + +.. automodule:: datamint.api.endpoints.users_api + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.apihandler.rst b/docs/source/datamint.apihandler.rst index 8a3e636a..fcb69c11 100644 --- a/docs/source/datamint.apihandler.rst +++ b/docs/source/datamint.apihandler.rst @@ -1,11 +1,12 @@ -datamint.APIHandler -=================== +Client API +========== -.. automodule:: datamint.apihandler.api_handler - :members: - :inherited-members: +.. toctree:: + :maxdepth: 2 + :caption: API Documentation -.. automodule:: datamint.apihandler.base_api_handler - :members: - :exclude-members: BaseAPIHandler, validate_call - \ No newline at end of file + datamint.api.client + datamint.api.endpoints + datamint.api.base_classes + datamint.exceptions + datamint.api.dto diff --git a/docs/source/datamint.entities.rst b/docs/source/datamint.entities.rst new file mode 100644 index 00000000..44962c4f --- /dev/null +++ b/docs/source/datamint.entities.rst @@ -0,0 +1,11 @@ +Entities +======== + +The ``datamint.entities`` module provides the core data structures that represent +various objects within the DataMint ecosystem. These entities are built using `Pydantic `_ models, ensuring robust data validation, +type safety, and seamless serialization/deserialization when interacting with the DataMint API. + +.. automodule:: datamint.entities + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/datamint.exceptions.rst b/docs/source/datamint.exceptions.rst new file mode 100644 index 00000000..56c319a5 --- /dev/null +++ b/docs/source/datamint.exceptions.rst @@ -0,0 +1,7 @@ +datamint.exceptions +=================== + +.. automodule:: datamint.exceptions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/datamint.experiment.rst b/docs/source/datamint.experiment.rst deleted file mode 100644 index 0eec6212..00000000 --- a/docs/source/datamint.experiment.rst +++ /dev/null @@ -1,5 +0,0 @@ -datamint.Experiment -=================== - -.. automodule:: datamint.experiment.experiment - :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 845e9ebb..a709e1ba 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,7 +17,7 @@ Datamint -------- - `Homepage `_ -- `Datamint Platform `_ +- `Datamint Platform `_ - `Github `_ .. toctree:: @@ -28,7 +28,6 @@ Datamint setup_api_key command_line_tools client_api - running_experiments pytorch_integration @@ -38,7 +37,7 @@ Datamint datamint.apihandler datamint.dataset - datamint.experiment + datamint.entities Indices and tables diff --git a/docs/source/running_experiments.rst b/docs/source/running_experiments.rst deleted file mode 100644 index aaf1d460..00000000 --- a/docs/source/running_experiments.rst +++ /dev/null @@ -1,179 +0,0 @@ - -Running Experiments -=================== - -The :py:class:`~datamint.experiment.experiment.Experiment` class allows you to log your experiments to the server. -It contains mechanisms to automatically log the model, the dataset, the hyperparameters, -and the results of your experiments without any extra effort. -Here is an example on how to use it: - -.. code-block:: python - - from datamint import Experiment - - # Create an instance of the Experiment class - exp = Experiment(name="Experiment", - project_name='Project Name' - ) - train_dataset = exp.get_dataset('train') - test_dataset = exp.get_dataset('test') - - # Train/Test your model here - # (...) - exp.finish() - -The above code will automatically collect and log all these information to the server: - -.. list-table:: Experiment Logging - :header-rows: 1 - - * - Automatically Logged - - Method to Log Manually - - Frequency (when automatically logged) - * - Model - - :py:meth:`~datamint.experiment.experiment.Experiment.log_model` - - Once, at :py:meth:`~datamint.experiment.experiment.Experiment.finish` - * - Dataset - - :py:meth:`~datamint.experiment.experiment.Experiment.log_dataset_stats` - - When :py:meth:`~datamint.experiment.experiment.Experiment.get_dataset` is called - * - Hyperparameters - - :py:meth:`~datamint.experiment.experiment.Experiment.log_model` - - Once, at :py:meth:`~datamint.experiment.experiment.Experiment.finish` - * - Metrics - - :py:meth:`~datamint.experiment.experiment.Experiment.log_metrics` and :py:meth:`~datamint.experiment.experiment.Experiment.log_metric` - - Per epoch and per dataloader - * - Predictions - - :py:meth:`~datamint.experiment.experiment.Experiment.log_predictions` - - Per evaluation and per dataloader - -Check an full functional example at `experiment_traintest_classifier.py `_ - - -Manual logging --------------- -For complex experiments, you may need to log manually log additional information that is not automatically collected by the |ExperimentClass|. -You can do that by using the |ExperimentClass| methods. -To disable automatic logging, set ``auto_log=False`` when creating the |ExperimentClass| object. - -Manual Summary logging -++++++++++++++++++++++ -Here is a complete example that manually logs everything required by the summary UI tab: - -.. _experiment_example_code_1: -.. code-block:: python - - from datamint import Experiment - import numpy as np - - # Create an experiment object - exp = Experiment(name='My first experiment', - project_name='testproject', - allow_existing=True, - auto_log=False) - - # Logs metrics - exp.log_metric('test/Sensitivity', 0.7) # a.k.a. Recall - exp.log_metric('test/Positive Predictive Value', 0.8) # a.k.a. Precision - exp.log_metric('test/F1Score', 0.75) - exp.log_metric('test/Accuracy', 0.8) - - # Logs predictions. - predictions_conf = np.array([[0.5, 0.2], [0.1, 0.4]]) - resource_ids = exp.get_dataset('test').get_resources_ids()[:2] - label_names = ['fracture', 'tumor'] # or `exp.get_dataset('test').labels_set` - exp.log_classification_predictions(predictions_conf, - label_names=label_names, - resource_ids=resource_ids, - dataset_split='test') - exp.finish() - -The effects of the above code can be seen in the image below: - -.. image:: ../images/experiment_mapping_code_UI.png - :alt: Experiment Mapping Code UI - :align: center - :name: experiment_mapping_code_UI - -Manual Model details logging -+++++++++++++++++++++++++++++ - -There are multiple detailed info that can be logged during the training of a model: - -- **Metrics along epochs/steps** such as loss, accuracy and sensitivity. For this, use :py:meth:`~datamint.experiment.experiment.Experiment.log_metric` with ``epoch=i`` and ``name="train/{METRIC_NAME}"`` or ``name="val/{METRIC_NAME}"``. -- **Predictions:** The model's predictions on the validation/test set. Useful to build curves such as ROC and Precision-Recall. For this, use :py:meth:`~datamint.experiment.experiment.Experiment.log_classification_predictions`. -- **Hyperparameters:** the hyperparameters used to train the model. For this, use the ``hyper_params`` parameter of :py:meth:`~datamint.experiment.experiment.Experiment.log_model`. Some hyperparameters are automatically logged by default, such as the number of layers, number of parameters, and the model attributes. -- **Environment:** The environment used to train the model. This is automatically collected by default. Disable it by ``log_enviroment=False``` when creating the |ExperimentClass| object. -- **Model:** The model itself. For this, use :py:meth:`~datamint.experiment.experiment.Experiment.log_model`. - -Here is an example of how to log the metrics along epochs during the training of a model: - -.. code-block:: python - - # Logs metrics at epoch 0 - cur_epoch = 0 - exp.log_metric('train/Sensitivity', 0.5, epoch=cur_epoch) - exp.log_metric('train/loss', 0.9, epoch=cur_epoch) - exp.log_metric('val/Sensitivity', 0.4, epoch=cur_epoch) - exp.log_metric('val/loss', 1.1, epoch=cur_epoch) - # (...) - - # Logs metrics at epoch 1 - cur_epoch = 1 - exp.log_metric('train/Sensitivity', 0.55, epoch=cur_epoch) - exp.log_metric('train/loss', 0.8, epoch=cur_epoch) - exp.log_metric('val/Sensitivity', 0.45, epoch=cur_epoch) - exp.log_metric('val/loss', 1.0, epoch=cur_epoch) - # (...) - -To log you model, you can use the following code: - -.. code-block:: python - - # definition of a custom model - class MyModel(nn.Module): - def __init__(self, hidden_size=32): - super().__init__() - self.hidden_size = hidden_size - self.fc = nn.Sequential( - nn.Linear(64, self.hidden_size), - nn.ReLU(), - nn.Linear(self.hidden_size, 1) - ) - - def forward(self, x): - return self.fc(x) - - model = MyModel(hidden_size=32) - hyper_params = {'learning_rate': 0.001, 'batch_size': 32} - exp.log_model(model, hyper_params=hyper_params) # `hidden_size`` will be automatically logged - # use `log_model_attributes=False` to avoid logging the model attributes - -It is possible to pass the file path of the model to be logged as well: - -.. code-block:: python - - # (...) - exp.log_model('model.pth') - -To log predictions at a given step/epoch, you can use the same :ref:`example code ` from Section `Manual Summary logging`_, -but with the ``epoch`` or ``step`` parameter set to the desired value: - -.. code-block:: python - - # (...) - exp.log_classification_predictions(predictions_conf, - label_names=label_names, - resource_ids=resource_ids, - dataset_split='test', - epoch=0) - - - - -Best Practices --------------- -- When logging metrics, use '/' to separate different levels of metrics. For example, 'train/loss' and 'test/loss'. -- Use :py:meth:`~datamint.experiment.experiment.Experiment.get_dataset` to get the dataset object, instead of directly using |DatamintDatasetClass|. This ensures that all relevant metadata and configurations are correctly applied and that the dataset stats are logged (when ``auto_log=True``). -- Regularly log metrics and other relevant information to keep track of the experiment's progress. Don't forget to provide epoch/step when possible. -- Use meaningful names for your experiments, datasets, to make it easier to identify and compare different runs. -- Use ``dry_run=True`` parameter of |ExperimentClass| for testing/debugging purposes. It will not log anything to the server. \ No newline at end of file diff --git a/docs/source/setup_api_key.rst b/docs/source/setup_api_key.rst index 2a57297f..37e33a52 100644 --- a/docs/source/setup_api_key.rst +++ b/docs/source/setup_api_key.rst @@ -25,12 +25,12 @@ Specify the API key as an environment variable. import os os.environ["DATAMINT_API_KEY"] = "my_api_key" -Method 3: APIHandler constructor ---------------------------------- -Specify API key in the |APIHandlerClass| constructor: +Method 3: Api constructor +------------------------- +Specify API key in the |ApiClass| constructor: .. code-block:: python - from datamint import APIHandler + from datamint import Api - api = APIHandler(api_key='my_api_key') \ No newline at end of file + api = Api(api_key='my_api_key') \ No newline at end of file diff --git a/notebooks/upload_data.ipynb b/notebooks/upload_data.ipynb index ac83d9c5..74ea1af4 100644 --- a/notebooks/upload_data.ipynb +++ b/notebooks/upload_data.ipynb @@ -51,7 +51,6 @@ "source": [ "from datamint import Api\n", "from pathlib import Path\n", - "_debug('datamint')\n", "# Creates a connection with the server.\n", "# Don't forget to run `datamint-config` in a terminal, if you haven't already.\n", "# Or use api_key parameter in Api()\n", From a8c4e352a7348cd5ed85691226c8c1aa2cb66c18 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 18 Sep 2025 17:00:03 -0300 Subject: [PATCH 13/13] Refactors APIs and improves type handling Enhances API implementations by refining type annotations, improving parameter type handling, and addressing potential edge cases. Removes unused or commented-out code for better code clarity. Adds new methods to extend functionality, such as resource status updates and project retrieval with archived inclusion. Optimizes error handling for consistency and implements minor adjustments to payload processing. Improves dataset handling by supporting additional file types and ensuring compatibility with various formats. --- datamint/api/endpoints/annotations_api.py | 44 ++++----- datamint/api/endpoints/projects_api.py | 45 +++++++++- datamint/api/endpoints/resources_api.py | 4 +- datamint/api/entity_base_api.py | 13 +-- datamint/dataset/base_dataset.py | 31 +++++-- datamint/entities/project.py | 6 ++ datamint/examples/example_projects.py | 85 +++++++++--------- notebooks/dataset_loading.ipynb | 105 ++++++++++++---------- pyproject.toml | 2 +- 9 files changed, 203 insertions(+), 132 deletions(-) diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 7097c685..8c1ea4f8 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -5,6 +5,7 @@ from ..entity_base_api import ApiConfig, CreatableEntityApi, DeletableEntityApi from datamint.entities.annotation import Annotation from datamint.entities.resource import Resource +from datamint.entities.project import Project from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry import numpy as np import os @@ -38,17 +39,6 @@ def __init__(self, config: ApiConfig, client: httpx.Client | None = None) -> Non """ super().__init__(config, Annotation, 'annotations', client) - # def create(self, annotation_data: dict[str, Any]) -> str: - # """Create a new annotation. - - # Args: - # annotation_data: Dictionary payload for the annotation. - - # Returns: - # The id of the created annotation. - # """ - # return self._create(annotation_data) - def get_list(self, resource: str | Resource | None = None, annotation_type: AnnotationType | str | None = None, @@ -75,11 +65,11 @@ def get_list(self, # remove nones payload = {k: v for k, v in payload.items() if v is not None} - return super().get_list(limit=limit, **payload) + return super().get_list(limit=limit, params=payload) async def _upload_segmentations_async(self, resource: str | Resource, - frame_index: int | None, + frame_index: int | Sequence [int] | None, file_path: str | np.ndarray, name: dict[int, str] | dict[tuple, str], imported_from: str | None = None, @@ -140,8 +130,12 @@ async def _upload_segmentations_async(self, frames_indices = list(range(nframes)) elif isinstance(frame_index, int): frames_indices = [frame_index] + elif isinstance(frame_index, Sequence): + if len(frame_index) != nframes: + raise ValueError("Length of frame_index does not match number of frames in segmentation.") + frames_indices = list(frame_index) else: - raise ValueError("frame_index must be an int or None") + raise ValueError("frame_index must be a list of integers or None.") annotids = [] for fidx, f in zip(frames_indices, fios): @@ -421,7 +415,7 @@ def upload_segmentations(self, - dict[tuple[int, int, int], str]: Mapping RGB tuples to names for RGB segmentations Use 'default' as a key for a unnamed classes. Example: {(255, 0, 0): 'Red_Region', (0, 255, 0): 'Green_Region'} - frame_index: The frame index of the segmentation. + frame_index: The frame index of the segmentation. If a list, it must have the same length as the number of frames in the segmentation. If None, it is assumed that the segmentations are in sequential order starting from 0. This parameter is ignored for NIfTI files as they are treated as volume segmentations. @@ -484,17 +478,21 @@ def upload_segmentations(self, # All other file types are converted to multiple PNGs and uploaded frame by frame standardized_name = self.standardize_segmentation_names(name) + _LOGGER.debug(f"Standardized segmentation names: {standardized_name}") # Handle frame_index parameter if isinstance(frame_index, list): if len(set(frame_index)) != len(frame_index): raise ValueError("frame_index list contains duplicate values.") + if isinstance(frame_index, Sequence) and len(frame_index) == 1: + frame_index = frame_index[0] + nest_asyncio.apply() loop = asyncio.get_event_loop() task = self._upload_segmentations_async( resource=resource, - frame_index=frame_index[0] if isinstance(frame_index, list) and len(frame_index) == 1 else None, + frame_index=frame_index, file_path=file_path, name=standardized_name, imported_from=imported_from, @@ -508,7 +506,8 @@ def upload_segmentations(self, return loop.run_until_complete(task) @staticmethod - def standardize_segmentation_names(name: str | dict[int, str] | dict[tuple, str] | None) -> dict[int, str] | dict[tuple, str]: + def standardize_segmentation_names(name: str | dict | None + ) -> dict: """ Standardize segmentation names to a consistent format. @@ -519,9 +518,9 @@ def standardize_segmentation_names(name: str | dict[int, str] | dict[tuple, str] Standardized name dictionary. """ if name is None: - return {0: 'default'} # Return a dict with integer key for compatibility + return {'default': 'default'} # Return a dict with integer key for compatibility elif isinstance(name, str): - return {0: name} # Use integer key for single string names + return {'default': name} # Use integer key for single string names elif isinstance(name, dict): # Return the dict as-is since it's already in the correct format return name @@ -782,7 +781,7 @@ def add_line_annotation(self, Add a line annotation to a resource. Args: - point1: The first point of the line. Can be a 2d or 3d point. + point1: The first point of the line. Can be a 2d or 3d point. If `coords_system` is 'pixel', it must be a 2d point and it represents the pixel coordinates of the image. If `coords_system` is 'patient', it must be a 3d point and it represents the patient coordinates of the image, relative to the DICOM metadata. @@ -791,9 +790,9 @@ def add_line_annotation(self, resource_id: The resource unique id. identifier: The annotation identifier, also as known as the annotation's label. frame_index: The frame index of the annotation. - dicom_metadata: The DICOM metadata of the image. If provided, the coordinates will be converted to the + dicom_metadata: The DICOM metadata of the image. If provided, the coordinates will be converted to the correct coordinates automatically using the DICOM metadata. - coords_system: The coordinate system of the points. Can be 'pixel', or 'patient'. + coords_system: The coordinate system of the points. Can be 'pixel', or 'patient'. If 'pixel', the points are in pixel coordinates. If 'patient', the points are in patient coordinates (see DICOM patient coordinates). project: The project unique id or name. worklist_id: The annotation worklist unique id. Optional. @@ -959,6 +958,7 @@ def download_multiple_files(self, """ import nest_asyncio nest_asyncio.apply() + async def _download_all_async(): async with aiohttp.ClientSession() as session: tasks = [ diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index a6ee2495..be1983ed 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import Sequence, Literal from ..entity_base_api import ApiConfig, CRUDEntityApi from datamint.entities.project import Project from datamint.entities.resource import Resource @@ -68,16 +68,33 @@ def create(self, return self._create(project_data) - def get_by_name(self, name: str) -> Project | None: + def get_all(self, limit: int | None = None) -> Sequence[Project]: + """Get all projects. + + Args: + limit: The maximum number of projects to return. If None, return all projects. + + Returns: + A list of project instances. + """ + return self.get_list(limit=limit, params={'includeArchived': True}) + + def get_by_name(self, + name: str, + include_archived: bool = True) -> Project | None: """Get a project by its name. Args: name (str): The name of the project. + include_archived (bool): Whether to include archived projects in the search. Returns: The project instance if found, otherwise None. """ - projects = self.get_all() + if include_archived: + projects = self.get_list(params={'includeArchived': True}) + else: + projects = self.get_all() for project in projects: if project.name == name: return project @@ -162,3 +179,25 @@ def download(self, project: str | Project, for data in response.iter_bytes(1024): progress_bar.update(len(data)) file.write(data) + + def set_work_status(self, + resource: str | Resource, + project: str | Project, + status: Literal['opened', 'annotated', 'closed']) -> None: + """ + Set the status of a resource. + + Args: + annotation: The annotation unique id or an annotation object. + status: The new status to set. + """ + resource_id = self._entid(resource) + proj_id = self._entid(project) + + jsondata = { + 'status': status + } + self._make_entity_request('POST', + entity_id=proj_id, + add_path=f'resources/{resource_id}/status', + json=jsondata) diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 2a0b53fc..ec5c7339 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -121,6 +121,8 @@ def get_list(self, "channel_name": channel, "filename": filename, } + # remove nones from payload + payload = {k: v for k, v in payload.items() if v is not None} if project_name is not None: if isinstance(project_name, str): project_name = [project_name] @@ -136,7 +138,7 @@ def get_list(self, } payload['tags'] = json.dumps(tags_filter) - return super().get_list(limit=limit, **payload) + return super().get_list(limit=limit, params=payload) def get_annotations(self, resource: str | Resource) -> Sequence[Annotation]: """Get annotations for a specific resource. diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index a1fb3dcb..50b702f7 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -91,7 +91,8 @@ def _stream_entity_request(self, raise ResourceNotFoundError(self.endpoint_base, {'id': entity_id}) from e raise - def get_list(self, limit: int | None = None, **kwargs) -> Sequence[T]: + def get_list(self, limit: int | None = None, + **kwargs) -> Sequence[T]: """Get entities with optional filtering. Returns: @@ -100,17 +101,17 @@ def get_list(self, limit: int | None = None, **kwargs) -> Sequence[T]: Raises: httpx.HTTPStatusError: If the request fails. """ - params = dict(kwargs) + new_kwargs = dict(kwargs) # Remove None values from the payload. - for k in list(params.keys()): - if params[k] is None: - del params[k] + for k in list(new_kwargs.keys()): + if new_kwargs[k] is None: + del new_kwargs[k] items_gen = self._make_request_with_pagination('GET', f'/{self.endpoint_base}', return_field=self.endpoint_base, limit=limit, - params=params) + **new_kwargs) all_items = [] for resp, items in items_gen: diff --git a/datamint/dataset/base_dataset.py b/datamint/dataset/base_dataset.py index f0985db7..9e998c12 100644 --- a/datamint/dataset/base_dataset.py +++ b/datamint/dataset/base_dataset.py @@ -16,7 +16,8 @@ from datamint.exceptions import DatamintException from medimgkit.dicom_utils import is_dicom from medimgkit.readers import read_array_normalized -from medimgkit.format_detection import guess_extension +from medimgkit.format_detection import guess_extension, guess_typez +from medimgkit.nifti_utils import NIFTI_MIMES, get_nifti_shape from datetime import datetime from pathlib import Path from datamint.entities import Annotation, DatasetInfo @@ -402,19 +403,33 @@ def get_annotations( @staticmethod def read_number_of_frames(filepath: str) -> int: """Read the number of frames in a file.""" - if is_dicom(filepath): + + mimetypes, ext = guess_typez(filepath) + mimetype = mimetypes[0] + if mimetype is None: + raise ValueError(f"Could not determine MIME type for file: {filepath}") + + if mimetype == 'application/dicom': ds = pydicom.dcmread(filepath) return getattr(ds, 'NumberOfFrames', 1) - elif filepath.lower().endswith(('.mp4', '.avi')): + elif mimetype.startswith('video/'): cap = cv2.VideoCapture(filepath) try: return int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) finally: cap.release() - elif filepath.lower().endswith(('.png', '.jpg', '.jpeg')): + elif mimetype in ('image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/tiff'): return 1 + elif mimetype in NIFTI_MIMES: + shape = get_nifti_shape(filepath) + if len(shape) == 3: + return shape[-1] + elif len(shape) > 3: + return shape[3] + else: + return 1 else: - raise ValueError(f"Unsupported file type: {filepath}") + raise ValueError(f"Unsupported file type '{mimetype}' for file {filepath}") def get_resources_ids(self) -> list[str]: """Get list of resource IDs.""" @@ -659,6 +674,9 @@ def _process_image_array(self, img: np.ndarray) -> Tensor: img = (img - min_val) / (img.max() - min_val) * 255 img = img.astype(np.uint8) + if not img.flags.writeable: + img = img.copy() + img_tensor = torch.from_numpy(img).contiguous() if isinstance(img_tensor, torch.ByteTensor): @@ -941,7 +959,8 @@ def _incremental_update(self) -> None: _LOGGER.error(f"Error deleting annotation file {filepath}: {e}") # Update resource annotations list - convert to Annotation objects - resource['annotations'] = [Annotation.from_dict(ann) for ann in new_resource_annotations] + # resource['annotations'] = [Annotation.from_dict(ann) for ann in new_resource_annotations] + resource['annotations'] = new_resource_annotations # Batch download all segmentation files if segmentations_to_download: diff --git a/datamint/entities/project.py b/datamint/entities/project.py index 38e9b3a7..cc2cff6a 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -56,3 +56,9 @@ class Project(BaseEntity): is_active_learning: bool = MISSING_FIELD two_up_display: bool = MISSING_FIELD require_review: bool = MISSING_FIELD + + @property + def url(self) -> str: + """Get the URL to access this project in the DataMint web application.""" + base_url = "https://app.datamint.io/projects/edit" + return f"{base_url}/{self.id}" diff --git a/datamint/examples/example_projects.py b/datamint/examples/example_projects.py index 81ffb7bb..6109b7eb 100644 --- a/datamint/examples/example_projects.py +++ b/datamint/examples/example_projects.py @@ -1,75 +1,72 @@ import requests import io -from datamint import APIHandler +from datamint import Api import logging from PIL import Image import numpy as np +from datamint.entities import Project, Resource +from pydicom.data import get_testdata_file _LOGGER = logging.getLogger(__name__) -def _download_pydicom_test_file(filename: str) -> io.BytesIO: - """Download a pydicom test file from GitHub and return its content as a BytesIO object.""" - url = f'https://raw.githubusercontent.com/pydicom/pydicom/master/tests/data/{filename}' - response = requests.get(url) - response.raise_for_status() - content = io.BytesIO(response.content) - content.name = filename - return content - - class ProjectMR: @staticmethod - def upload_resource_emri_small(api: APIHandler = None) -> str: + def upload_resource_emri_small(api: Api | None = None) -> Resource: if api is None: - api = APIHandler() + api = Api() - searched_res = api.get_resources(status='published', tags=['example'], filename='emri_small.dcm') + searched_res = api.resources.get_list(status='published', + tags=['example'], + filename='emri_small.dcm') for res in searched_res: _LOGGER.info('Resource already exists.') - return res['id'] + return res - dcm_content = _download_pydicom_test_file('emri_small.dcm') + dcm_path = get_testdata_file("emri_small.dcm", + read=False) - _LOGGER.info(f'Uploading resource {dcm_content.name}...') - return api.upload_resources(dcm_content, - anonymize=True, - publish=True, - tags=['example']) + _LOGGER.info('Uploading resource emri_small.dcm...') + resid = api.resources.upload_resource(dcm_path, + anonymize=False, + publish=True, + tags=['example']) + return api.resources.get_by_id(resid) @staticmethod - def _upload_annotations(api: APIHandler, - resid: str, - proj) -> None: + def _upload_annotations(api: Api, + res: Resource, + proj: Project) -> None: _LOGGER.info('Uploading annotations...') - proj_id = proj['id'] - proj_info = api.get_project_by_id(proj_id) segurl = 'https://github.com/user-attachments/assets/8c5d7dfe-1b5a-497d-b76e-fe790f09bb90' resp = requests.get(segurl, stream=True) resp.raise_for_status() img = Image.open(io.BytesIO(resp.content)).convert('L') - api.upload_segmentations(resid, np.array(img), - name='object1', frame_index=1, - worklist_id=proj_info['worklist_id']) - api.set_annotation_status(project_id=proj_id, - resource_id=resid, - status='closed') + api.annotations.upload_segmentations(res, np.array(img), + name='object1', frame_index=1, + worklist_id=proj.worklist_id) + api.projects.set_work_status(resource=res, + project=proj, + status='closed') @staticmethod def create(project_name: str = 'Example Project MR', - with_annotations=True) -> str: - api = APIHandler() + with_annotations=True) -> Project: + api = Api() + + res = ProjectMR.upload_resource_emri_small(api) + proj = api.projects.get_by_name(name=project_name) + if proj: + _LOGGER.warning(f'Project {project_name} already exists. Returning it without modifications...') + return proj - resid = ProjectMR.upload_resource_emri_small(api) - proj = api.get_project_by_name(project_name) - if 'id' in proj: - msg = f'Project {project_name} already exists. Delete it first or choose another name.' - raise ValueError(msg) _LOGGER.info(f'Creating project {project_name}...') - proj = api.create_project(name=project_name, - description='This is an example project', - resources_ids=[resid]) + projid = api.projects.create(name=project_name, + description='This is an example project', + resources_ids=[res.id]) + proj = api.projects.get_by_id(projid) + if with_annotations: - ProjectMR._upload_annotations(api, resid, proj) + ProjectMR._upload_annotations(api, res, proj) - return proj['id'] + return proj diff --git a/notebooks/dataset_loading.ipynb b/notebooks/dataset_loading.ipynb index ac7af28e..5ccbe771 100644 --- a/notebooks/dataset_loading.ipynb +++ b/notebooks/dataset_loading.ipynb @@ -77,26 +77,15 @@ "cell_type": "code", "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'a8bab606-f750-4656-94f9-e446c5443b7c'" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from datamint.examples import ProjectMR\n", "\n", - "PROJECT_NAME = 'Example Project MR4'\n", + "PROJECT_NAME = 'Example Project MR'\n", "\n", - "proj_id = ProjectMR.create(project_name=PROJECT_NAME,\n", - " with_annotations=True)\n", - "print(f\"Check your project at https://app.datamint.io/projects/edit/{proj_id}\")" + "proj = ProjectMR.create(project_name=PROJECT_NAME,\n", + " with_annotations=True)\n", + "print(f\"Check your project at {proj.url}\")" ] }, { @@ -117,29 +106,7 @@ "cell_type": "code", "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "e8b0cf829e344fcd89d83f2ee44064d1", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "0.00B [00:00, ?B/s]" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dataset length: 1\n" - ] - } - ], + "outputs": [], "source": [ "from datamint import Dataset\n", "\n", @@ -169,6 +136,19 @@ "execution_count": 4, "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "
[09/18/25 16:57:44] INFO     Original image is uint16, converting to uint8                      base_dataset.py:668\n",
+       "
\n" + ], + "text/plain": [ + "\u001b[2;36m[09/18/25 16:57:44]\u001b[0m\u001b[2;36m \u001b[0m\u001b[34mINFO \u001b[0m Original image is uint16, converting to uint8 \u001b]8;id=502300;file:///home/lhsmello/projects/Sonance/datamint-python-api/datamint/dataset/base_dataset.py\u001b\\\u001b[2mbase_dataset.py\u001b[0m\u001b]8;;\u001b\\\u001b[2m:\u001b[0m\u001b]8;id=369607;file:///home/lhsmello/projects/Sonance/datamint-python-api/datamint/dataset/base_dataset.py#668\u001b\\\u001b[2m668\u001b[0m\u001b]8;;\u001b\\\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", @@ -189,6 +169,33 @@ "print('Image.shape:', img.shape)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "you can get more advanced information from the 'metainfo' attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "dict_keys(['id', 'resource_uri', 'storage', 'location', 'upload_channel', 'filename', 'modality', 'mimetype', 'size', 'upload_mechanism', 'customer_id', 'status', 'created_at', 'created_by', 'published', 'deleted', 'source_filepath', 'metadata', 'projects', 'published_on', 'published_by', 'tags', 'publish_transforms', 'deleted_at', 'deleted_by', 'instance_uid', 'series_uid', 'study_uid', 'patient_id', 'segmentations', 'measurements', 'categories', 'user_info', 'labels', 'file'])" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "item['metainfo'].keys() # dictionary with metadata about the resource" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -205,22 +212,22 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -244,7 +251,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -253,7 +260,7 @@ "{'lucas@mail.com': torch.Size([10, 2, 64, 64])}" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -288,12 +295,12 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAaAAAAGzCAYAAABpdMNsAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAIPdJREFUeJzt3Q1wVNX5x/EnmBeBkBeCJFATxIoGRCiiQgpqhdQMdSwIYx2rlVpHCkbkxU5tpgW0VUNhFMUR8KUFR0VqOoMYp4g0QKgaEFArAo0BU0kNSdRxN4AkoeT855x/s8OGBJOQ8Oze/X5mDsnee7O5Z/fm/jj3PntvlDHGCAAAZ1m3s/0LAQAggAAAahgBAQBUEEAAABUEEABABQEEAFBBAAEAVBBAAAAVBBAAQAUBBLTBqlWrJCoqSnbu3Pmty/7gBz9wDcDpEUBAmFm2bJkLxJY88sgj8uMf/1hSU1NdYD744INnff2Atopu85IA2uStt97q8gDq06eP/PznPz9l3u9+9ztJS0uTESNGyIYNG7p0PYAzRQABnSw2NlbtNS0vL5cLLrhAvvzySznvvPPU1gNoCw7BASLywQcfyIQJEyQhIUHi4+Nl/Pjxsm3btlNem2+++UZ++ctfSkpKilv2jjvukK+//vpbzwHV19fLggUL5KKLLpK4uDhJT0+XX//61256cy+99JJcddVV0qNHD0lOTpZrrrkmMKqy4bJnzx4pLi52h9hsO/l32flAuGAEhIhnd+hXX321CxQbCjExMfLMM8+4Hbvd0Y8aNSrwGt17772SlJTkzq2UlpbK8uXL5bPPPpMtW7a4MGhJY2OjOy/z9ttvy7Rp02Tw4MGye/duWbJkiXzyySfy2muvBZZ96KGH3HN///vfl9///vduNLV9+3bZtGmTXH/99fLEE0/IzJkzXUj+9re/dT9jz/cAYcneDwiIZJMmTTKxsbHmwIEDgWmVlZWmV69e5pprrnGPV65cae+bZUaOHGkaGhoCyy1atMhNX7duXWDatdde61qTF1980XTr1s384x//CPq9K1ascD/7zjvvuMdlZWVuuZtuusmcOHEiaNnGxsbA95deemnQ87fkiy++cM+9YMGCDrwiwNnBIThEtBMnTrjDW5MmTZILL7wwML1fv37y05/+1I1aamtrA9PtCMaOkJrMmDFDoqOj5W9/+1urv6OgoMCNejIzM925maY2btw4N3/z5s3uqx0J2dHS/PnzpVu34D/N1kZXQDjjEBwi2hdffOHO61xyySWnzLOhYQOhoqIiMG3QoEFBy9hDYTas/v3vf7f6O8rKymTfvn2tFgXU1NS4rwcOHHDBM2TIkDPoERA+CCCgi9kQu+yyy+Txxx9vcb4tSAAiEQGEiGZHJbbazBYUNPevf/3LjUhsQOzYsSMwmrnuuusCyxw5ckQOHTokP/rRj1r9Hd/97nfln//8p6usO92hNLucDau9e/fK9773vVaX43AcvIJzQIho55xzjqsuW7duXdBhtOrqalm9erWMHTvWVcc1efbZZ+X48eOBx7YK7r///a8r4W7NT37yE/n888/lueeeO2XesWPH5OjRo+57ex7KBp6tfrNBdDJjbE3B/+vZs6f4fL4z6DUQGhgBIeI9/PDDsnHjRhc299xzjysqsGXY9jM6ixYtCnp9Ghoa3EjGhoodNdmrEtifs2XWrfnZz34mr776qkyfPt0VHIwZM8YVP9gRlp1ur1hwxRVXuM8I2dLqP/zhD64sfPLkye4zQ3b01b9/f8nPz3fPN3LkSBd8dr3tz/Tt2zdQ0PDiiy+6snB7XsvaunWrW65pPQYMGBDx7zdCyFmqtgNC2vvvv29ycnJMfHy86dGjh7nuuuvMu+++G5jfVIZdXFxspk2bZpKTk92yt912m/nqq6+Cnqt5GbZlS7f/+Mc/uhLquLg49/O2pPuhhx4yfr8/aNk///nPZsSIEYHl7HNt3LgxML+qqsrccMMNrkzcrtPJv8t+b6e11DZv3twFrxzQcVH2H+0QBLzEjl7syOXvf/+79qoAIY1zQEAns0UJ9mKhAE6PAAI6ybvvviu/+tWv3Od57HkiAKfHITigk9x5552yfv16ufXWW2Xx4sWumAFA6wggAIAKDsEBAFQQQAAAFV12kPrpp592x8Grqqpk+PDh8tRTT7mbbH0b+wnwyspK6dWrF5ccAYAwZD/dc/jwYfcB6uZXdm++YKdbs2aNu7+K/UDdnj17zN13322SkpJMdXX1t/5sRUVFqx+ko/EasA2wDbANSNi8BnZ/fjpdEkBXXXWVyc3NDTy2N9fq37+/yc/P/9af9fl86i8ajdeAbYBtgG1Azvg1sPvz0+n0c0D2Wlm7du2S7OzswDQ7BLOPS0pKTlneXm/L3vCrqdlhGwAg/H3blds7PYDsnR7thRab36fePrbng5qzF1hMTEwMNO6NAgCRQb0KLi8vT/x+f6CdfPdJAIB3dXoVnL0Glr3Hir2fysns47S0tFOWtxdttA0AEFk6fQQUGxvr7ldSVFQUVFptH2dlZXX2rwMAhKku+RzQ3LlzZerUqe4mW/azP0888YS766O9VhYAAF0WQLfccot88cUXMn/+fFd4YO9v/+abb55SmAAAiFwhdzFSW4ptq+EAAOHNFpYlJCSEbhUcACAyEUAAABUEEABABQEEAFBBAAEAVBBAAAAVBBAAQAUBBABQQQABAFQQQAAAFQQQAEAFAQQAUEEAAQBUEEAAABUEEABABQEEAFBBAAEACCAAQORgBAQAUEEAAQBUEEAAABUEEABABQEEAFBBAAEAVBBAAAAVBBAAQAUBBABQQQABAFQQQAAAFQQQAEAFAQQAUEEAAQBUEEAAABUEEABABQEEAFBBAAEAvpUxps3N7/d/+xMSQAAALYyAAAAqCCAAgAoCCACgggACAKiI1vm1AICzxVamhSJGQAAAFQQQAEAFAQQAUEEAAQDCI4C2bt0qN954o/Tv31+ioqLktddeO+Vk1/z586Vfv37SvXt3yc7OlrKyss5cZwBAJAbQ0aNHZfjw4fL000+3OH/RokWydOlSWbFihWzfvl169uwpOTk5UldX1xnrCwARxbTjGmyttZBlzoD98bVr1wYeNzY2mrS0NLN48eLANJ/PZ+Li4swrr7zSpuf0+/3ueWm8BmwDbANsA2LCUdN+3H49nU49B1ReXi5VVVXusFuTxMREGTVqlJSUlLT4M/X19VJbWxvUAADe16kBZMPHSk1NDZpuHzfNay4/P9+FVFNLT0/vzFUCAIQo9Sq4vLw8d++IplZRUaG9SgCAcAugtLQ097W6ujpoun3cNK+5uLg4SUhICGoAAO/r1AAaOHCgC5qioqLANHtOx1bDZWVldeavAoCw5JkKNo2LkR45ckT2798fVHjw4YcfSu/evSUjI0Nmz54tDz/8sAwaNMgF0rx589xnhiZNmtQZ6wsA8Ir2ltdt3ry5xdLIqVOnBkqx582bZ1JTU1359fjx401paWm7y/dovAZsA2wDXtwGIoG/jWXYUfYfCSH2kJ2thgMALwqxXW6X7sdtYdnpzuurV8EBACITN6QDgDMUCaOarsAICACgggACAKgggAAAKgggAIAKAggAoIIqOADAt7J3wO5sjIAAACoIIACACgIIAKCCAAIAqCCAAAAqqIIDgAi9tltUF1S2tQcjIACACgIIAKCCAAIAqCCAAAAqCCAAgAqq4ABELK9Vu0UpV7W1FyMgAIAKAggAoIIAAgCoIIAAACooQgDgKV4rLPBKwUFLGAEBAFQQQAAAFQQQAEAFAQQAUEEAAQBUUAUHICx5rdotygNVbe3FCAgAoIIAAgCoIIAAACoIIACACgIIAKCCKjgAIS1cq90isaqtvRgBAQBUEEAAABUEEABABQEEAFBBAAEAVFAFByAkhGu1m0XFW8cwAgIAqCCAAAAqCCAAgAoCCAAQ+gGUn58vV155pfTq1Uv69u0rkyZNktLS0qBl6urqJDc3V1JSUiQ+Pl6mTJki1dXVnb3eAIBICqDi4mIXLtu2bZONGzfK8ePH5frrr5ejR48GlpkzZ44UFhZKQUGBW76yslImT57cFesOIAwq29rawqXaraWGDjJnoKamxm41pri42D32+XwmJibGFBQUBJbZt2+fW6akpKRNz+n3+93yNF4DtoHw3wa8Rvv1lDBrdn9+Omd0Dsjv97uvvXv3dl937drlRkXZ2dmBZTIzMyUjI0NKSkpafI76+nqpra0NagAA7+twADU2Nsrs2bNlzJgxMnToUDetqqpKYmNjJSkpKWjZ1NRUN6+180qJiYmBlp6e3tFVAgBEQgDZc0Eff/yxrFmz5oxWIC8vz42kmlpFRcUZPR8AwMOX4rn33nvljTfekK1bt8r5558fmJ6WliYNDQ3i8/mCRkG2Cs7Oa0lcXJxrAMJXuBQRNEcBQRiNgOxGZsNn7dq1smnTJhk4cGDQ/JEjR0pMTIwUFRUFptky7YMHD0pWVlbnrTUAILJGQPaw2+rVq2XdunXus0BN53XsuZvu3bu7r3fddZfMnTvXFSYkJCTIzJkzXfiMHj26q/oAAAhHnVGCuHLlysAyx44dM/fcc49JTk42PXr0MDfddJM5dOhQm38HZdj6pZM0XoP2bgPhim1dVMuwo/73JoQMW4ZtR1IAwkeI7UbajHNAXcsWltkjYa3hWnAAABXckA6A50c6CE2MgAAAKgggAIAKAggAoIIAAgCoIIAAACqoggPg+Wo3Pu8TmhgBAQBUEEAAABUEEABABQEEAFBBAAEAVFAFB0S4cK14o7It/DECAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAgAoCCACgggACAKgggAAAKrgUDxAhwvWSO/AuRkAAABUEEABABQEEAFBBAAEAVBBAAAAVBBAAQAUBBABQQQABAFQQQAAAFQQQAEAFAQQAUMG14IAIERUV1eJ0rhEHLYyAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIABA6AfQ8uXLZdiwYZKQkOBaVlaWrF+/PjC/rq5OcnNzJSUlReLj42XKlClSXV3dFesNAIikADr//PNl4cKFsmvXLtm5c6eMGzdOJk6cKHv27HHz58yZI4WFhVJQUCDFxcVSWVkpkydP7qp1BwCEM3OGkpOTzfPPP298Pp+JiYkxBQUFgXn79u0z9leUlJS0+fn8fr/7GRqvAdvA2dkGwhXbh4T8a2D356fT4XNAJ06ckDVr1sjRo0fdoTg7Kjp+/LhkZ2cHlsnMzJSMjAwpKSlp9Xnq6+ultrY2qAEAvK/dAbR79253ficuLk6mT58ua9eulSFDhkhVVZXExsZKUlJS0PKpqaluXmvy8/MlMTEx0NLT0zvWEwCAtwPokksukQ8//FC2b98uM2bMkKlTp8revXs7vAJ5eXni9/sDraKiosPPBQDw8A3p7Cjnoosuct+PHDlSduzYIU8++aTccsst0tDQID6fL2gUZKvg0tLSWn0+O5KyDUDo3KiOm9QhLD4H1NjY6M7j2DCKiYmRoqKiwLzS0lI5ePCgO0cEAECHR0D2cNmECRNcYcHhw4dl9erVsmXLFtmwYYM7f3PXXXfJ3LlzpXfv3u5zQjNnznThM3r06Pb8GgBABGhXANXU1Mgdd9whhw4dcoFjP5Rqw+eHP/yhm79kyRLp1q2b+wCqHRXl5OTIsmXLumrdAQBhLOp/9fQhw5Zh23ADoCfEdgttPneF0GILy+zRsNZwLTgAQHhUwQHwvtZGF6E0MmptXRgZhQ9GQAAAFQQQAEAFAQQAUEEAAQBUEEAAABVUwQHwVHUcwgcjIACACgIIAKCCAAIAqCCAAAAqCCAAgAqq4AB4qjquPb+T68bpYgQEAFBBAAEAVBBAAAAVBBAAQAVFCAAiojgBoYcREABABQEEAFBBAAEAVBBAAAAVBBAAQAVVcAAiVmvVeFyi5+xgBAQAUEEAAQBUEEAAABUEEABABQEEAFBBFRyAs66lKrNQuj4c1XFnByMgAIAKAggAoIIAAgCoIIAAACoIIACACqrgAISEcLh7KtVxnYsREABABQEEAFBBAAEAVBBAAAAVFCEACGnhXJzQGaJa6b8XMAICAKgggAAAKgggAIAKAggAoIIAAgCEXwAtXLjQVWjMnj07MK2urk5yc3MlJSVF4uPjZcqUKVJdXd0Z6woAAXbf01JDBATQjh075JlnnpFhw4YFTZ8zZ44UFhZKQUGBFBcXS2VlpUyePLkz1hUA4CWmAw4fPmwGDRpkNm7caK699loza9YsN93n85mYmBhTUFAQWHbfvn22QN6UlJS06bn9fr9bnsZrwDbANtCRbcBrJIy3A7s/P50OjYDsIbYbbrhBsrOzg6bv2rVLjh8/HjQ9MzNTMjIypKSkpMXnqq+vl9ra2qAGAPC+dl8JYc2aNfL++++7Q3DNVVVVSWxsrCQlJQVNT01NdfNakp+fLw899FB7VwMAEObaNQKqqKiQWbNmycsvvyznnntup6xAXl6e+P3+QLO/AwDgfe0KIHuIraamRi6//HKJjo52zRYaLF261H1vRzoNDQ3i8/mCfs5WwaWlpbX4nHFxcZKQkBDUAKCjqIzz6CG48ePHy+7du4Om3Xnnne48zwMPPCDp6ekSExMjRUVFrvzaKi0tlYMHD0pWVlbnrjkAIHICqFevXjJ06NCgaT179nSf+Wmaftddd8ncuXOld+/ebjQzc+ZMFz6jR4/u3DUHAIS1Tr8dw5IlS6Rbt25uBGQr3HJycmTZsmWd/WsAAGEu6n915iHDlmEnJiZqrwYADwmx3Vy7RIXx1R1sYdnpzutzLTgAgAruiArA89o7iuiMEVM4j1zOFkZAAAAVBBAAQAUBBABQQQABAFQQQAAAFVTBAUAzVLCdHYyAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAQOgH0IMPPihRUVFBLTMzMzC/rq5OcnNzJSUlReLj42XKlClSXV3dFesNAIi0EdCll14qhw4dCrS33347MG/OnDlSWFgoBQUFUlxcLJWVlTJ58uTOXmcAgAdEt/sHoqMlLS3tlOl+v1/+9Kc/yerVq2XcuHFu2sqVK2Xw4MGybds2GT16dOesMQAgMkdAZWVl0r9/f7nwwgvltttuk4MHD7rpu3btkuPHj0t2dnZgWXt4LiMjQ0pKSlp9vvr6eqmtrQ1qAADva1cAjRo1SlatWiVvvvmmLF++XMrLy+Xqq6+Ww4cPS1VVlcTGxkpSUlLQz6Smprp5rcnPz5fExMRAS09P73hvAADePAQ3YcKEwPfDhg1zgTRgwAB59dVXpXv37h1agby8PJk7d27gsR0BEUIA4H1nVIZtRzsXX3yx7N+/350XamhoEJ/PF7SMrYJr6ZxRk7i4OElISAhqAADvO6MAOnLkiBw4cED69esnI0eOlJiYGCkqKgrMLy0tdeeIsrKyOmNdAQBeYtrh/vvvN1u2bDHl5eXmnXfeMdnZ2aZPnz6mpqbGzZ8+fbrJyMgwmzZtMjt37jRZWVmutYff7zd2tWi8BmwDbANsAxLWr4Hdn59Ou84B/ec//5Fbb71VvvrqKznvvPNk7NixrsTafm8tWbJEunXr5j6AaqvbcnJyZNmyZV2VnQCAMBZlU0hCiC1CsNVwAIDwZj8ferrz+lwLDgCgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAgAoCCACgggACAKgggAAAKgggAIAKAggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAADhEUCff/653H777ZKSkiLdu3eXyy67THbu3BmYb4yR+fPnS79+/dz87OxsKSsr6+z1BgBEUgB9/fXXMmbMGImJiZH169fL3r175bHHHpPk5OTAMosWLZKlS5fKihUrZPv27dKzZ0/JycmRurq6rlh/AEC4Mu3wwAMPmLFjx7Y6v7Gx0aSlpZnFixcHpvl8PhMXF2deeeWVNv0Ov99v7GrReA3YBtgG2AYkrF8Duz8/nXaNgF5//XW54oor5Oabb5a+ffvKiBEj5LnnngvMLy8vl6qqKnfYrUliYqKMGjVKSkpKWnzO+vp6qa2tDWoAAO9rVwB9+umnsnz5chk0aJBs2LBBZsyYIffdd5+88MILbr4NHys1NTXo5+zjpnnN5efnu5Bqaunp6R3vDQDAmwHU2Ngol19+uTz66KNu9DNt2jS5++673fmejsrLyxO/3x9oFRUVHX4uAIBHA8hWtg0ZMiRo2uDBg+XgwYPu+7S0NPe1uro6aBn7uGlec3FxcZKQkBDUAADe164AshVwpaWlQdM++eQTGTBggPt+4MCBLmiKiooC8+05HVsNl5WV1VnrDADwgnYUwZn33nvPREdHm0ceecSUlZWZl19+2fTo0cO89NJLgWUWLlxokpKSzLp168xHH31kJk6caAYOHGiOHTtGFVwIVKXQeA3YBtgGJESq4NoVQFZhYaEZOnSoK63OzMw0zz777Cml2PPmzTOpqalumfHjx5vS0tI2Pz9l2PxxsINkG2AbkIgIoCiXQiHEHrKz1XAAgPBmC8tOd16fa8EBAFQQQAAAFQQQAEAFAQQAUEEAAQBUEEAAABUEEABABQEEAFBBAAEAVBBAAAAVBBAAQAUBBABQEXIBFGLXRgUAdNH+POQC6PDhw9qrAAA4C/vzkLsdQ2Njo1RWVkqvXr3cyqenp0tFRYWnb9Vtb0FBP72B99JbeD87xsaK3X/3799funVrfZwTLSHGruz555/vvo+KinJfbfh4OYCa0E/v4L30Ft7P9mvLfd1C7hAcACAyEEAAABUhHUBxcXGyYMEC99XL6Kd38F56C+9n1wq5IgQAQGQI6REQAMC7CCAAgAoCCACgggACAKgggAAAKkI6gJ5++mm54IIL5Nxzz5VRo0bJe++9p71KZ2Tr1q1y4403ustT2Ks8vPbaa0HzbUHi/PnzpV+/ftK9e3fJzs6WsrIyCSf5+fly5ZVXuksp9e3bVyZNmiSlpaVBy9TV1Ulubq6kpKRIfHy8TJkyRaqrqyWcLF++XIYNGxb4hHxWVpasX7/eU31sbuHChW67nT17tqf6+eCDD7p+ndwyMzM91ccmn3/+udx+++2uL3Yfc9lll8nOnTvV9kEhG0B/+ctfZO7cue5zQO+//74MHz5ccnJypKamRsLV0aNHXT9ssLZk0aJFsnTpUlmxYoVs375devbs6fps/wDCRXFxsftj3bZtm2zcuFGOHz8u119/vet7kzlz5khhYaEUFBS45e21/yZPnizhxF4uyu6Qd+3a5f6Ax40bJxMnTpQ9e/Z4po8n27FjhzzzzDMudE/mlX5eeumlcujQoUB7++23PdfHr7/+WsaMGSMxMTHuP0t79+6Vxx57TJKTk/X2QSZEXXXVVSY3Nzfw+MSJE6Z///4mPz/feIF96deuXRt43NjYaNLS0szixYsD03w+n4mLizOvvPKKCVc1NTWur8XFxYE+xcTEmIKCgsAy+/btc8uUlJSYcJacnGyef/55z/Xx8OHDZtCgQWbjxo3m2muvNbNmzXLTvdLPBQsWmOHDh7c4zyt9tB544AEzduxY0xqNfVBIjoAaGhrc/yzt8O/ki5TaxyUlJeJF5eXlUlVVFdRnezE/e+gxnPvs9/vd1969e7uv9n21o6KT+2kPd2RkZIRtP0+cOCFr1qxxozx7KM5rfbQj2htuuCGoP5aX+mkPM9lD4xdeeKHcdtttcvDgQc/18fXXX5crrrhCbr75Znd4fMSIEfLcc8+p7oNCMoC+/PJL90edmpoaNN0+ti+QFzX1y0t9trfWsOcL7LB/6NChbprtS2xsrCQlJYV9P3fv3u3OCdjLtUyfPl3Wrl0rQ4YM8VQfbbDaQ+D23F5zXumn3cGuWrVK3nzzTXduz+6Ir776anc7Aa/00fr0009d/wYNGiQbNmyQGTNmyH333ScvvPCC2j4o5G7HAO+w/3P++OOPg46ne8kll1wiH374oRvl/fWvf5WpU6e6cwReYe/DNWvWLHcuzxYCedWECRMC39tzXDaQBgwYIK+++qo7Ee8VjY2NbgT06KOPusd2BGT/Pu35HrvtagjJEVCfPn3knHPOOaXSxD5OS0sTL2rql1f6fO+998obb7whmzdvDtzfybJ9sYdYfT5f2PfT/s/4oosukpEjR7oRgi0wefLJJz3TR3v4yRb9XH755RIdHe2aDVh7ktp+b/9n7IV+NmdHOxdffLHs37/fM++lZSvb7Aj9ZIMHDw4cbtTYB3UL1T9s+0ddVFQUlN72sT3G7kUDBw50b/LJfbZ3Y7SVKOHUZ1tfYcPHHo7atGmT69fJ7Ptqq3BO7qct07Z/BOHUz5bYbbS+vt4zfRw/frw7zGhHeU3N/g/aniNp+t4L/WzuyJEjcuDAAbfD9sp7adlD4c0/EvHJJ5+40Z7aPsiEqDVr1rjqi1WrVpm9e/eaadOmmaSkJFNVVWXCla0m+uCDD1yzL/3jjz/uvv/ss8/c/IULF7o+rlu3znz00Udm4sSJZuDAgebYsWMmXMyYMcMkJiaaLVu2mEOHDgXaN998E1hm+vTpJiMjw2zatMns3LnTZGVluRZOfvOb37jKvvLycvde2cdRUVHmrbfe8kwfW3JyFZxX+nn//fe77dW+l++8847Jzs42ffr0cRWcXumj9d5775no6GjzyCOPmLKyMvPyyy+bHj16mJdeesk0Odv7oJANIOupp55yb3xsbKwry962bZsJZ5s3b3bB07xNnTo1UAY5b948k5qa6sJ3/PjxprS01ISTlvpn28qVKwPL2I35nnvucWXL9g/gpptuciEVTn7xi1+YAQMGuG3zvPPOc+9VU/h4pY9tCSAv9POWW24x/fr1c+/ld77zHfd4//79nupjk8LCQjN06FC3f8nMzDTPPvusOdnZ3gdxPyAAgIqQPAcEAPA+AggAoIIAAgCoIIAAACoIIACACgIIAKCCAAIAqCCAAAAqCCAAAAEEAIgcjIAAAKLh/wCGgy2NhtZiEQAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -319,12 +326,12 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "metadata": {}, "outputs": [ { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] diff --git a/pyproject.toml b/pyproject.toml index 61d4bbf3..34dfedef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ matplotlib = "*" lightning = "*" albumentations = ">=2.0.0" lazy-loader = ">=0.3.0" -medimgkit = ">=0.5.0" +medimgkit = ">=0.6.0" typing_extensions = ">=4.0.0" pydantic = ">=2.6.4" # For compatibility with the datamintapi package