From e36dc5b15c12ba528a433a927689c06c2e0a3112 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 9 Oct 2025 15:24:50 -0300 Subject: [PATCH 1/2] Refactor ResourcesApi and EntityBaseApi for improved structure and caching - Updated ResourcesApi to allow optional injection of AnnotationsApi and ProjectsApi. - Removed redundant _determine_mimetype method from ResourcesApi, utilizing BaseApi instead. - Enhanced EntityBaseApi to initialize entity objects with injected API context. - Introduced CacheManager for efficient caching of resource and annotation data. - Added lazy loading and caching for resources and projects in DatasetInfo. - Improved error handling and logging throughout the API and entity classes. - Updated AnnotationType to use StrEnum for better string handling. - Introduced ImagingData type alias for consistent imaging data handling. - Added methods for fetching resources and projects in the Project and DatasetInfo classes. - Updated project and resource classes to include web access URLs and browser opening functionality. - Enhanced caching mechanisms to validate against server versions for data freshness. --- datamint/api/base_api.py | 74 +++++- datamint/api/client.py | 5 +- datamint/api/dto/__init__.py | 10 +- datamint/api/endpoints/annotations_api.py | 19 +- datamint/api/endpoints/projects_api.py | 70 ++--- datamint/api/endpoints/resources_api.py | 61 +++-- datamint/api/entity_base_api.py | 50 +--- datamint/apihandler/dto/annotation_dto.py | 8 +- datamint/configs.py | 6 + datamint/dataset/base_dataset.py | 6 +- datamint/entities/__init__.py | 6 +- datamint/entities/annotation.py | 78 +++++- datamint/entities/base_entity.py | 53 +++- datamint/entities/cache_manager.py | 302 ++++++++++++++++++++++ datamint/entities/datasetinfo.py | 109 +++++++- datamint/entities/project.py | 53 +++- datamint/entities/resource.py | 165 ++++++++++-- datamint/types.py | 17 ++ pyproject.toml | 1 + 19 files changed, 931 insertions(+), 162 deletions(-) create mode 100644 datamint/entities/cache_manager.py create mode 100644 datamint/types.py diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index d87d8f92..d0e03dac 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -1,27 +1,28 @@ import logging -from typing import Any, Generator, AsyncGenerator, Sequence +from typing import Any, Generator, AsyncGenerator, Sequence, TYPE_CHECKING import httpx from dataclasses import dataclass from datamint.exceptions import DatamintException, ResourceNotFoundError +from datamint.types import ImagingData 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 import contextlib import asyncio -from medimgkit.format_detection import GZIP_MIME_TYPES +from medimgkit.format_detection import GZIP_MIME_TYPES, DEFAULT_MIME_TYPE, guess_typez, guess_extension + +if TYPE_CHECKING: + from datamint.api.client import Api logger = logging.getLogger(__name__) # Generic type for entities _PAGE_LIMIT = 5000 - @dataclass class ApiConfig: """Configuration for API client. @@ -37,6 +38,15 @@ class ApiConfig: timeout: float = 30.0 max_retries: int = 3 + @property + def web_app_url(self) -> str: + """Get the base URL for the web application.""" + if self.server_url.startswith('http://localhost:3001'): + return 'http://localhost:3000' + if self.server_url.startswith('https://stagingapi.datamint.io'): + return 'https://staging.datamint.io' + return 'https://app.datamint.io' + class BaseApi: """Base class for all API endpoint handlers.""" @@ -53,6 +63,7 @@ def __init__(self, self.config = config self.client = client or self._create_client() self.semaphore = asyncio.Semaphore(20) + self._api_instance: 'Api | None' = None # Injected by Api class def _create_client(self) -> httpx.Client: """Create and configure HTTP client with authentication and timeouts.""" @@ -399,10 +410,30 @@ def _convert_array_response(self, @staticmethod def convert_format(bytes_array: bytes, - mimetype: str, + mimetype: str | None = None, 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.""" + ) -> ImagingData | bytes: + """ Convert the bytes array to the appropriate format based on the mimetype. + + Args: + bytes_array: Raw file content bytes + mimetype: Optional MIME type of the content + file_path: deprecated + + Returns: + Converted content in appropriate format (pydicom.Dataset, PIL Image, cv2.VideoCapture, ...) + + Example: + >>> fpath = 'path/to/file.dcm' + >>> with open(fpath, 'rb') as f: + ... dicom_bytes = f.read() + >>> dicom = BaseApi.convert_format(dicom_bytes) + + """ + if mimetype is None: + mimetype, ext = BaseApi._determine_mimetype(bytes_array) + if mimetype is None: + raise ValueError("Could not determine mimetype from content.") content_io = BytesIO(bytes_array) if mimetype.endswith('/dicom'): return pydicom.dcmread(content_io) @@ -429,3 +460,30 @@ def convert_format(bytes_array: bytes, return nib.Nifti1Image.from_stream(f) raise ValueError(f"Unsupported mimetype: {mimetype}") + + @staticmethod + def _determine_mimetype(content: bytes, + declared_mimetype: str | None = None) -> tuple[str | None, str | None]: + """Infer MIME type and file extension from content and optional declared type. + + Args: + content: Raw file content bytes + declared_mimetype: Optional MIME type declared by the source + + Returns: + Tuple of (inferred_mimetype, file_extension) + """ + # 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 declared_mimetype is not None: + if mimetype is None: + mimetype = declared_mimetype + ext = guess_extension(mimetype) + elif mimetype == DEFAULT_MIME_TYPE: + mimetype = declared_mimetype + ext = guess_extension(mimetype) + + return mimetype, ext diff --git a/datamint/api/client.py b/datamint/api/client.py index 04b2dab5..82ef8703 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -66,7 +66,10 @@ def check_connection(self): 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) + endpoint = api_class(self.config, self._client) + # Inject this API instance into the endpoint so it can inject into entities + endpoint._api_instance = self + self._endpoints[name] = endpoint return self._endpoints[name] @property diff --git a/datamint/api/dto/__init__.py b/datamint/api/dto/__init__.py index b789a550..ecb628be 100644 --- a/datamint/api/dto/__init__.py +++ b/datamint/api/dto/__init__.py @@ -1,5 +1,9 @@ from datamint.apihandler.dto import annotation_dto -from datamint.apihandler.dto.annotation_dto import AnnotationType, CreateAnnotationDto, Geometry, BoxGeometry +from datamint.apihandler.dto.annotation_dto import ( + AnnotationType, CreateAnnotationDto, + Geometry, BoxGeometry, LineGeometry, + CoordinateSystem +) __all__ = [ "annotation_dto", @@ -7,4 +11,6 @@ "CreateAnnotationDto", "Geometry", "BoxGeometry", -] \ No newline at end of file + "LineGeometry", + "CoordinateSystem" +] diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 8d9b4f34..cf1cf908 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -7,7 +7,7 @@ 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 +from datamint.api.dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry import numpy as np import os import aiohttp @@ -31,15 +31,21 @@ class AnnotationsApi(CreatableEntityApi[Annotation], DeletableEntityApi[Annotation]): """API handler for annotation-related endpoints.""" - def __init__(self, config: ApiConfig, client: httpx.Client | None = None) -> None: + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None, + models_api=None, + resources_api=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. """ + from .resources_api import ResourcesApi super().__init__(config, Annotation, 'annotations', client) - self._models_api = ModelsApi(config, client=client) + self._models_api = ModelsApi(config, client=client) if models_api is None else models_api + self._resources_api = ResourcesApi(config, client=client, annotations_api=self) if resources_api is None else resources_api def get_list(self, resource: str | Resource | None = None, @@ -903,7 +909,7 @@ def _create_geometry_annotation(self, def download_file(self, annotation: str | Annotation, - fpath_out: str | Path | None = None) -> bytes: + fpath_out: str | os.PathLike | None = None) -> bytes: """ Download the segmentation file for a given resource and annotation. @@ -923,7 +929,7 @@ def download_file(self, resp = self._make_request('GET', f'/annotations/{resource_id}/annotations/{annotation_id}/file') if fpath_out: - with open(str(fpath_out), 'wb') as f: + with open(fpath_out, 'wb') as f: f.write(resp.content) return resp.content @@ -1028,3 +1034,6 @@ def patch(self, respdata = resp.json() if isinstance(respdata, dict) and 'error' in respdata: raise DatamintException(respdata['error']) + + def _get_resource(self, ann: Annotation) -> Resource: + return self._resources_api.get_by_id(ann.resource_id) diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index be1983ed..8c1be0ac 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -30,7 +30,8 @@ def get_project_resources(self, project: Project | str) -> list[Resource]: """ response = self._get_child_entities(project, 'resources') resources_data = response.json() - return [Resource(**item) for item in resources_data] + resources = [Resource(**item) for item in resources_data] + return resources def create(self, name: str, @@ -148,47 +149,48 @@ 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: str | Project, - outpath: str, - all_annotations: bool = False, - include_unannotated: bool = False, - ) -> None: - """Download a project by its id. - - Args: - 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. - 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 - - project_id = self._entid(project) - 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) + # def download(self, project: str | Project, + # outpath: str, + # all_annotations: bool = False, + # include_unannotated: bool = False, + # ) -> None: + # """Download a project by its id. + + # Args: + # 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. + # 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 + + # project_id = self._entid(project) + # 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) def set_work_status(self, - resource: str | Resource, project: str | Project, + resource: str | Resource, status: Literal['opened', 'annotated', 'closed']) -> None: """ Set the status of a resource. Args: - annotation: The annotation unique id or an annotation object. + project: The project unique id or a project object. + resource: The resource unique id or a resource object. status: The new status to set. """ resource_id = self._entid(resource) diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index e725d00b..eff59ee3 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -1,9 +1,8 @@ from typing import Any, Optional, Sequence, TypeAlias, Literal, IO 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 ..entity_base_api import CreatableEntityApi, DeletableEntityApi from datamint.entities.resource import Resource +from datamint.entities.project import Project from datamint.entities.annotation import Annotation from datamint.exceptions import DatamintException, ResourceNotFoundError import httpx @@ -23,10 +22,9 @@ 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 +from datamint.types import ImagingData _LOGGER = logging.getLogger(__name__) @@ -54,17 +52,25 @@ def _open_io(file_path: str | Path | IO, mode: str = 'rb') -> IO: class ResourcesApi(CreatableEntityApi[Resource], DeletableEntityApi[Resource]): """API handler for resource-related endpoints.""" - def __init__(self, config: ApiConfig, client: Optional[httpx.Client] = None) -> None: + def __init__(self, + config: ApiConfig, + client: Optional[httpx.Client] = None, + annotations_api=None, + projects_api=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. """ + from .annotations_api import AnnotationsApi + from .projects_api import ProjectsApi super().__init__(config, Resource, 'resources', client) nest_asyncio.apply() - self.annotations_api = AnnotationsApi(config, client) - self.projects_api = ProjectsApi(config, client) + self.annotations_api = AnnotationsApi( + config, client, resources_api=self) if annotations_api is None else annotations_api + self.projects_api = ProjectsApi(config, client) if projects_api is None else projects_api def get_list(self, status: Optional[ResourceStatus] = None, @@ -710,21 +716,6 @@ def upload_resource(self, # 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, - 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, @@ -761,8 +752,8 @@ async def _async_download_file(self, f.write(data_bytes) # Determine mimetype from file content - mimetype, ext = self._determine_mimetype(content=data_bytes, - resource=resource) + mimetype, ext = BaseApi._determine_mimetype(content=data_bytes, + declared_mimetype=resource.mimetype if isinstance(resource, Resource) else None) # Generate final path with extension if needed if mimetype is not None and mimetype != DEFAULT_MIME_TYPE: @@ -850,7 +841,7 @@ def download_resource_file(self, save_path: Optional[str] = None, auto_convert: bool = True, add_extension: bool = False - ) -> bytes | pydicom.Dataset | Image.Image | cv2.VideoCapture | nib_FileBasedImage | tuple[Any, str]: + ) -> ImagingData | tuple[ImagingData, str] | bytes: """ Download a resource file. @@ -888,8 +879,8 @@ def download_resource_file(self, mimetype = None ext = None if auto_convert or add_extension: - mimetype, ext = self._determine_mimetype(content=response.content, - resource=resource) + mimetype, ext = BaseApi._determine_mimetype(content=response.content, + declared_mimetype=resource.mimetype if isinstance(resource, Resource) else None) if auto_convert: if mimetype is None: _LOGGER.warning("Could not determine mimetype. Returning a bytes array.") @@ -1005,3 +996,17 @@ def set_tags(self, add_path='tags', json=data) return response + + # def get_projects(self, resource: Resource) -> Sequence[Project]: + # """ + # Get all projects this resource belongs to. + + # Args: + # resource: The Resource instance. + + # Returns: + # List of Project instances + # """ + # resource._ensure_attr('projects') + # proj_ids = [p['id'] for p in resource.projects] + # return [proj for proj in self.projects_api.get_all() if proj.id in proj_ids] diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index 50b702f7..7b9d899d 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -1,7 +1,6 @@ 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 @@ -37,9 +36,14 @@ def __init__(self, config: ApiConfig, client: Optional HTTP client instance. If None, a new one will be created. """ super().__init__(config, client) - self.entity_class = entity_class + self.__entity_class = entity_class self.endpoint_base = endpoint_base.strip('/') + def _init_entity_obj(self, **kwargs) -> T: + obj = self.__entity_class(**kwargs) + obj._api = self + return obj + @staticmethod def _entid(entity: BaseEntity | str) -> str: return entity if isinstance(entity, str) else entity.id @@ -117,7 +121,7 @@ def get_list(self, limit: int | None = None, for resp, items in items_gen: all_items.extend(items) - return [self.entity_class(**item) for item in all_items] + return [self._init_entity_obj(**item) for item in all_items] def get_all(self, limit: int | None = None) -> Sequence[T]: """Get all entities with optional pagination and filtering. @@ -143,7 +147,7 @@ def get_by_id(self, entity_id: str) -> T: 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()) + return self._init_entity_obj(**response.json()) async def _create_async(self, entity_data: dict[str, Any]) -> str | Sequence[str | dict]: """Create a new entity. @@ -177,42 +181,6 @@ def _get_child_entities(self, 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. @@ -264,7 +232,7 @@ async def _delete_async(self, httpx.HTTPStatusError: If deletion fails or entity not found """ async with self._make_entity_request_async('DELETE', entity, - session=session) as resp: + session=session) as resp: await resp.text() # Consume response to complete request # def get_deleted(self, **kwargs) -> Sequence[T]: diff --git a/datamint/apihandler/dto/annotation_dto.py b/datamint/apihandler/dto/annotation_dto.py index 285d4eb3..7c8311fc 100644 --- a/datamint/apihandler/dto/annotation_dto.py +++ b/datamint/apihandler/dto/annotation_dto.py @@ -17,7 +17,11 @@ import json from typing import Any, TypeAlias, Literal import logging -from enum import Enum +import sys +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from backports.strenum import StrEnum from medimgkit.dicom_utils import pixel_to_patient import pydicom import numpy as np @@ -31,7 +35,7 @@ """ -class AnnotationType(Enum): +class AnnotationType(StrEnum): SEGMENTATION = 'segmentation' AREA = 'area' DISTANCE = 'distance' diff --git a/datamint/configs.py b/datamint/configs.py index fe837701..87914460 100644 --- a/datamint/configs.py +++ b/datamint/configs.py @@ -18,6 +18,12 @@ DIRS = PlatformDirs(appname='datamintapi') CONFIG_FILE = os.path.join(DIRS.user_config_dir, 'datamintapi.yaml') +try: + DATAMINT_DATA_DIR = os.path.join(os.path.expanduser("~"), '.datamint') +except Exception as e: + _LOGGER.error(f"Could not determine home directory: {e}") + DATAMINT_DATA_DIR = None + def get_env_var_name(key: str) -> str: diff --git a/datamint/dataset/base_dataset.py b/datamint/dataset/base_dataset.py index 9d40a46c..88e8910c 100644 --- a/datamint/dataset/base_dataset.py +++ b/datamint/dataset/base_dataset.py @@ -20,6 +20,7 @@ from datamint.entities import Annotation, DatasetInfo import cv2 from datamint.entities import Resource +import datamint.configs _LOGGER = logging.getLogger(__name__) @@ -55,7 +56,7 @@ class DatamintBaseDataset: exclude_frame_label_names: List of frame label names to exclude. If None, no frame labels will be excluded. """ - DATAMINT_DEFAULT_DIR = ".datamint" + DATAMINT_DATASETS_DIR = "datasets" def __init__( @@ -184,8 +185,7 @@ def _setup_directories(self, root: str | None) -> None: """Setup root and dataset directories.""" if root is None: root = os.path.join( - os.path.expanduser("~"), - self.DATAMINT_DEFAULT_DIR, + datamint.configs.DATAMINT_DATA_DIR, self.DATAMINT_DATASETS_DIR ) os.makedirs(root, exist_ok=True) diff --git a/datamint/entities/__init__.py b/datamint/entities/__init__.py index 3162ff4b..7f655673 100644 --- a/datamint/entities/__init__.py +++ b/datamint/entities/__init__.py @@ -7,14 +7,16 @@ from .resource import Resource from .user import User # new export from .datasetinfo import DatasetInfo +from .cache_manager import CacheManager __all__ = [ 'Annotation', 'BaseEntity', + 'CacheManager', 'Channel', 'ChannelResourceData', + 'DatasetInfo', 'Project', 'Resource', - "User", - 'DatasetInfo', + 'User', ] diff --git a/datamint/entities/annotation.py b/datamint/entities/annotation.py index c5a69406..d09f6677 100644 --- a/datamint/entities/annotation.py +++ b/datamint/entities/annotation.py @@ -5,11 +5,20 @@ records returned by the DataMint API. """ -from typing import Any +from typing import TYPE_CHECKING, Any import logging +import os + from .base_entity import BaseEntity, MISSING_FIELD -from pydantic import Field +from .cache_manager import CacheManager +from pydantic import PrivateAttr from datetime import datetime +from datamint.api.dto import AnnotationType +from datamint.types import ImagingData + +if TYPE_CHECKING: + from datamint.api.endpoints.annotations_api import AnnotationsApi + from .resource import Resource logger = logging.getLogger(__name__) @@ -21,6 +30,8 @@ 'index': 'frame_index', } +_ANNOTATION_CACHE_KEY = "annotation_data" + class Annotation(BaseEntity): """Pydantic Model representing a DataMint annotation. @@ -60,7 +71,7 @@ class Annotation(BaseEntity): identifier: str scope: str frame_index: int | None - annotation_type: str + annotation_type: AnnotationType text_value: str | None numeric_value: float | int | None units: str | None @@ -83,7 +94,66 @@ 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 + file: str | None = None + + _api: 'AnnotationsApi' = PrivateAttr() + + def __init__(self, **data): + """Initialize the annotation entity.""" + super().__init__(**data) + self._cache: CacheManager = CacheManager('annotations') + self._resource: 'Resource | None' = None + + @property + def resource(self) -> 'Resource': + """Lazily load and cache the associated Resource entity.""" + if self._resource is None: + self._resource = self._api._get_resource(self) + return self._resource + + def fetch_file_data( + self, + save_path: os.PathLike | str | None = None, + auto_convert: bool = True, + use_cache: bool = False, + ) -> bytes | ImagingData: + # Version info for cache validation + version_info = self._generate_version_info() + + # Try to get from cache + img_data = None + if use_cache: + img_data = self._cache.get(self.id, _ANNOTATION_CACHE_KEY, version_info) + + if img_data is None: + # Fetch from server using download_resource_file + logger.debug(f"Fetching image data from server for resource {self.id}") + img_data = self._api.download_file( + self, + fpath_out=save_path + ) + # Cache the data + if use_cache: + self._cache.set(self.id, _ANNOTATION_CACHE_KEY, img_data, version_info) + + if auto_convert: + return self._api.convert_format(img_data) + + return img_data + + def _generate_version_info(self) -> dict: + """Helper to generate version info for caching.""" + return { + 'created_at': self.created_at, + 'deleted_at': self.deleted_at, + 'associated_file': self.associated_file, + } + + def invalidate_cache(self) -> None: + """Invalidate all cached data for this annotation.""" + self._cache.invalidate(self.id) + self._resource = None + logger.debug(f"Invalidated cache for annotation {self.id}") @classmethod def from_dict(cls, data: dict[str, Any]) -> 'Annotation': diff --git a/datamint/entities/base_entity.py b/datamint/entities/base_entity.py index d857424b..f4eba60a 100644 --- a/datamint/entities/base_entity.py +++ b/datamint/entities/base_entity.py @@ -1,7 +1,11 @@ import logging import sys -from typing import Any -from pydantic import ConfigDict, BaseModel +from typing import Any, TYPE_CHECKING +from pydantic import ConfigDict, BaseModel, PrivateAttr + +if TYPE_CHECKING: + from datamint.api.client import Api + from datamint.api.entity_base_api import EntityBaseApi if sys.version_info >= (3, 11): from typing import Self @@ -22,9 +26,14 @@ class BaseEntity(BaseModel): This class provides common functionality for all entities, such as serialization and deserialization from dictionaries, as well as handling unknown fields gracefully. + + The API client is automatically injected by the Api class when entities + are created through API endpoints. """ - model_config = ConfigDict(extra='allow') # Allow extra fields not defined in the model + model_config = ConfigDict(extra='allow', arbitrary_types_allowed=True) # Allow extra fields and arbitrary types + + _api: 'EntityBaseApi[Self] | EntityBaseApi' = PrivateAttr() def asdict(self) -> dict[str, Any]: """Convert the entity to a dictionary, including unknown fields.""" @@ -38,14 +47,46 @@ 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}") + + @staticmethod + def is_attr_missing(value: Any) -> bool: + """Check if a value is the MISSING_FIELD sentinel.""" + return value == MISSING_FIELD + + def _refresh(self) -> Self: + """Refresh the entity data from the server. + + This method fetches the latest data from the server and updates + the current instance with any missing or updated fields. + + Returns: + The updated Entity instance (self) + """ + updated_ent = self._api.get_by_id(self._api._entid(self)) + + # Update all fields from the fresh data + for field_name, field_value in updated_ent.model_dump().items(): + if field_value != MISSING_FIELD: + setattr(self, field_name, field_value) + + return self + + def _ensure_attr(self, attr_name: str) -> None: + """Ensure that a given attribute is not MISSING_FIELD, refreshing if necessary. + + Args: + attr_name: Name of the attribute to check and ensure + """ + if self.is_attr_missing(getattr(self, attr_name)): + self._refresh() diff --git a/datamint/entities/cache_manager.py b/datamint/entities/cache_manager.py new file mode 100644 index 00000000..1ec3a07f --- /dev/null +++ b/datamint/entities/cache_manager.py @@ -0,0 +1,302 @@ +"""Cache manager for storing and retrieving entity-related data locally. + +This module provides caching functionality for resource data (images, segmentations, etc.) +with automatic validation against server versions to ensure data freshness. +""" + +import hashlib +import json +import logging +import pickle +from datetime import datetime +from pathlib import Path +from typing import Any, TypeVar, Generic +from pydantic import BaseModel +import appdirs +import datamint.configs + +_LOGGER = logging.getLogger(__name__) + +T = TypeVar('T') + + +class CacheManager(Generic[T]): + """Manages local caching of entity data with versioning support. + + This class handles storing and retrieving cached data with automatic + validation against server versions to ensure data consistency. + + The cache uses a directory structure: + - cache_root/ + - resources/ + - {resource_id}/ + - image_data.pkl + - metadata.json + - annotations/ + - {annotation_id}/ + - segmentation_data.pkl + - metadata.json + + Attributes: + cache_root: Root directory for cache storage + entity_type: Type of entity being cached (e.g., 'resources', 'annotations') + """ + + class ItemMetadata(BaseModel): + cached_at: datetime + data_path: str + data_type: str + mimetype: str + version_hash: str | None = None + version_info: dict | None = None + entity_id: str | None = None + + def __init__(self, entity_type: str, cache_root: Path | str | None = None): + """Initialize the cache manager. + + Args: + entity_type: Type of entity (e.g., 'resources', 'annotations') + cache_root: Root directory for cache. If None, uses system cache directory. + """ + self.entity_type = entity_type + + if cache_root is None: + # Use platform-specific cache directory + # app_cache_dir = appdirs.user_cache_dir('datamint', 'sonance') + # cache_root = Path(app_cache_dir) / 'entity_cache' + cache_root = Path(datamint.configs.DATAMINT_DATA_DIR) + else: + cache_root = Path(cache_root) + + self.cache_root = cache_root / entity_type + + def _get_entity_cache_dir(self, entity_id: str) -> Path: + """Get the cache directory for a specific entity. + + Args: + entity_id: Unique identifier for the entity + + Returns: + Path to the entity's cache directory + """ + entity_dir = self.cache_root / entity_id + entity_dir = entity_dir.resolve().absolute() + entity_dir.mkdir(parents=True, exist_ok=True) + return entity_dir + + def _get_metadata_path(self, entity_id: str) -> Path: + """Get the path to the metadata file for an entity. + + Args: + entity_id: Unique identifier for the entity + + Returns: + Path to the metadata file + """ + return self._get_entity_cache_dir(entity_id) / 'metadata.json' + + def _get_data_path(self, entity_id: str, data_key: str) -> Path: + """Get the path to a data file for an entity. + + Args: + entity_id: Unique identifier for the entity + data_key: Key identifying the type of data (e.g., 'image_data', 'segmentation') + + Returns: + Path to the data file + """ + return self._get_entity_cache_dir(entity_id) / f"{data_key}.pkl" + + def _compute_version_hash(self, version_info: dict[str, Any]) -> str: + """Compute a hash from version information. + + Args: + version_info: Dictionary containing version information (e.g., updated_at, size) + + Returns: + Hash string representing the version + """ + # Sort keys for consistent hashing + sorted_info = json.dumps(version_info, sort_keys=True) + return hashlib.sha256(sorted_info.encode()).hexdigest() + + def get( + self, + entity_id: str, + data_key: str, + version_info: dict[str, Any] | None = None + ) -> T | None: + """Retrieve cached data for an entity. + + Args: + entity_id: Unique identifier for the entity + data_key: Key identifying the type of data + version_info: Optional version information from server to validate cache + + Returns: + Cached data if valid, None if cache miss or invalid + """ + metadata_path = self._get_metadata_path(entity_id) + data_path = self._get_data_path(entity_id, data_key) + + # Check if cache exists + if not metadata_path.exists() or not data_path.exists(): + _LOGGER.debug(f"Cache miss for {entity_id}/{data_key}") + return None + + try: + # Load or create metadata + with open(metadata_path, 'r') as f: + jsondata = f.read() + cached_metadata = CacheManager.ItemMetadata.model_validate_json(jsondata) + + # Validate version if provided + if version_info is not None: + server_version = self._compute_version_hash(version_info) + + if server_version != cached_metadata.version_hash: + _LOGGER.debug( + f"Cache version mismatch for {entity_id}/{data_key}. " + f"Server: {server_version}, Cached: {cached_metadata.version_hash}" + ) + return None + + data = self._load_data(cached_metadata) + + _LOGGER.debug(f"Cache hit for {entity_id}/{data_key}") + return data + + except Exception as e: + _LOGGER.warning(f"Error reading cache for {entity_id}/{data_key}: {e}") + return None + + def set( + self, + entity_id: str, + data_key: str, + data: T, + version_info: dict[str, Any] | None = None + ) -> None: + """Store data in cache for an entity. + + Args: + entity_id: Unique identifier for the entity + data_key: Key identifying the type of data + data: Data to cache + version_info: Optional version information from server + """ + metadata_path = self._get_metadata_path(entity_id) + data_path = self._get_data_path(entity_id, data_key) + + try: + mimetype = self._save_data(data_path, data) + + metadata = CacheManager.ItemMetadata( + cached_at=datetime.now(), + data_path=str(data_path.absolute()), + data_type=type(data).__name__, + mimetype=mimetype, + entity_id=entity_id + ) + + # Update metadata for this data key + + if version_info is not None: + metadata.version_hash = self._compute_version_hash(version_info) + # Store version_info as JSON string to ensure metadata is JSON-serializable + metadata.version_info = version_info + + # Save metadata + with open(metadata_path, 'w') as f: + f.write(metadata.model_dump_json(indent=2)) + + _LOGGER.debug(f"Cached data for {entity_id}/{data_key}") + + except Exception as e: + _LOGGER.warning(f"Error writing cache for {entity_id}/{data_key}: {e}") + + def _load_data(self, + metadata: 'CacheManager.ItemMetadata') -> T: + path = metadata.data_path + if metadata.mimetype == 'application/octet-stream': + with open(path, 'rb') as f: + return f.read() + else: + with open(path, 'rb') as f: + return pickle.load(f) + + + def _save_data(self, path: Path, data: T) -> str: + """ + Save data and returns the mimetype + """ + if isinstance(data, bytes): + with open(path, 'wb') as f: + f.write(data) + return 'application/octet-stream' + else: + with open(path, 'wb') as f: + pickle.dump(data, f) + return 'application/x-python-serialize' + + def invalidate(self, entity_id: str, data_key: str | None = None) -> None: + """Invalidate cached data for an entity. + + Args: + entity_id: Unique identifier for the entity + data_key: Optional key for specific data. If None, invalidates all data for entity. + """ + if data_key is None: + # Invalidate entire entity cache + entity_dir = self._get_entity_cache_dir(entity_id) + if entity_dir.exists(): + import shutil + shutil.rmtree(entity_dir) + _LOGGER.debug(f"Invalidated all cache for {entity_id}") + else: + # Invalidate specific data + data_path = self._get_data_path(entity_id, data_key) + if data_path.exists(): + data_path.unlink() + _LOGGER.debug(f"Invalidated cache for {entity_id}/{data_key}") + + # Update metadata + metadata_path = self._get_metadata_path(entity_id) + if metadata_path.exists(): + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + if data_key in metadata: + del metadata[data_key] + + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + def clear_all(self) -> None: + """Clear all cached data for this entity type.""" + if self.cache_root.exists(): + import shutil + shutil.rmtree(self.cache_root) + self.cache_root.mkdir(parents=True, exist_ok=True) + _LOGGER.info(f"Cleared all cache for {self.entity_type}") + + def get_cache_info(self, entity_id: str) -> dict[str, Any]: + """Get information about cached data for an entity. + + Args: + entity_id: Unique identifier for the entity + + Returns: + Dictionary containing cache information + """ + metadata_path = self._get_metadata_path(entity_id) + + if not metadata_path.exists(): + return {} + + try: + with open(metadata_path, 'r') as f: + return json.load(f) + except Exception as e: + _LOGGER.warning(f"Error reading cache info for {entity_id}: {e}") + return {} diff --git a/datamint/entities/datasetinfo.py b/datamint/entities/datasetinfo.py index 470574c2..bc85d713 100644 --- a/datamint/entities/datasetinfo.py +++ b/datamint/entities/datasetinfo.py @@ -1,14 +1,24 @@ -"""Project entity module for DataMint API.""" +"""Dataset entity module for DataMint API.""" from datetime import datetime import logging +from typing import TYPE_CHECKING, Sequence + from .base_entity import BaseEntity, MISSING_FIELD +if TYPE_CHECKING: + from datamint.api.client import Api + from .resource import Resource + from .project import Project + logger = logging.getLogger(__name__) class DatasetInfo(BaseEntity): """Pydantic Model representing a DataMint dataset. + + This class provides access to dataset information and related entities + like resources and projects. """ id: str @@ -20,3 +30,100 @@ class DatasetInfo(BaseEntity): updated_at: str | None total_resource: int resource_ids: list[str] + + def __init__(self, **data): + """Initialize the dataset info entity.""" + super().__init__(**data) + self._manager: EntityManager['DatasetInfo'] = EntityManager(self) + + # Cache for lazy-loaded data + self._resources_cache: Sequence['Resource'] | None = None + self._projects_cache: Sequence['Project'] | None = None + + def _inject_api(self, api: 'Api') -> None: + """Inject API client into this dataset (called automatically by Api class).""" + self._manager.set_api(api) + + def get_resources( + self, + refresh: bool = False, + limit: int | None = None + ) -> Sequence['Resource']: + """Get all resources in this dataset. + + Results are cached after the first call unless refresh=True. + + Args: + api: Optional API client. Uses the one from set_api() if not provided. + refresh: If True, bypass cache and fetch fresh data + + Returns: + List of Resource instances in this dataset + + Raises: + RuntimeError: If no API client is available + + Example: + >>> dataset = api._datasetsinfo.get_by_id("dataset-id") + >>> dataset.set_api(api) + >>> resources = dataset.get_resources() + """ + if refresh or self._resources_cache is None: + api_client = self._manager._ensure_api(api) + + # Fetch resources by their IDs + resources = [] + for resource_id in self.resource_ids: + try: + resource = api_client.resources.get_by_id(resource_id) + resource.set_api(api_client) + resources.append(resource) + except Exception as e: + logger.warning(f"Failed to fetch resource {resource_id}: {e}") + + self._resources_cache = resources + + return self._resources_cache + + def get_projects( + self, + api: 'Api | None' = None, + refresh: bool = False + ) -> Sequence['Project']: + """Get all projects associated with this dataset. + + Results are cached after the first call unless refresh=True. + + Args: + refresh: If True, bypass cache and fetch fresh data + + Returns: + List of Project instances + + Raises: + RuntimeError: If no API client is available + + Example: + >>> dataset = api.datasetsinfo.get_by_id("dataset-id") + >>> projects = dataset.get_projects() + """ + if refresh or self._projects_cache is None: + api_client = self._manager.api + + # Get all projects and filter by dataset_id + all_projects = api_client.projects.get_all() + projects = [p for p in all_projects if p.dataset_id == self.id] + + self._projects_cache = projects + + return self._projects_cache + + def invalidate_cache(self) -> None: + """Invalidate all cached relationship data. + + This forces fresh data fetches on the next access. + """ + self._resources_cache = None + self._projects_cache = None + logger.debug(f"Invalidated cache for dataset {self.id}") + diff --git a/datamint/entities/project.py b/datamint/entities/project.py index cc2cff6a..344d9a7d 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -1,8 +1,14 @@ """Project entity module for DataMint API.""" - from datetime import datetime import logging +from typing import Sequence, Literal, TYPE_CHECKING from .base_entity import BaseEntity, MISSING_FIELD +import webbrowser +from pydantic import PrivateAttr + +if TYPE_CHECKING: + from datamint.api.endpoints.projects_api import ProjectsApi + from .resource import Resource logger = logging.getLogger(__name__) @@ -35,7 +41,7 @@ class Project(BaseEntity): """ id: str name: str - created_at: str # ISO timestamp string + created_at: str created_by: str dataset_id: str worklist_id: str @@ -48,17 +54,52 @@ class Project(BaseEntity): 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 + most_recent_experiment: str | None = MISSING_FIELD 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 + _api: 'ProjectsApi' = PrivateAttr() + + def fetch_resources(self) -> Sequence['Resource']: + """Fetch resources associated with this project from the API, + IMPORTANT: It always fetches fresh data from the server. + + Returns: + List of Resource instances associated with the project. + """ + return self._api.get_project_resources(self.id) + + def set_work_status(self, resource: 'Resource', status: Literal['opened', 'annotated', 'closed']) -> None: + """Set the status of a resource. + + Args: + resource: The resource unique id or a resource object. + status: The new status to set. + """ + + return self._api.set_work_status(self, resource, status) + @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}" + base_url = self._api.config.web_app_url + return f'{base_url}/projects/edit/{self.id}' + + def show(self) -> None: + """Open the project in the default web browser.""" + webbrowser.open(self.url) + + def as_torch_dataset(self, + root_dir: str | None = None, + auto_update: bool = True, + return_as_semantic_segmentation: bool = False): + from datamint.dataset import Dataset + return Dataset(project_name=self.name, + root=root_dir, + auto_update=auto_update, + return_as_semantic_segmentation=return_as_semantic_segmentation, + all_annotations=True) diff --git a/datamint/entities/resource.py b/datamint/entities/resource.py index 84eb53de..bd1cdd61 100644 --- a/datamint/entities/resource.py +++ b/datamint/entities/resource.py @@ -1,19 +1,33 @@ """Resource entity module for DataMint API.""" from datetime import datetime -from typing import Optional, Any +from typing import TYPE_CHECKING, Optional, Any, Sequence import logging + from .base_entity import BaseEntity, MISSING_FIELD -from pydantic import Field +from .cache_manager import CacheManager +from pydantic import PrivateAttr +from datamint.api.dto import AnnotationType +import webbrowser +from datamint.types import ImagingData + +if TYPE_CHECKING: + from datamint.api.endpoints.resources_api import ResourcesApi + from .project import Project + from .annotation import Annotation logger = logging.getLogger(__name__) + +_IMAGE_CACHEKEY = "image_data" + + 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 @@ -84,42 +98,145 @@ class Resource(BaseEntity): categories: Optional[Any] = None # TODO: Define proper type when spec available user_info: Optional[dict] = None + _api: 'ResourcesApi' = PrivateAttr() + + def __init__(self, **data): + """Initialize the resource entity.""" + super().__init__(**data) + self._cache: CacheManager[bytes] = CacheManager[bytes]('resources') + + def fetch_file_data( + self, + auto_convert: bool = True, + save_path: str | None = None, + use_cache: bool = False, + ) -> bytes | ImagingData: + """Get the file data for this resource. + + This method automatically caches the file data locally. On subsequent + calls, it checks the server for changes and uses cached data if unchanged. + + Args: + use_cache: If True, uses cached data when available and valid + auto_convert: If True, automatically converts to appropriate format (pydicom.Dataset, PIL Image, etc.) + save_path: Optional path to save the file locally + + Returns: + File data (format depends on auto_convert and file type) + """ + # Version info for cache validation + version_info = self._generate_version_info() + + # Try to get from cache + img_data = None + if use_cache: + img_data = self._cache.get(self.id, _IMAGE_CACHEKEY, version_info) + if img_data is not None: + logger.debug(f"Using cached image data for resource {self.id}") + + if img_data is None: + # Fetch from server using download_resource_file + logger.debug(f"Fetching image data from server for resource {self.id}") + img_data = self._api.download_resource_file( + self, + save_path=save_path, + auto_convert=False + ) + # Cache the data + if use_cache: + self._cache.set(self.id, _IMAGE_CACHEKEY, img_data, version_info) + + if auto_convert: + try: + mimetype, ext = self._api._determine_mimetype(img_data, self) + img_data = self._api.convert_format(img_data, + mimetype=mimetype, + file_path=save_path) + except Exception as e: + logger.error(f"Failed to auto-convert resource {self.id}: {e}") + + return img_data + + def _generate_version_info(self) -> dict: + """Helper to generate version info for caching.""" + return { + 'created_at': self.created_at, + 'deleted_at': self.deleted_at, + 'size': self.size, + } + + def _save_into_cache(self, data: bytes) -> None: + """Helper to save raw data into cache.""" + version_info = self._generate_version_info() + self._cache.set(self.id, _IMAGE_CACHEKEY, data, version_info) + + def fetch_annotations( + self, + annotation_type: AnnotationType | str | None = None + ) -> Sequence['Annotation']: + """Get annotations associated with this resource.""" + + annotations = self._api.get_annotations(self) + + if annotation_type: + annotation_type = AnnotationType(annotation_type) + annotations = [a for a in annotations if a.annotation_type == annotation_type] + return annotations + + # def get_projects( + # self, + # ) -> Sequence['Project']: + # """Get all projects this resource belongs to. + + # Returns: + # List of Project instances + # """ + # return self._api.get_projects(self) + + + def invalidate_cache(self) -> None: + """Invalidate cached data for this resource. + """ + # Invalidate all + self._cache.invalidate(self.id) + logger.debug(f"Invalidated all cache for resource {self.id}") + @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 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] if self.projects != MISSING_FIELD else [] + 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 """ @@ -128,3 +245,13 @@ def __repr__(self) -> str: f"modality='{self.modality}', status='{self.status}', " f"published={self.published})" ) + + @property + def url(self) -> str: + """Get the URL to access this resource in the DataMint web application.""" + base_url = self._api.config.web_app_url + return f'{base_url}/resource/{self.id}' + + def show(self) -> None: + """Open the resource in the default web browser.""" + webbrowser.open(self.url) diff --git a/datamint/types.py b/datamint/types.py new file mode 100644 index 00000000..9b547bc8 --- /dev/null +++ b/datamint/types.py @@ -0,0 +1,17 @@ +from typing import TypeAlias, TYPE_CHECKING, Union + +if TYPE_CHECKING: + import pydicom.dataset + from PIL import Image + import cv2 + from nibabel.filebasedimages import FileBasedImage as nib_FileBasedImage + +# Type alias for imaging formats +ImagingData: TypeAlias = ( + Union[ + 'pydicom.dataset.Dataset', + 'Image.Image', + 'cv2.VideoCapture', + 'nib_FileBasedImage' + ] +) diff --git a/pyproject.toml b/pyproject.toml index 3c788a22..3aed58b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ medimgkit = ">=0.7.2" typing_extensions = ">=4.0.0" pydantic = ">=2.6.4" httpx = "*" +backports-strenum = { version = "*", python = "<3.11" } # For compatibility with the datamintapi package datamintapi = "0.0.*" # Extra dependencies for docs From 5cd9eb5bd85f7587ebcd12d3e3b6b6c653fea8ac Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 16 Oct 2025 14:33:16 -0300 Subject: [PATCH 2/2] removed unused import --- datamint/entities/cache_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datamint/entities/cache_manager.py b/datamint/entities/cache_manager.py index 1ec3a07f..170642aa 100644 --- a/datamint/entities/cache_manager.py +++ b/datamint/entities/cache_manager.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any, TypeVar, Generic from pydantic import BaseModel -import appdirs +# import appdirs import datamint.configs _LOGGER = logging.getLogger(__name__)