From 95301c4c776b18d6a6fcbefb65594a67a117f777 Mon Sep 17 00:00:00 2001 From: luandalmazo Date: Thu, 9 Jul 2026 16:09:37 -0300 Subject: [PATCH] update parameters and add deprecated warning --- datamint/api/endpoints/annotations_api.py | 86 ++- datamint/api/endpoints/annotationsets_api.py | 622 ++++++++++++++---- datamint/api/endpoints/datasetsinfo_api.py | 21 +- datamint/api/endpoints/deploy_model_api.py | 20 +- datamint/api/endpoints/inference_api.py | 39 +- datamint/api/endpoints/projects_api.py | 122 +++- datamint/api/endpoints/resources_api.py | 22 +- datamint/api/endpoints/users_api.py | 41 +- datamint/client_cmd_tools/datamint_upload.py | 2 +- datamint/dataset/factory.py | 33 +- datamint/examples/example_projects.py | 2 +- datamint/lightning/datamodule.py | 11 +- .../specialized/nnunet/data_import.py | 2 +- tests/test_annotations_api.py | 84 ++- tests/test_annotationsets_api.py | 68 +- tests/test_datamodule_collate.py | 11 + tests/test_dataset_factory.py | 22 + tests/test_datasetsinfo_api.py | 29 +- tests/test_deploy_model_api.py | 32 + tests/test_inference_api.py | 39 ++ tests/test_projects_api.py | 63 +- tests/test_resources_api.py | 23 +- tests/test_users_api.py | 37 +- 23 files changed, 1200 insertions(+), 231 deletions(-) create mode 100644 tests/test_dataset_factory.py create mode 100644 tests/test_deploy_model_api.py create mode 100644 tests/test_inference_api.py diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 445e0145..7d652138 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -7,6 +7,7 @@ import json import logging import os +import warnings import aiohttp import httpx @@ -39,6 +40,7 @@ if TYPE_CHECKING: from .resources_api import ResourcesApi from .models_api import ModelsApi + from datamint.entities import Project _LOGGER = logging.getLogger(__name__) @@ -75,8 +77,8 @@ def get_list(self, resource: str | Resource | Sequence[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, + from_date: date | None = None, + to_date: date | None = None, dataset_id: str | None = None, worklist_id: str | None = None, status: Literal['new', 'published'] | None = None, @@ -90,8 +92,8 @@ def get_list(self, resource: str | Resource | Sequence[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, + from_date: date | None = None, + to_date: date | None = None, dataset_id: str | None = None, worklist_id: str | None = None, status: Literal['new', 'published'] | None = None, @@ -106,14 +108,17 @@ def get_list( # type: ignore[override] resource: str | Resource | Sequence[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, + from_date: date | None = None, + to_date: 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, limit: int | None = None, group_by_resource: bool = False, + *, + date_from: date | None = None, + date_to: date | None = None, **kwargs: Any, ) -> Sequence[Annotation] | Sequence[Sequence[Annotation]]: """ @@ -124,8 +129,8 @@ def get_list( # type: ignore[override] a list of resources, or None to retrieve annotations from all resources. annotation_type: Filter by annotation type (e.g., 'segmentation', 'category'). annotator_email: Filter by annotator email address. - date_from: Filter annotations created on or after this date. - date_to: Filter annotations created on or before this date. + from_date: Filter annotations created on or after this date. + to_date: Filter annotations created on or before this date. dataset_id: Filter by dataset unique id. worklist_id: Filter by annotation worklist unique id. status: Filter by annotation status ('new' or 'published'). @@ -133,6 +138,8 @@ def get_list( # type: ignore[override] limit: Maximum number of annotations to return. group_by_resource: If True, return results grouped by resource. For instance, the first index of the returned list will contain all annotations for the first resource. + date_from: (DEPRECATED) Use ``from_date`` instead. + date_to: (DEPRECATED) Use ``to_date`` instead. Returns: Sequence[Annotation] | Sequence[Sequence[Annotation]]: List of annotations, or list of lists if grouped by resource. @@ -167,12 +174,23 @@ def group_annotations_by_resource(annotations: Sequence[Annotation], resource_annotations_map[ann.resource_id].append(ann) return [resource_annotations_map[rid] for rid in resource_ids] + if date_from is not None: + warnings.warn("The 'date_from' parameter is deprecated. " + "Please use 'from_date' instead", DeprecationWarning) + if from_date is None: + from_date = date_from + if date_to is not None: + warnings.warn("The 'date_to' parameter is deprecated. " + "Please use 'to_date' instead", DeprecationWarning) + if to_date is None: + to_date = date_to + # Build search payload according to POST /annotations/search schema payload = { '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, + 'from': from_date.isoformat() if from_date is not None else None, + 'to': to_date.isoformat() if to_date is not None else None, 'dataset_id': dataset_id, 'annotation_worklist_id': worklist_id, 'status': status, @@ -585,9 +603,11 @@ def upload_volume_segmentation(self, imported_from: str | None = None, author_email: str | None = None, worklist_id: str | None = None, - ai_model_name: str | None = None, + model_name: str | None = None, transpose_segmentation: bool = False, source: str | None = 'imported', + *, + ai_model_name: str | None = None, ) -> list[str]: """ Upload a 3D volume segmentation to a resource. @@ -603,8 +623,9 @@ def upload_volume_segmentation(self, imported_from: The imported from value. author_email: The author email. worklist_id: The annotation worklist unique id. - ai_model_name: The AI model name. + model_name: The AI model name. transpose_segmentation: Whether to transpose the segmentation before uploading. + ai_model_name: (DEPRECATED) Use ``model_name`` instead. source: Annotation source tag. Defaults to 'imported' since this is a direct API entry point; :meth:`upload_predictions` overrides it with 'model_pipeline'/'model_deploy'. @@ -634,13 +655,19 @@ def upload_volume_segmentation(self, """ import nest_asyncio + if ai_model_name is not None: + warnings.warn("The 'ai_model_name' parameter is deprecated. " + "Please use 'model_name' instead", DeprecationWarning) + if model_name is None: + model_name = ai_model_name + if isinstance(file_path, Path): file_path = str(file_path) if isinstance(file_path, str) and not os.path.exists(file_path): raise FileNotFoundError(f"File {file_path} not found.") - model_id = self._check_model(ai_model_name) + model_id = self._check_model(model_name) nest_asyncio.apply() loop = asyncio.get_event_loop() @@ -668,8 +695,10 @@ def upload_segmentations(self, discard_empty_segmentations: bool = True, worklist_id: str | None = None, transpose_segmentation: bool = False, - ai_model_name: str | None = None, + model_name: str | None = None, source: str | None = 'imported', + *, + ai_model_name: str | None = None, ) -> list[str]: """ Upload frame-by-frame segmentations to a resource. @@ -698,9 +727,10 @@ def upload_segmentations(self, worklist_id: The annotation worklist unique id. model_id: The model unique id. transpose_segmentation: Whether to transpose the segmentation or not. - ai_model_name: Optional AI model name to associate with the segmentation. + model_name: Optional AI model name to associate with the segmentation. source: Annotation source tag. Defaults to 'imported' since this is a direct API entry point; :meth:`upload_predictions` overrides it with 'model_pipeline'/'model_deploy'. + ai_model_name: (DEPRECATED) Use ``model_name`` instead. Returns: List of segmentation unique ids. @@ -729,6 +759,12 @@ def upload_segmentations(self, """ import nest_asyncio + if ai_model_name is not None: + warnings.warn("The 'ai_model_name' parameter is deprecated. " + "Please use 'model_name' instead", DeprecationWarning) + if model_name is None: + model_name = ai_model_name + if isinstance(file_path, Path): file_path = str(file_path) @@ -740,7 +776,7 @@ def upload_segmentations(self, "NIfTI files are volume segmentations. Use `upload_volume_segmentation` instead." ) - model_id = self._check_model(ai_model_name) + model_id = self._check_model(model_name) standardized_name = self.standardize_segmentation_names(name) _LOGGER.debug(f"Standardized segmentation names: {standardized_name}") @@ -1149,7 +1185,7 @@ def upload_predictions( resource=resource, file_path=mask, name=ann.class_map, - ai_model_name=model_name, + model_name=model_name, source=source, ) annotation_ids.extend(ids) @@ -1166,7 +1202,7 @@ def upload_predictions( file_path=mask, name=ann.identifier, frame_index=ann.frame_index, - ai_model_name=model_name, + model_name=model_name, source=source, ) annotation_ids.extend(ids) @@ -1550,6 +1586,8 @@ def bulk_download_file(self, def patch(self, annotation: str | Annotation, identifier: str | None = None, + project: 'str | Project | None' = None, + *, project_id: str | None = None) -> None: """ Partially update an annotation's metadata. @@ -1557,14 +1595,22 @@ def patch(self, Args: annotation: The annotation unique id or Annotation instance. identifier: Optional new identifier/label for the annotation. - project_id: Optional project ID to associate with the annotation. + project: Optional project ID or Project instance to associate with the annotation. + project_id: (DEPRECATED) Use ``project`` instead. Raises: ServerError: If the update fails. """ annotation_id = self._entid(annotation) - payload = {'identifier': identifier, 'project_id': project_id} + if project_id is not None: + warnings.warn("The 'project_id' parameter is deprecated. " + "Please use 'project' instead", DeprecationWarning) + if project is None: + project = project_id + + payload = {'identifier': identifier, + 'project_id': self._entid(project) if project is not None else None} # remove None values payload = {k: v for k, v in payload.items() if v is not None} if len(payload) == 0: diff --git a/datamint/api/endpoints/annotationsets_api.py b/datamint/api/endpoints/annotationsets_api.py index 4f469cd7..47899edc 100644 --- a/datamint/api/endpoints/annotationsets_api.py +++ b/datamint/api/endpoints/annotationsets_api.py @@ -3,9 +3,11 @@ from datamint.entities.annotation_worklist import AnnotationWorklist from typing_extensions import override import logging +import warnings if TYPE_CHECKING: from datamint.entities import Project + from datamint.entities.resource import Resource _LOGGER = logging.getLogger(__name__) @@ -28,9 +30,10 @@ def create(self, segmentation_data: dict | None = None, viewable_ai_annotations: list[str] | None = None, editable_ai_annotations: list[str] | None = None, - project_id: str | None = None, + project: 'str | Project | None' = None, return_url: str | None = None, *, + project_id: str | None = None, return_entity: Literal[True] = True, exists_ok: bool = False ) -> AnnotationWorklist: ... @@ -47,9 +50,10 @@ def create(self, segmentation_data: dict | None = None, viewable_ai_annotations: list[str] | None = None, editable_ai_annotations: list[str] | None = None, - project_id: str | None = None, + project: 'str | Project | None' = None, return_url: str | None = None, *, + project_id: str | None = None, return_entity: Literal[False], exists_ok: bool = False ) -> str: ... @@ -66,9 +70,10 @@ def create(self, segmentation_data: dict | None = None, viewable_ai_annotations: list[str] | None = None, editable_ai_annotations: list[str] | None = None, - project_id: str | None = None, + project: 'str | Project | None' = None, return_url: str | None = None, *, + project_id: str | None = None, return_entity: bool = True, exists_ok: bool = False, ) -> str | AnnotationWorklist: @@ -94,12 +99,19 @@ def create(self, - ``definitions`` (required): array of definition dicts. viewable_ai_annotations: Optional list of AI annotation identifiers to display. editable_ai_annotations: Optional list of AI annotation identifiers to allow editing. - project_id: Optional project UUID to associate with this worklist. + project: Optional project ID or Project instance to associate with this worklist. return_url: Optional URL to redirect after annotation. + project_id: (DEPRECATED) Use ``project`` instead. Returns: The ID of the created annotation set. """ + if project_id is not None: + warnings.warn("The 'project_id' parameter is deprecated. " + "Please use 'project' instead", DeprecationWarning) + if project is None: + project = project_id + payload: dict = {'name': name, 'resource_ids': resource_ids} if description is not None: payload['description'] = description @@ -117,27 +129,40 @@ def create(self, payload['viewable_ai_annotations'] = viewable_ai_annotations if editable_ai_annotations is not None: payload['editable_ai_annotations'] = editable_ai_annotations - if project_id is not None: - payload['project_id'] = project_id + if project is not None: + payload['project_id'] = self._entid(project) if return_url is not None: payload['return_url'] = return_url return self._create(payload, return_entity=return_entity, exists_ok=exists_ok) def update_segmentation_group(self, - annotation_worklist: str | AnnotationWorklist, - definitions: list[dict], + worklist_id: 'str | AnnotationWorklist | None' = None, + definitions: list[dict] | None = None, segmentation_value_type: str = 'single_label', - renames: list[str] | None = None) -> None: + renames: list[str] | None = None, + *, + annotation_worklist: 'str | AnnotationWorklist | None' = None) -> None: """Replace the segmentation-group definitions for an annotation worklist. Args: - annotation_worklist: The annotation worklist ID or AnnotationWorklist instance. + worklist_id: The annotation worklist ID or AnnotationWorklist instance. definitions: List of definition dicts with keys ``identifier``, ``color``, and ``index``. segmentation_value_type: ``'single_label'`` (default) or ``'multi_label'``. renames: Optional rename pairs (old→new identifier strings). + annotation_worklist: (DEPRECATED) Use ``worklist_id`` instead. """ + if annotation_worklist is not None: + warnings.warn("The 'annotation_worklist' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_worklist + if worklist_id is None: + raise TypeError("update_segmentation_group() missing required argument: 'worklist_id'") + if definitions is None: + raise TypeError("update_segmentation_group() missing required argument: 'definitions'") + payload: dict = { 'segmentationData': { 'segmentationValueType': segmentation_value_type, @@ -146,15 +171,30 @@ def update_segmentation_group(self, } if renames is not None: payload['renames'] = renames - self._make_entity_request('PUT', annotation_worklist, + self._make_entity_request('PUT', worklist_id, 'segmentation-group', json=payload) - def get_segmentation_group(self, annotation_set: str) -> dict: - """Get the segmentation group for a given annotation set ID.""" + def get_segmentation_group(self, + worklist_id: str | None = None, + *, + annotation_set: str | None = None) -> dict: + """Get the segmentation group for a given worklist ID. + + Args: + worklist_id: The annotation worklist ID. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("get_segmentation_group() missing required argument: 'worklist_id'") return self._make_entity_request('GET', - annotation_set, + worklist_id, 'segmentation-group').json() def get_by_project(self, project: 'str | Project') -> list[AnnotationWorklist]: @@ -184,108 +224,221 @@ def get_by_project(self, project: 'str | Project') -> list[AnnotationWorklist]: def delete_segmentation_group( self, - annotation_set: str, - identifier: str, + worklist_id: str | None = None, + identifier: str | None = None, + *, + annotation_set: str | None = None, ) -> None: """Delete a specific segmentation group from a worklist. - + Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. identifier: The segmentation group identifier to delete. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. """ - self._make_entity_request('DELETE', annotation_set, + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("delete_segmentation_group() missing required argument: 'worklist_id'") + if identifier is None: + raise TypeError("delete_segmentation_group() missing required argument: 'identifier'") + + self._make_entity_request('DELETE', worklist_id, f'segmentation-group/{identifier}') def get_annotator_status( self, - annotation_set: str, - email: str, + worklist_id: str | None = None, + annotator_email: str | None = None, + *, + annotation_set: str | None = None, + email: str | None = None, ) -> dict: """Get a specific annotator's progress status within a worklist. - + Args: - annotation_set: The annotation set ID. - email: The annotator's email address. - + worklist_id: The annotation worklist ID. + annotator_email: The annotator's email address. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + email: (DEPRECATED) Use ``annotator_email`` instead. + Returns: Dict with annotator status information. """ - return self._make_entity_request('GET', annotation_set, - f'users/{email}').json() + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if email is not None: + warnings.warn("The 'email' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = email + if worklist_id is None: + raise TypeError("get_annotator_status() missing required argument: 'worklist_id'") + if annotator_email is None: + raise TypeError("get_annotator_status() missing required argument: 'annotator_email'") + + return self._make_entity_request('GET', worklist_id, + f'users/{annotator_email}').json() def get_segmentations( self, - annotation_set: str, - resource_id: str, + worklist_id: str | None = None, + resource: 'str | Resource | None' = None, all: bool = True, + annotator_email: str | None = None, + *, + annotation_set: str | None = None, + resource_id: str | None = None, annotator: str | None = None, ) -> list[dict]: """Get all segmentations for a resource within a worklist. - + Args: - annotation_set: The annotation set ID. - resource_id: The resource ID. + worklist_id: The annotation worklist ID. + resource: The resource unique id or a Resource instance. all: Whether to get all segmentations. - annotator: Optional annotator email filter. - + annotator_email: Optional annotator email filter. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. + annotator: (DEPRECATED) Use ``annotator_email`` instead. + Returns: List of segmentation objects. """ - params: dict[str, bool | str] = {'all': all} + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id if annotator is not None: - params['annotator'] = annotator - return self._make_entity_request('GET', annotation_set, - f'resources/{resource_id}/segmentations', + warnings.warn("The 'annotator' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = annotator + if worklist_id is None: + raise TypeError("get_segmentations() missing required argument: 'worklist_id'") + if resource is None: + raise TypeError("get_segmentations() missing required argument: 'resource'") + resource_id_str = self._entid(resource) + + params: dict[str, bool | str] = {'all': all} + if annotator_email is not None: + params['annotator'] = annotator_email + return self._make_entity_request('GET', worklist_id, + f'resources/{resource_id_str}/segmentations', params=params).json() def get_annotations( self, - annotation_set: str, - resource_id: str, + worklist_id: str | None = None, + resource: 'str | Resource | None' = None, all: bool = True, + annotator_email: str | None = None, + *, + annotation_set: str | None = None, + resource_id: str | None = None, annotator: str | None = None, ) -> list[dict]: """Get all annotations (non-segmentation types) for a resource within a worklist. - + Args: - annotation_set: The annotation set ID. - resource_id: The resource ID. + worklist_id: The annotation worklist ID. + resource: The resource unique id or a Resource instance. all: Whether to get all annotations. - annotator: Optional annotator email filter. - + annotator_email: Optional annotator email filter. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. + annotator: (DEPRECATED) Use ``annotator_email`` instead. + Returns: List of annotation objects. """ - params: dict[str, bool | str] = {'all': all} + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id if annotator is not None: - params['annotator'] = annotator - return self._make_entity_request('GET', annotation_set, - f'resources/{resource_id}/annotations', + warnings.warn("The 'annotator' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = annotator + if worklist_id is None: + raise TypeError("get_annotations() missing required argument: 'worklist_id'") + if resource is None: + raise TypeError("get_annotations() missing required argument: 'resource'") + resource_id_str = self._entid(resource) + + params: dict[str, bool | str] = {'all': all} + if annotator_email is not None: + params['annotator'] = annotator_email + return self._make_entity_request('GET', worklist_id, + f'resources/{resource_id_str}/annotations', params=params).json() def get_ai_segmentations( self, - annotation_set: str, - resource_id: str, + worklist_id: str | None = None, + resource: 'str | Resource | None' = None, + *, + annotation_set: str | None = None, + resource_id: str | None = None, ) -> list[dict]: """Get AI-generated segmentations for a resource within a worklist. - + Args: - annotation_set: The annotation set ID. - resource_id: The resource ID. - + worklist_id: The annotation worklist ID. + resource: The resource unique id or a Resource instance. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. + Returns: List of AI segmentation objects. """ - return self._make_entity_request('GET', annotation_set, - f'resources/{resource_id}/ai_segmentations').json() + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id + if worklist_id is None: + raise TypeError("get_ai_segmentations() missing required argument: 'worklist_id'") + if resource is None: + raise TypeError("get_ai_segmentations() missing required argument: 'resource'") + resource_id_str = self._entid(resource) + + return self._make_entity_request('GET', worklist_id, + f'resources/{resource_id_str}/ai_segmentations').json() def upload_annotations( self, - annotation_set: str, - resource_id: str, - payload: str, + worklist_id: str | None = None, + resource: 'str | Resource | None' = None, + payload: str | None = None, images: list[Any] | None = None, + *, + annotation_set: str | None = None, + resource_id: str | None = None, ) -> dict: """Upload one or more resource segmentations. @@ -303,14 +456,34 @@ def upload_annotations( - ``geometry``: any value, optional. Args: - annotation_set: The annotation set ID. - resource_id: The resource ID. + worklist_id: The annotation worklist ID. + resource: The resource unique id or a Resource instance. payload: A JSON string containing an array of segmentation items. images: Optional list of file-like objects or file paths to upload. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. Returns: Response dict with created annotation info. """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id + if worklist_id is None: + raise TypeError("upload_annotations() missing required argument: 'worklist_id'") + if resource is None: + raise TypeError("upload_annotations() missing required argument: 'resource'") + if payload is None: + raise TypeError("upload_annotations() missing required argument: 'payload'") + resource_id_str = self._entid(resource) + if images is not None: files = {} opened_files: list = [] @@ -323,213 +496,375 @@ def upload_annotations( f = open(img, 'rb') opened_files.append(f) files[f'images'] = (img, f) - return self._make_entity_request('POST', annotation_set, - f'resources/{resource_id}/segmentations', + return self._make_entity_request('POST', worklist_id, + f'resources/{resource_id_str}/segmentations', files=files, data={'payload': payload}).json() finally: for f in opened_files: f.close() else: - return self._make_entity_request('POST', annotation_set, - f'resources/{resource_id}/segmentations', + return self._make_entity_request('POST', worklist_id, + f'resources/{resource_id_str}/segmentations', data={'payload': payload}).json() def update_annotation_status( self, - annotation_set: str, - resource_id: str, + worklist_id: str | None = None, + resource: 'str | Resource | None' = None, status: Literal['opened', 'annotated', 'closed', 'approved', 'revision_request'] = 'closed', - annotator: str | None = None, + annotator_email: str | None = None, message: str | None = None, path: str | None = None, + *, + annotation_set: str | None = None, + resource_id: str | None = None, + annotator: str | None = None, ) -> list[dict]: """Update the annotation status for a resource within a worklist. - + Args: - annotation_set: The annotation set ID. - resource_id: The resource ID. + worklist_id: The annotation worklist ID. + resource: The resource unique id or a Resource instance. status: New status (opened, annotated, closed, approved, revision_request). - annotator: Optional annotator email. + annotator_email: Optional annotator email. message: Optional message. path: Optional path. - + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. + annotator: (DEPRECATED) Use ``annotator_email`` instead. + Returns: List of objects with updated status information. """ - payload = {'status': status} + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id if annotator is not None: - payload['annotator'] = annotator + warnings.warn("The 'annotator' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = annotator + if worklist_id is None: + raise TypeError("update_annotation_status() missing required argument: 'worklist_id'") + if resource is None: + raise TypeError("update_annotation_status() missing required argument: 'resource'") + resource_id_str = self._entid(resource) + + payload = {'status': status} + if annotator_email is not None: + payload['annotator'] = annotator_email if message is not None: payload['message'] = message if path is not None: payload['path'] = path - return self._make_entity_request('POST', annotation_set, - f'resources/{resource_id}/status', + return self._make_entity_request('POST', worklist_id, + f'resources/{resource_id_str}/status', json=payload).json() def set_annotator( self, - annotation_set: str, - user_id: str, - status: Literal['active', 'frozen'], - expertise_level: Literal['learner', 'trained', 'expert'], + worklist_id: str | None = None, + user_id: str | None = None, + status: Literal['active', 'frozen'] | None = None, + expertise_level: Literal['learner', 'trained', 'expert'] | None = None, return_url: str | None = None, + *, + annotation_set: str | None = None, ) -> dict: """Set or update an annotator's status and expertise level in a worklist. - + Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. user_id: The user's UUID. status: Annotator status (active or frozen). expertise_level: Expertise level (learner, trained, or expert). return_url: Optional return URL. - + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + Returns: Response dict with created annotator info. """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("set_annotator() missing required argument: 'worklist_id'") + if user_id is None: + raise TypeError("set_annotator() missing required argument: 'user_id'") + if status is None: + raise TypeError("set_annotator() missing required argument: 'status'") + if expertise_level is None: + raise TypeError("set_annotator() missing required argument: 'expertise_level'") + payload = { 'status': status, 'expertise_level': expertise_level, } if return_url is not None: payload['return_url'] = return_url - return self._make_entity_request('POST', annotation_set, + return self._make_entity_request('POST', worklist_id, f'annotators/{user_id}', json=payload).json() def remove_annotator( self, - annotation_set: str, - user_id: str, + worklist_id: str | None = None, + user_id: str | None = None, + *, + annotation_set: str | None = None, ) -> None: """Remove an annotator from a worklist. - + Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. user_id: The user's UUID. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("remove_annotator() missing required argument: 'worklist_id'") + if user_id is None: + raise TypeError("remove_annotator() missing required argument: 'user_id'") + self._make_request('DELETE', - f'/annotationsets/{annotation_set}/annotators/{user_id}') + f'/annotationsets/{worklist_id}/annotators/{user_id}') def update_resources( self, - annotation_set: str, + worklist_id: str | None = None, + resource_ids_to_add: list[str] | None = None, + resource_ids_to_delete: list[str] | None = None, + *, + annotation_set: str | None = None, resources_to_add: list[str] | None = None, resources_to_delete: list[str] | None = None, ) -> dict: """Add or remove resources from a worklist. - + Args: - annotation_set: The annotation set ID. - resources_to_add: Optional list of resource IDs to add. - resources_to_delete: Optional list of resource IDs to delete. - + worklist_id: The annotation worklist ID. + resource_ids_to_add: Optional list of resource IDs to add. + resource_ids_to_delete: Optional list of resource IDs to delete. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resources_to_add: (DEPRECATED) Use ``resource_ids_to_add`` instead. + resources_to_delete: (DEPRECATED) Use ``resource_ids_to_delete`` instead. + Returns: Response dict with updated worklist info. """ - payload: dict = {} + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set if resources_to_add is not None: - payload['resource_ids_to_add'] = resources_to_add + warnings.warn("The 'resources_to_add' parameter is deprecated. " + "Please use 'resource_ids_to_add' instead", DeprecationWarning) + if resource_ids_to_add is None: + resource_ids_to_add = resources_to_add if resources_to_delete is not None: - payload['resource_ids_to_delete'] = resources_to_delete - return self._make_entity_request('POST', annotation_set, 'resources', + warnings.warn("The 'resources_to_delete' parameter is deprecated. " + "Please use 'resource_ids_to_delete' instead", DeprecationWarning) + if resource_ids_to_delete is None: + resource_ids_to_delete = resources_to_delete + if worklist_id is None: + raise TypeError("update_resources() missing required argument: 'worklist_id'") + + payload: dict = {} + if resource_ids_to_add is not None: + payload['resource_ids_to_add'] = resource_ids_to_add + if resource_ids_to_delete is not None: + payload['resource_ids_to_delete'] = resource_ids_to_delete + return self._make_entity_request('POST', worklist_id, 'resources', json=payload).json() def get_annotation_statuses( self, - annotation_set: str, + worklist_id: str | None = None, status: str | None = None, user_id: str | None = None, + resource: 'str | Resource | None' = None, + *, + annotation_set: str | None = None, resource_id: str | None = None, ) -> list[dict]: """Get annotation statuses for a worklist. Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. status: Optional status filter. Allowed values: ``opened``, ``annotated``, ``closed``, ``approved``, ``revision_request``. user_id: Optional user ID filter. - resource_id: Optional resource ID filter (UUID v4). + resource: Optional resource unique id, or Resource instance, filter. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. Returns: List of annotation status dicts. """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id + if worklist_id is None: + raise TypeError("get_annotation_statuses() missing required argument: 'worklist_id'") + params: dict[str, str | list[str]] = {} if status is not None: params['status'] = status if user_id is not None: params['user_id'] = user_id - if resource_id is not None: - params['resource_id'] = resource_id - return self._make_entity_request('GET', annotation_set, + if resource is not None: + params['resource_id'] = self._entid(resource) + return self._make_entity_request('GET', worklist_id, 'annotation-statuses', params=params or None).json() def reset_annotator_status( self, - annotation_set: str, - resource_id: str, - annotator: str, + worklist_id: str | None = None, + resource: 'str | Resource | None' = None, + annotator_email: str | None = None, + *, + annotation_set: str | None = None, + resource_id: str | None = None, + annotator: str | None = None, ) -> None: """Reset one annotator's resource status and delete related annotations. Args: - annotation_set: The annotation set ID. - resource_id: The resource ID. - annotator: The annotator identifier. + worklist_id: The annotation worklist ID. + resource: The resource unique id or a Resource instance. + annotator_email: The annotator's email address. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. + annotator: (DEPRECATED) Use ``annotator_email`` instead. """ - self._make_entity_request('DELETE', annotation_set, - f'resources/{resource_id}/annotator/{annotator}/status-reset') + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id + if annotator is not None: + warnings.warn("The 'annotator' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = annotator + if worklist_id is None: + raise TypeError("reset_annotator_status() missing required argument: 'worklist_id'") + if resource is None: + raise TypeError("reset_annotator_status() missing required argument: 'resource'") + if annotator_email is None: + raise TypeError("reset_annotator_status() missing required argument: 'annotator_email'") + resource_id_str = self._entid(resource) + + self._make_entity_request('DELETE', worklist_id, + f'resources/{resource_id_str}/annotator/{annotator_email}/status-reset') def get_annotators_statistics( self, - annotation_set: str, + worklist_id: str | None = None, + annotator_email: str | None = None, + *, + annotation_set: str | None = None, email: str | None = None, ) -> list[dict]: """Get annotator statistics for a worklist. Args: - annotation_set: The annotation set ID. - email: Optional annotator email filter. + worklist_id: The annotation worklist ID. + annotator_email: Optional annotator email filter. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. + email: (DEPRECATED) Use ``annotator_email`` instead. Returns: List of per-annotator stat dicts. """ - params: dict[str, str] | None = None + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set if email is not None: - params = {'email': email} - return self._make_entity_request('GET', annotation_set, + warnings.warn("The 'email' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = email + if worklist_id is None: + raise TypeError("get_annotators_statistics() missing required argument: 'worklist_id'") + + params: dict[str, str] | None = None + if annotator_email is not None: + params = {'email': annotator_email} + return self._make_entity_request('GET', worklist_id, 'annotators-statistic', params=params).json() def get_annotations_statistics( self, - annotation_set: str, + worklist_id: str | None = None, + *, + annotation_set: str | None = None, ) -> list[dict]: """Get annotation statistics for a worklist. Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. Returns: List of annotation stat objects. """ - return self._make_entity_request('GET', annotation_set, + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("get_annotations_statistics() missing required argument: 'worklist_id'") + + return self._make_entity_request('GET', worklist_id, 'annotations-statistic').json() def download_annotations( self, - annotation_set: str, + worklist_id: str | None = None, from_date: str | None = None, to_date: str | None = None, annotators: list[str] | None = None, annotations: list[str] | None = None, format: str = 'csv', + *, + annotation_set: str | None = None, ) -> bytes: """Download annotations as a streamed file. Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. from_date: Optional start date filter (ISO string). to_date: Optional end date filter (ISO string). annotators: Optional list of annotator emails. Accepts either a repeated/array @@ -537,10 +872,19 @@ def download_annotations( annotations: Optional list of annotation identifiers. Accepts either a repeated/array input or a comma-separated string. format: Export format. Allowed values: ``csv`` (default), ``excel``. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. Returns: The raw bytes of the downloaded file. """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("download_annotations() missing required argument: 'worklist_id'") + params: dict[str, str | list[str]] = {'format': format} if from_date is not None: params['from'] = from_date @@ -550,39 +894,51 @@ def download_annotations( params['annotators[]'] = annotators if annotations is not None: params['annotations[]'] = annotations - response = self._make_entity_request('GET', annotation_set, + response = self._make_entity_request('GET', worklist_id, 'download-annotations', params=params or None) return response.content def upload_segmentation_group( self, - annotation_set: str, - file: Any, + worklist_id: str | None = None, + file: Any = None, replace_existing: bool = True, + *, + annotation_set: str | None = None, ) -> dict: """Upload a segmentation group definition file to an annotation set. Args: - annotation_set: The annotation set ID. + worklist_id: The annotation worklist ID. file: The file to upload. Must have one of these extensions: ``.yaml``, ``.yml``, ``.csv``, ``.json``. Maximum size: 10 MB. replace_existing: Whether to replace existing segmentation data. + annotation_set: (DEPRECATED) Use ``worklist_id`` instead. Returns: Response dict with created segmentation group info. """ + if annotation_set is not None: + warnings.warn("The 'annotation_set' parameter is deprecated. " + "Please use 'worklist_id' instead", DeprecationWarning) + if worklist_id is None: + worklist_id = annotation_set + if worklist_id is None: + raise TypeError("upload_segmentation_group() missing required argument: 'worklist_id'") + if file is None: + raise TypeError("upload_segmentation_group() missing required argument: 'file'") + # Use multipart form data for file upload - import io if hasattr(file, 'read'): file_data = file.read() filename = getattr(file, 'name', 'segmentation.yaml') else: file_data = file filename = getattr(file, 'name', 'segmentation.yaml') - + files = {'file': (filename, file_data)} data = {'replace_existing': str(replace_existing).lower()} - return self._make_entity_request('POST', annotation_set, + return self._make_entity_request('POST', worklist_id, 'segmentation-group/upload', files=files, data=data).json() diff --git a/datamint/api/endpoints/datasetsinfo_api.py b/datamint/api/endpoints/datasetsinfo_api.py index fd2dff22..a4a4f67c 100644 --- a/datamint/api/endpoints/datasetsinfo_api.py +++ b/datamint/api/endpoints/datasetsinfo_api.py @@ -1,10 +1,14 @@ -from typing import Literal +from typing import Literal, TYPE_CHECKING from pathlib import Path from ..entity_base_api import ApiConfig, DeletableEntityApi from datamint.entities.datasetinfo import DatasetInfo import httpx from tqdm.auto import tqdm +import warnings + +if TYPE_CHECKING: + from datamint.entities import Project class DatasetsInfoApi(DeletableEntityApi[DatasetInfo]): @@ -42,6 +46,8 @@ def update_resources(self, dataset: str | DatasetInfo, resource_ids_to_add: list[str] | None = None, resource_ids_to_delete: list[str] | None = None, + project: 'str | Project | None' = None, + *, project_id: str | None = None) -> None: """Add or remove resources from a dataset. @@ -49,15 +55,22 @@ def update_resources(self, dataset: The dataset ID or DatasetInfo instance. resource_ids_to_add: List of resource IDs to add. resource_ids_to_delete: List of resource IDs to remove. - project_id: Optional project ID context. + project: Optional project ID or Project instance context. + project_id: (DEPRECATED) Use ``project`` instead. """ + if project_id is not None: + warnings.warn("The 'project_id' parameter is deprecated. " + "Please use 'project' instead", DeprecationWarning) + if project is None: + project = project_id + payload: dict = {'all_files_selected': False} if resource_ids_to_add is not None: payload['resource_ids_to_add'] = resource_ids_to_add if resource_ids_to_delete is not None: payload['resource_ids_to_delete'] = resource_ids_to_delete - if project_id is not None: - payload['project_id'] = project_id + if project is not None: + payload['project_id'] = self._entid(project) dataset_id = self._entid(dataset) self._make_entity_request('POST', dataset_id, add_path='resources', json=payload) diff --git a/datamint/api/endpoints/deploy_model_api.py b/datamint/api/endpoints/deploy_model_api.py index 429f9573..145d1bd3 100644 --- a/datamint/api/endpoints/deploy_model_api.py +++ b/datamint/api/endpoints/deploy_model_api.py @@ -6,6 +6,7 @@ import time import httpx +import warnings from datamint.exceptions import ResourceNotFoundError, JobTimeoutError from ..entity_base_api import EntityBaseApi, ApiConfig @@ -37,19 +38,32 @@ def get_by_id(self, entity_id: str) -> DeployJob: e.params = {'id': entity_id} raise - def stream_status(self, job_id: str) -> Generator[dict[str, Any], None, None]: + def stream_status(self, + job: str | DeployJob | None = None, + *, + job_id: str | None = None) -> Generator[dict[str, Any], None, None]: """Stream status updates for a deployment job via Server-Sent Events. Yields dictionaries parsed from SSE ``data:`` lines until the stream is closed by the server. Args: - job_id: The job identifier. + job: The job ID string or ``DeployJob`` instance. + job_id: (DEPRECATED) Use ``job`` instead. Yields: Parsed JSON dictionaries for each SSE event. """ - with self._stream_request('GET', f'/{self.endpoint_base}/status/{job_id}/stream') as resp: + if job_id is not None: + warnings.warn("The 'job_id' parameter is deprecated. " + "Please use 'job' instead", DeprecationWarning) + if job is None: + job = job_id + if job is None: + raise TypeError("stream_status() missing required argument: 'job'") + job_id_str = self._entid(job) + + with self._stream_request('GET', f'/{self.endpoint_base}/status/{job_id_str}/stream') as resp: for line in resp.iter_lines(): if line.startswith('data:'): payload = line[len('data:'):].strip() diff --git a/datamint/api/endpoints/inference_api.py b/datamint/api/endpoints/inference_api.py index 675d4de5..5367ccd3 100644 --- a/datamint/api/endpoints/inference_api.py +++ b/datamint/api/endpoints/inference_api.py @@ -6,6 +6,7 @@ import time import httpx +import warnings from ..entity_base_api import EntityBaseApi, ApiConfig from datamint.entities.inferencejob import InferenceJob @@ -120,35 +121,61 @@ def submit( # Status / cancel # ------------------------------------------------------------------ - def get_status(self, job_id: str) -> InferenceJob: + def get_status(self, + job: str | InferenceJob | None = None, + *, + job_id: str | None = None) -> InferenceJob: """Get the current status of an inference job. Args: - job_id: The job identifier. + job: The job ID string or ``InferenceJob`` instance. + job_id: (DEPRECATED) Use ``job`` instead. Returns: An ``InferenceJob`` populated with the latest status. """ - response = self._make_request('GET', f'/{self.endpoint_base}/status/{job_id}') + if job_id is not None: + warnings.warn("The 'job_id' parameter is deprecated. " + "Please use 'job' instead", DeprecationWarning) + if job is None: + job = job_id + if job is None: + raise TypeError("get_status() missing required argument: 'job'") + job_id_str = self._entid(job) + + response = self._make_request('GET', f'/{self.endpoint_base}/status/{job_id_str}') return self._parse_job_response(response.json()) def get_by_id(self, entity_id: str) -> InferenceJob: """Alias for ``get_status`` to satisfy ``EntityBaseApi`` interface.""" return self.get_status(entity_id) - def stream_status(self, job_id: str) -> Generator[dict[str, Any], None, None]: + def stream_status(self, + job: str | InferenceJob | None = None, + *, + job_id: str | None = None) -> Generator[dict[str, Any], None, None]: """Stream status updates for an inference job via Server-Sent Events. Yields dictionaries parsed from SSE ``data:`` lines until the stream is closed by the server. Args: - job_id: The job identifier. + job: The job ID string or ``InferenceJob`` instance. + job_id: (DEPRECATED) Use ``job`` instead. Yields: Parsed JSON dictionaries for each SSE event. """ - with self._stream_request('GET', f'/{self.endpoint_base}/status/{job_id}/stream') as resp: + if job_id is not None: + warnings.warn("The 'job_id' parameter is deprecated. " + "Please use 'job' instead", DeprecationWarning) + if job is None: + job = job_id + if job is None: + raise TypeError("stream_status() missing required argument: 'job'") + job_id_str = self._entid(job) + + with self._stream_request('GET', f'/{self.endpoint_base}/status/{job_id_str}/stream') as resp: for line in resp.iter_lines(): if line.startswith('data:'): payload = line[len('data:'):].strip() diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index 1b036a6d..f30c0317 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -1,6 +1,7 @@ from typing import Literal, TYPE_CHECKING, overload from collections.abc import Sequence from pathlib import Path +import warnings from ..entity_base_api import ApiConfig, CRUDEntityApi from datamint.entities.project import Project @@ -53,11 +54,12 @@ def get_project_resources(self, project: Project | str | None = None) -> list[Re def create(self, name: str, description: str, - resources_ids: list[str] | None = None, + resource_ids: list[str] | None = None, is_active_learning: bool = False, two_up_display: bool = False, segmentation_spec: Literal['single_label', 'multi_label'] = 'single_label', *, + resources_ids: list[str] | None = None, return_entity: Literal[True] = True, exists_ok: bool = False ) -> Project: ... @@ -66,11 +68,12 @@ def create(self, def create(self, name: str, description: str, - resources_ids: list[str] | None = None, + resource_ids: list[str] | None = None, is_active_learning: bool = False, two_up_display: bool = False, segmentation_spec: Literal['single_label', 'multi_label'] = 'single_label', *, + resources_ids: list[str] | None = None, return_entity: Literal[False], exists_ok: bool = False ) -> str: ... @@ -78,11 +81,12 @@ def create(self, def create(self, name: str, description: str, - resources_ids: list[str] | None = None, + resource_ids: list[str] | None = None, is_active_learning: bool = False, two_up_display: bool = False, segmentation_spec: Literal['single_label', 'multi_label'] = 'single_label', *, + resources_ids: list[str] | None = None, return_entity: bool = True, exists_ok: bool = False ) -> str | Project: @@ -91,31 +95,38 @@ def create(self, 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. + resource_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. return_entity: Whether to return the created Project instance or just its ID. exists_ok: If ``True``, do not raise an error when a project with the same name already exists. Instead, the existing project is returned when possible. + resources_ids: (DEPRECATED) Use ``resource_ids`` instead. Returns: The id of the created project. """ + if resources_ids is not None: + warnings.warn("The 'resources_ids' parameter is deprecated. " + "Please use 'resource_ids' instead", DeprecationWarning) + if resource_ids is None: + resource_ids = resources_ids + proj = self.get_by_name(name, include_archived=True) if proj is not None: if exists_ok: return proj if return_entity else proj.id else: raise EntityAlreadyExistsError(entity_type='Project', params={'name': name}) - resources_ids = resources_ids or [] + resource_ids = resource_ids or [] project_data = {'name': name, 'is_active_learning': is_active_learning, - 'resource_ids': resources_ids, + 'resource_ids': resource_ids, "segmentationData": {"segmentationValueType": segmentation_spec, "definitions": []}, 'annotation_set': { - "resource_ids": resources_ids, + "resource_ids": resource_ids, "annotations": [], }, "two_up_display": two_up_display, @@ -373,6 +384,8 @@ def get_annotation_statuses(self, project: str | Project | None = None, status: str | None = None, user_id: str | None = None, + resource: str | Resource | None = None, + *, resource_id: str | None = None) -> list[dict]: """Get per-resource annotation statuses for a project. @@ -381,14 +394,22 @@ def get_annotation_statuses(self, session's default project (see `datamint.select_project()`) when omitted. status: Optional status filter. user_id: Optional user ID filter. - resource_id: Optional resource ID filter. + resource: Optional resource unique id, or Resource instance, filter. + resource_id: (DEPRECATED) Use ``resource`` instead. Returns: List of annotation status dicts. """ + if resource_id is not None: + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id + project = self._resolve_project_or_default(project) params = {k: v for k, v in {'status': status, 'user_id': user_id, - 'resource_id': resource_id}.items() if v is not None} + 'resource_id': self._entid(resource) if resource is not None else None + }.items() if v is not None} response = self._make_entity_request('GET', project, add_path='annotation-statuses', params=params or None) return response.json() @@ -483,21 +504,32 @@ def get_resource_split( def reset_annotator_status(self, resource: str | Resource, - annotator: str, - project: str | Project | None = None) -> None: + annotator_email: str | None = None, + project: str | Project | None = None, + *, + annotator: str | None = None) -> None: """Reset annotation status for a specific annotator on a resource. Args: resource: The resource ID or Resource instance. - annotator: The annotator's email address. + annotator_email: The annotator's email address. project: The project ID or Project instance. Falls back to the session's default project (see `datamint.select_project()`) when omitted. + annotator: (DEPRECATED) Use ``annotator_email`` instead. """ + if annotator is not None: + warnings.warn("The 'annotator' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = annotator + if annotator_email is None: + raise TypeError("reset_annotator_status() missing required argument: 'annotator_email'") + proj_id = self._entid(self._resolve_project_or_default(project)) resource_id = self._entid(resource) self._make_request('DELETE', f'/{self.endpoint_base}/{proj_id}/resources/{resource_id}' - f'/annotator/{annotator}/status-reset') + f'/annotator/{annotator_email}/status-reset') # ------------------------------------------------------------------ # Download annotations @@ -556,19 +588,28 @@ def download_annotations(self, def get_annotators_stats(self, project: str | Project | None = None, + annotator_email: str | None = None, + *, email: str | None = None) -> list[dict]: """Get per-annotator completion statistics for a project. Args: project: The project ID or Project instance. Falls back to the session's default project (see `datamint.select_project()`) when omitted. - email: Optional annotator email to filter results. + annotator_email: Optional annotator email to filter results. + email: (DEPRECATED) Use ``annotator_email`` instead. Returns: List of per-annotator stat dicts. """ + if email is not None: + warnings.warn("The 'email' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = email + project = self._resolve_project_or_default(project) - params = {'email': email} if email is not None else None + params = {'email': annotator_email} if annotator_email is not None else None response = self._make_entity_request('GET', project, add_path='annotators-statistic', params=params) return response.json() @@ -601,20 +642,33 @@ def get_files_matrix_stats(self, project: str | Project | None = None) -> dict: response = self._make_entity_request('GET', project, add_path='files-matrix-statistic') return response.json() - def get_annotator_status(self, email: str, project: str | Project | None = None) -> dict: + def get_annotator_status(self, + annotator_email: str | None = None, + project: str | Project | None = None, + *, + email: str | None = None) -> dict: """Get a specific annotator's progress status in a project. Args: - email: The annotator's email address. + annotator_email: The annotator's email address. project: The project ID or Project instance. Falls back to the session's default project (see `datamint.select_project()`) when omitted. + email: (DEPRECATED) Use ``annotator_email`` instead. Returns: Annotator status dict. """ + if email is not None: + warnings.warn("The 'email' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = email + if annotator_email is None: + raise TypeError("get_annotator_status() missing required argument: 'annotator_email'") + proj_id = self._entid(self._resolve_project_or_default(project)) response = self._make_request('GET', - f'/{self.endpoint_base}/{proj_id}/users/{email}/status') + f'/{self.endpoint_base}/{proj_id}/users/{annotator_email}/status') return response.json() # ------------------------------------------------------------------ @@ -623,27 +677,43 @@ def get_annotator_status(self, email: str, project: str | Project | None = None) def get_review_messages(self, project: str | Project | None = None, + annotator_email: str | None = None, + resource: str | Resource | None = None, + statuses: list[str] | None = None, + *, annotator: str | None = None, - resource_id: str | None = None, - statuses: list[str] | None = None) -> list[dict]: + resource_id: str | None = None) -> list[dict]: """Get review feedback messages for a project. Args: project: The project ID or Project instance. Falls back to the session's default project (see `datamint.select_project()`) when omitted. - annotator: Optional annotator email filter. - resource_id: Optional resource ID filter. + annotator_email: Optional annotator email filter. + resource: Optional resource unique id, or Resource instance, filter. statuses: Optional list of status strings to filter by. + annotator: (DEPRECATED) Use ``annotator_email`` instead. + resource_id: (DEPRECATED) Use ``resource`` instead. Returns: List of review message dicts. """ - project = self._resolve_project_or_default(project) - params: dict = {} if annotator is not None: - params['annotator'] = annotator + warnings.warn("The 'annotator' parameter is deprecated. " + "Please use 'annotator_email' instead", DeprecationWarning) + if annotator_email is None: + annotator_email = annotator if resource_id is not None: - params['resourceId'] = resource_id + warnings.warn("The 'resource_id' parameter is deprecated. " + "Please use 'resource' instead", DeprecationWarning) + if resource is None: + resource = resource_id + + project = self._resolve_project_or_default(project) + params: dict = {} + if annotator_email is not None: + params['annotator'] = annotator_email + if resource is not None: + params['resourceId'] = self._entid(resource) if statuses is not None: params['statuses'] = statuses response = self._make_entity_request('GET', project, add_path='reviewmessages', diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 222255e5..e90e8148 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -22,6 +22,7 @@ from tqdm.auto import tqdm import asyncio import aiohttp +import warnings from pathlib import Path from PIL import Image import io @@ -459,7 +460,7 @@ async def _upload_resources_async(self, publish: bool = False, segmentation_files: Sequence[dict] | None = None, transpose_segmentation: bool = False, - ai_model: str | None = None, + model_name: str | None = None, metadata_files: Sequence[str | dict | None] | None = None, progress_bar: tqdm | None = None, session: aiohttp.ClientSession | None = None, @@ -532,7 +533,7 @@ async def __upload_single_resource(all_files_path, index: int, name=name, frame_index=frame_index, transpose_segmentation=transpose_segmentation, - model_id=ai_model, + model_id=model_name, source='imported', session=session, ) @@ -693,12 +694,14 @@ def upload_resources(self, publish_to: Project | str | None = None, segmentation_files: Sequence[Sequence[str] | dict] | None = None, transpose_segmentation: bool = False, - ai_model: str | None = None, + model_name: str | None = None, modality: str | None = None, assemble_dicoms: bool = True, metadata: Sequence[str | dict | None] | None = None, discard_dicom_reports: bool = True, - progress_bar: bool = False + progress_bar: bool = False, + *, + ai_model: str | None = None, ) -> Sequence[str | Exception]: """ Upload multiple resources. @@ -725,9 +728,10 @@ def upload_resources(self, - 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. - ai_model (Optional[str]): The name of the AI model to associate with uploaded segmentations. + model_name (Optional[str]): The name of the AI model to associate with uploaded segmentations. Must match an existing deployed model name on the server. modality (Optional[str]): The modality of the resources. + ai_model (Optional[str]): (DEPRECATED) Use ``model_name`` instead. assemble_dicoms (bool): Whether to assemble the dicom files or not based on the SeriesInstanceUID and InstanceNumber attributes. metadata (Optional[list[str | dict | None]]): JSON metadata to include with each resource. Must have the same length as `files_path`. @@ -741,6 +745,12 @@ def upload_resources(self, list[str | Exception]: A list of resource IDs or errors. """ + if ai_model is not None: + warnings.warn("The 'ai_model' parameter is deprecated. " + "Please use 'model_name' instead", DeprecationWarning) + if model_name is None: + model_name = ai_model + self._validate_upload_params(on_error, files_path) proj = self._resolve_project(publish_to) @@ -782,7 +792,7 @@ def upload_resources(self, publish=publish, segmentation_files=normalized_seg_files, transpose_segmentation=transpose_segmentation, - ai_model=ai_model, + model_name=model_name, modality=modality, metadata_files=metadata, ) diff --git a/datamint/api/endpoints/users_api.py b/datamint/api/endpoints/users_api.py index f88f5974..e31a37af 100644 --- a/datamint/api/endpoints/users_api.py +++ b/datamint/api/endpoints/users_api.py @@ -1,8 +1,12 @@ -from typing import Literal, cast, overload +from typing import Literal, TYPE_CHECKING, cast, overload from ..entity_base_api import CreatableEntityApi, ApiConfig from datamint.entities import User import httpx +import warnings + +if TYPE_CHECKING: + from datamint.entities import Project class UsersApi(CreatableEntityApi[User]): @@ -92,9 +96,11 @@ def invite(self, firstname: str | None = None, lastname: str | None = None, return_url: str | None = None, - project_id: str | None = None, + project: 'str | Project | None' = None, project_roles: list[str] | None = None, annotation_worklist_id: str | None = None, + *, + project_id: str | None = None, ) -> dict: """Send an invitation email to a new user. @@ -103,13 +109,20 @@ def invite(self, firstname: The invitee's first name. lastname: The invitee's last name. return_url: URL the invite link should redirect to after acceptance. - project_id: Optional project to add the invitee to. + project: Optional project ID or Project instance to add the invitee to. project_roles: Roles to assign in the given project. annotation_worklist_id: Optional annotation worklist to associate. + project_id: (DEPRECATED) Use ``project`` instead. Returns: The server response as a dict. """ + if project_id is not None: + warnings.warn("The 'project_id' parameter is deprecated. " + "Please use 'project' instead", DeprecationWarning) + if project is None: + project = project_id + payload: dict = {'email': email} if firstname is not None: payload['firstname'] = firstname @@ -117,8 +130,8 @@ def invite(self, payload['lastname'] = lastname if return_url is not None: payload['return_url'] = return_url - if project_id is not None: - payload['project_id'] = project_id + if project is not None: + payload['project_id'] = self._entid(project) if project_roles is not None: payload['project_roles'] = project_roles if annotation_worklist_id is not None: @@ -156,18 +169,28 @@ def delete_user(self, email: str) -> None: """ self._make_entity_request('DELETE', email) - def get_invitations(self, project_id: str | None = None) -> list[dict]: + def get_invitations(self, + project: 'str | Project | None' = None, + *, + project_id: str | None = None) -> list[dict]: """List pending user invitations. Args: - project_id: Optional project ID to filter invitations. + project: Optional project ID or Project instance to filter invitations. + project_id: (DEPRECATED) Use ``project`` instead. Returns: List of invitation dicts. """ - params: dict = {} if project_id is not None: - params['project_id'] = project_id + warnings.warn("The 'project_id' parameter is deprecated. " + "Please use 'project' instead", DeprecationWarning) + if project is None: + project = project_id + + params: dict = {} + if project is not None: + params['project_id'] = self._entid(project) response = self._make_request('GET', f'/{self.endpoint_base}/invitations', params=params or None) return response.json() diff --git a/datamint/client_cmd_tools/datamint_upload.py b/datamint/client_cmd_tools/datamint_upload.py index 80380ceb..779bcddb 100644 --- a/datamint/client_cmd_tools/datamint_upload.py +++ b/datamint/client_cmd_tools/datamint_upload.py @@ -824,7 +824,7 @@ def main(): publish_to=args.project, segmentation_files=segfiles, transpose_segmentation=args.transpose_segmentation, - ai_model=args.ai_model, + model_name=args.ai_model, assemble_dicoms=args.assemble_dicoms, metadata=metadata_files, progress_bar=True diff --git a/datamint/dataset/factory.py b/datamint/dataset/factory.py index ce15c38c..830a15f2 100644 --- a/datamint/dataset/factory.py +++ b/datamint/dataset/factory.py @@ -1,10 +1,12 @@ from __future__ import annotations import logging +import warnings from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from datamint.entities.resource import Resource + from datamint.entities import Project from .base import DatamintBaseDataset _LOGGER = logging.getLogger(__name__) @@ -25,7 +27,10 @@ def _classify_resource(resource: 'Resource') -> str: return getattr(resource, 'kind', 'unknown') -def build_dataset(project_name: str, **kwargs: Any) -> 'DatamintBaseDataset': +def build_dataset(project: str | Project | None = None, + *, + project_name: str | None = None, + **kwargs: Any) -> 'DatamintBaseDataset': """Auto-detect and return the appropriate dataset class for a project. Fetches a small sample of resources from the project to determine the @@ -36,7 +41,8 @@ def build_dataset(project_name: str, **kwargs: Any) -> 'DatamintBaseDataset': - Videos → :class:`~datamint.dataset.VideoDataset` Args: - project_name: Name of the Datamint project. + project: Name, ID, or ``Project`` instance of the Datamint project. + project_name: (DEPRECATED) Use ``project`` instead. **kwargs: Forwarded to the dataset constructor (transforms, filters, etc.). Returns: @@ -52,7 +58,16 @@ def build_dataset(project_name: str, **kwargs: Any) -> 'DatamintBaseDataset': ds = build_dataset('MyProject', include_unannotated=False) """ + if project_name is not None: + warnings.warn("The 'project_name' parameter is deprecated. " + "Please use 'project' instead", DeprecationWarning) + if project is None: + project = project_name + if project is None: + raise TypeError("build_dataset() missing required argument: 'project'") + from datamint import Api + from datamint.entities import Project as ProjectCls from .image_dataset import ImageDataset from .volume_dataset import VolumeDataset from .video_dataset import VideoDataset @@ -65,23 +80,25 @@ def build_dataset(project_name: str, **kwargs: Any) -> 'DatamintBaseDataset': 'video': VideoDataset, } + project_display = project.name if isinstance(project, ProjectCls) else project + api = Api() - sample = api.resources.get_list(project_name=project_name, limit=5) + sample = api.resources.get_list(project_name=project, limit=5) if not sample: - raise ValueError(f"Project '{project_name}' has no resources.") + raise ValueError(f"Project '{project_display}' has no resources.") kinds = {_classify_resource(r) for r in sample} unknown = kinds - set(_KIND_TO_CLS) if unknown: raise ValueError( - f"Project '{project_name}' contains unsupported resource types: {sorted(unknown)}. " + f"Project '{project_display}' contains unsupported resource types: {sorted(unknown)}. " "Instantiate the dataset class directly." ) if kinds == {'image', 'volume'}: _LOGGER.warning( - f"Project '{project_name}' contains a mix of 2D and 3D DICOM resources. " + f"Project '{project_display}' contains a mix of 2D and 3D DICOM resources. " "Defaulting to VolumeDataset. Use ImageDataset or VolumeDataset directly " "if you need a specific type." ) @@ -89,11 +106,11 @@ def build_dataset(project_name: str, **kwargs: Any) -> 'DatamintBaseDataset': if len(kinds) > 1: raise ValueError( - f"Project '{project_name}' contains mixed data types: {sorted(kinds)}. " + f"Project '{project_display}' contains mixed data types: {sorted(kinds)}. " "Instantiate the dataset class directly." ) kind = next(iter(kinds)) dataset_cls = _KIND_TO_CLS[kind] _LOGGER.info(f"Detected resource type '{kind}'; using {dataset_cls.__name__}.") - return dataset_cls(project=project_name, **kwargs) + return dataset_cls(project=project, **kwargs) diff --git a/datamint/examples/example_projects.py b/datamint/examples/example_projects.py index 1696e9aa..b4adf90b 100644 --- a/datamint/examples/example_projects.py +++ b/datamint/examples/example_projects.py @@ -62,7 +62,7 @@ def create(project_name: str = 'Example Project MR', _LOGGER.info(f'Creating project {project_name}...') projid = api.projects.create(name=project_name, description='This is an example project', - resources_ids=[res.id]) + resource_ids=[res.id]) proj = api.projects.get_by_id(projid) if with_annotations: diff --git a/datamint/lightning/datamodule.py b/datamint/lightning/datamodule.py index d8b92c96..52fac031 100644 --- a/datamint/lightning/datamodule.py +++ b/datamint/lightning/datamodule.py @@ -58,8 +58,12 @@ class DatamintDataModule(L.LightningDataModule): split_as_of_timestamp: Historical timestamp forwarded to :meth:`DatamintBaseDataset.split` when reusing project-scoped split assignments. - use_server_splits: If *True*, use server-side ``split:*`` tags - instead of local random splitting. + use_server_splits: (DEPRECATED in favor of ``use_project_splits``) If + *True*, use server-side ``split:*`` tags instead of local random + splitting. + use_project_splits: If *True*, read split assignments from the + project splits API instead of local random splitting. Preferred + over ``use_server_splits``. train_transform: Albumentations transform applied **only** to the training split (e.g. augmentations). Calls :meth:`~datamint.dataset.base.DatamintBaseDataset.set_transform` @@ -105,6 +109,7 @@ def __init__( split_seed: int | None = None, split_as_of_timestamp: str | None = None, use_server_splits: bool | None = None, + use_project_splits: bool | None = None, train_transform: Callable | None = None, eval_transform: Callable | None = None, collate_fn: Callable | None = None, @@ -134,6 +139,7 @@ def __init__( self._split_seed = split_seed self._split_as_of_timestamp = split_as_of_timestamp self._use_server_splits = use_server_splits + self._use_project_splits = use_project_splits self._train_transform = train_transform self._eval_transform = eval_transform self.collate_fn = collate_fn @@ -167,6 +173,7 @@ def _resolve_dataset_splits(self) -> dict[str, DatamintBaseDataset | None]: parts = self.dataset.split( seed=self._split_seed, use_server_splits=self._use_server_splits, + use_project_splits=self._use_project_splits, as_of_timestamp=self._split_as_of_timestamp, **(self._split_cfg or {}), ) diff --git a/datamint/lightning/trainers/specialized/nnunet/data_import.py b/datamint/lightning/trainers/specialized/nnunet/data_import.py index ae8253cd..0e275533 100644 --- a/datamint/lightning/trainers/specialized/nnunet/data_import.py +++ b/datamint/lightning/trainers/specialized/nnunet/data_import.py @@ -130,7 +130,7 @@ def import_predictions( resource=resource_uuid, file_path=pred_path, name=class_map, - ai_model_name=mlflow_model_id, + model_name=mlflow_model_id, source=source, ) _LOGGER.info( diff --git a/tests/test_annotations_api.py b/tests/test_annotations_api.py index c642ab05..2d599da9 100644 --- a/tests/test_annotations_api.py +++ b/tests/test_annotations_api.py @@ -1,4 +1,7 @@ +from datetime import date + import httpx +import pytest from datamint.api.base_api import ApiConfig from datamint.api.endpoints.annotations_api import AnnotationsApi @@ -31,7 +34,7 @@ def handler(request: httpx.Request) -> httpx.Response: annotations_api.patch( api_ids.annotation_id, identifier="tumor", - project_id=api_ids.project_id, + project=api_ids.project_id, ) annotations_api.approve(api_ids.annotation_id) annotations_api.delete_batch([api_ids.annotation_id, api_ids.annotation_id_2]) @@ -119,4 +122,81 @@ def handler(request: httpx.Request) -> httpx.Response: assert annotation.scope == "frame" assert annotation.geometry is not None assert annotation.geometry.point1 == (0, 0, 2) - assert annotation.geometry.point2 == (10, 30, 2) \ No newline at end of file + assert annotation.geometry.point2 == (10, 30, 2) + + +def test_annotations_api_get_list_date_range_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "POST" and decoded_path(request) == "/annotations/search": + return httpx.Response(200, json=[]) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + annotations_api = AnnotationsApi(api_config, client=client) + + annotations_api.get_list(from_date=date(2026, 1, 1), to_date=date(2026, 1, 31)) + with pytest.warns(DeprecationWarning, match="date_from"): + annotations_api.get_list(date_from=date(2026, 2, 1)) + with pytest.warns(DeprecationWarning, match="date_to"): + annotations_api.get_list(date_to=date(2026, 2, 28)) + + assert json_body(requests[0])["from"] == "2026-01-01" + assert json_body(requests[0])["to"] == "2026-01-31" + assert json_body(requests[1])["from"] == "2026-02-01" + assert json_body(requests[2])["to"] == "2026-02-28" + + +def test_annotations_api_patch_project_id_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "PATCH" and decoded_path(request) == f"/annotations/{api_ids.annotation_id}": + return httpx.Response(200, json={}) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + annotations_api = AnnotationsApi(api_config, client=client) + with pytest.warns(DeprecationWarning, match="project_id"): + annotations_api.patch(api_ids.annotation_id, project_id=api_ids.project_id) + + assert json_body(requests[0]) == {"project_id": api_ids.project_id} + + +def test_annotations_api_upload_segmentation_model_name_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, +) -> None: + with make_client(lambda request: httpx.Response(404)) as client: + annotations_api = AnnotationsApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="ai_model_name"): + with pytest.raises(FileNotFoundError): + annotations_api.upload_segmentations( + api_ids.resource_id, + "/nonexistent/segmentation.png", + ai_model_name="legacy-model", + ) + with pytest.warns(DeprecationWarning, match="ai_model_name"): + with pytest.raises(FileNotFoundError): + annotations_api.upload_volume_segmentation( + api_ids.resource_id, + "/nonexistent/segmentation.nii.gz", + ai_model_name="legacy-model", + ) \ No newline at end of file diff --git a/tests/test_annotationsets_api.py b/tests/test_annotationsets_api.py index 21283835..9260e05a 100644 --- a/tests/test_annotationsets_api.py +++ b/tests/test_annotationsets_api.py @@ -1,4 +1,5 @@ import httpx +import pytest from datamint.api.base_api import ApiConfig from datamint.api.endpoints.annotationsets_api import AnnotationWorklistApi @@ -41,4 +42,69 @@ def handler(request: httpx.Request) -> httpx.Response: renames=["old_tumor:new_tumor"], ) - assert annotation_set_id == api_ids.annotation_set_id \ No newline at end of file + assert annotation_set_id == api_ids.annotation_set_id + + +def test_annotationsets_api_deprecated_parameter_aliases( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + definitions = [{"identifier": "tumor", "color": [255, 0, 0], "index": 1}] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = decoded_path(request) + if request.method == "POST" and path == "/annotationsets": + return httpx.Response(200, json={"id": api_ids.annotation_set_id}) + if request.method == "PUT" and path == f"/annotationsets/{api_ids.annotation_set_id}/segmentation-group": + return httpx.Response(200, json={"updated": True}) + if request.method == "GET" and path == ( + f"/annotationsets/{api_ids.annotation_set_id}/resources/{api_ids.resource_id}/segmentations" + ): + return httpx.Response(200, json=[]) + if request.method == "POST" and path == f"/annotationsets/{api_ids.annotation_set_id}/resources": + return httpx.Response(200, json={"updated": True}) + if request.method == "GET" and path == f"/annotationsets/{api_ids.annotation_set_id}/users/{api_ids.email}": + return httpx.Response(200, json={"status": "active"}) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + annotationsets_api = AnnotationWorklistApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="project_id"): + annotationsets_api.create( + name="Lung Worklist", + resource_ids=[api_ids.resource_id], + project_id=api_ids.project_id, + return_entity=False, + ) + with pytest.warns(DeprecationWarning, match="annotation_worklist"): + annotationsets_api.update_segmentation_group( + annotation_worklist=api_ids.annotation_set_id, + definitions=definitions, + ) + with pytest.warns(DeprecationWarning, match="annotation_set"): + with pytest.warns(DeprecationWarning, match="resource_id"): + with pytest.warns(DeprecationWarning, match="annotator"): + annotationsets_api.get_segmentations( + annotation_set=api_ids.annotation_set_id, + resource_id=api_ids.resource_id, + annotator=api_ids.email, + ) + with pytest.warns(DeprecationWarning, match="resources_to_add"): + annotationsets_api.update_resources( + api_ids.annotation_set_id, + resources_to_add=[api_ids.resource_id], + ) + with pytest.warns(DeprecationWarning, match="email"): + annotationsets_api.get_annotator_status( + api_ids.annotation_set_id, email=api_ids.email, + ) + + assert json_body(requests[0])["project_id"] == api_ids.project_id + assert requests[2].url.params["annotator"] == api_ids.email + assert json_body(requests[3])["resource_ids_to_add"] == [api_ids.resource_id] \ No newline at end of file diff --git a/tests/test_datamodule_collate.py b/tests/test_datamodule_collate.py index 9573309a..81203a14 100644 --- a/tests/test_datamodule_collate.py +++ b/tests/test_datamodule_collate.py @@ -44,3 +44,14 @@ def test_default_falls_back_to_dataset_collate_fn(): passed = MockLoader.call_args.kwargs.get('collate_fn') assert passed is dm.dataset.get_collate_fn.return_value + + +def test_use_project_splits_forwarded_to_dataset_split(): + mock_dataset = MagicMock() + mock_dataset.split.return_value = {"train": mock_dataset, "val": None, "test": None} + dm = DatamintDataModule(dataset=mock_dataset, use_project_splits=True) + + dm._resolve_dataset_splits() + + assert mock_dataset.split.call_args.kwargs["use_project_splits"] is True + assert mock_dataset.split.call_args.kwargs["use_server_splits"] is None diff --git a/tests/test_dataset_factory.py b/tests/test_dataset_factory.py new file mode 100644 index 00000000..8f7a3fbe --- /dev/null +++ b/tests/test_dataset_factory.py @@ -0,0 +1,22 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from datamint.dataset.factory import build_dataset + + +def test_build_dataset_project_name_deprecated_alias() -> None: + fake_api = MagicMock() + fake_api.resources.get_list.return_value = [] + + with patch("datamint.Api", return_value=fake_api): + with pytest.warns(DeprecationWarning, match="project_name"): + with pytest.raises(ValueError, match="MyProject"): + build_dataset(project_name="MyProject") + + fake_api.resources.get_list.assert_called_once_with(project_name="MyProject", limit=5) + + +def test_build_dataset_requires_project() -> None: + with pytest.raises(TypeError): + build_dataset() diff --git a/tests/test_datasetsinfo_api.py b/tests/test_datasetsinfo_api.py index 29837f44..505ae8a4 100644 --- a/tests/test_datasetsinfo_api.py +++ b/tests/test_datasetsinfo_api.py @@ -1,6 +1,7 @@ from pathlib import Path import httpx +import pytest from datamint.api.base_api import ApiConfig from datamint.api.endpoints.datasetsinfo_api import DatasetsInfoApi @@ -33,7 +34,7 @@ def handler(request: httpx.Request) -> httpx.Response: api_ids.dataset_id, resource_ids_to_add=[api_ids.resource_id], resource_ids_to_delete=[api_ids.resource_id_2], - project_id=api_ids.project_id, + project=api_ids.project_id, ) assert resources == resources_payload @@ -78,4 +79,28 @@ def handler(request: httpx.Request) -> httpx.Response: f"/datasets/{api_ids.dataset_id}/download/nifti", "/datasets/download/download-token", ] - assert output_path.read_bytes() == archive_bytes \ No newline at end of file + assert output_path.read_bytes() == archive_bytes + + +def test_datasets_api_update_resources_project_id_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "POST" and decoded_path(request) == f"/datasets/{api_ids.dataset_id}/resources": + return httpx.Response(200, json={"updated": True}) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + datasets_api = DatasetsInfoApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="project_id"): + datasets_api.update_resources(api_ids.dataset_id, project_id=api_ids.project_id) + + assert json_body(requests[0])["project_id"] == api_ids.project_id \ No newline at end of file diff --git a/tests/test_deploy_model_api.py b/tests/test_deploy_model_api.py new file mode 100644 index 00000000..47cff8dc --- /dev/null +++ b/tests/test_deploy_model_api.py @@ -0,0 +1,32 @@ +import httpx +import pytest + +from datamint.api.base_api import ApiConfig +from datamint.api.endpoints.deploy_model_api import DeployModelApi + + +def test_deploy_model_api_stream_status_job_id_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, +) -> None: + requests: list[httpx.Request] = [] + sse_body = b'data: {"status": "running"}\n\n' + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = decoded_path(request) + expected = f"/datamint/api/v1/deploy-model/status/{api_ids.resource_id}/stream" + if request.method == "GET" and path == expected: + return httpx.Response(200, content=sse_body) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + deploy_api = DeployModelApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="job_id"): + events = list(deploy_api.stream_status(job_id=api_ids.resource_id)) + + assert events == [{"status": "running"}] + assert len(requests) == 1 diff --git a/tests/test_inference_api.py b/tests/test_inference_api.py new file mode 100644 index 00000000..6ad3cdf9 --- /dev/null +++ b/tests/test_inference_api.py @@ -0,0 +1,39 @@ +import httpx +import pytest + +from datamint.api.base_api import ApiConfig +from datamint.api.endpoints.inference_api import InferenceApi + + +def test_inference_api_get_status_and_stream_status_job_id_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, +) -> None: + requests: list[httpx.Request] = [] + sse_body = b'data: {"status": "completed"}\n\n' + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = decoded_path(request) + base = f"/datamint/api/v1/model-inference/status/{api_ids.resource_id}" + if request.method == "GET" and path == base: + return httpx.Response( + 200, + json={"job_id": api_ids.resource_id, "status": "completed", "model_name": "my-model"}, + ) + if request.method == "GET" and path == f"{base}/stream": + return httpx.Response(200, content=sse_body) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + inference_api = InferenceApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="job_id"): + job = inference_api.get_status(job_id=api_ids.resource_id) + with pytest.warns(DeprecationWarning, match="job_id"): + events = list(inference_api.stream_status(job_id=api_ids.resource_id)) + + assert job.id == api_ids.resource_id + assert events == [{"status": "completed"}] diff --git a/tests/test_projects_api.py b/tests/test_projects_api.py index e77aee14..dbe3671a 100644 --- a/tests/test_projects_api.py +++ b/tests/test_projects_api.py @@ -1,6 +1,7 @@ from pathlib import Path import httpx +import pytest from datamint.api.base_api import ApiConfig from datamint.api.endpoints.projects_api import ProjectsApi @@ -52,7 +53,7 @@ def handler(request: httpx.Request) -> httpx.Response: api_ids.project_id, status="annotated", user_id=api_ids.user_id, - resource_id=api_ids.resource_id, + resource=api_ids.resource_id, ) assert members == members_payload @@ -147,4 +148,62 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.url.params.get_list("annotations[]") == ["tumor"] assert request.url.params["from"] == "2026-04-01" assert request.url.params["to"] == "2026-04-13" - assert output_path.read_bytes() == export_bytes \ No newline at end of file + assert output_path.read_bytes() == export_bytes + + +def test_projects_api_deprecated_parameter_aliases( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = decoded_path(request) + if request.method == "GET" and path == "/projects": + return httpx.Response(200, json=[]) + if request.method == "POST" and path == "/projects": + return httpx.Response(200, json={"id": api_ids.project_id}) + if request.method == "GET" and path == f"/projects/{api_ids.project_id}/annotation-statuses": + return httpx.Response(200, json=[]) + if request.method == "DELETE" and path == ( + f"/projects/{api_ids.project_id}/resources/{api_ids.resource_id}" + f"/annotator/{api_ids.email}/status-reset" + ): + return httpx.Response(200, json={}) + if request.method == "GET" and path == f"/projects/{api_ids.project_id}/users/{api_ids.email}/status": + return httpx.Response(200, json={"status": "active"}) + if request.method == "GET" and path == f"/projects/{api_ids.project_id}/annotators-statistic": + return httpx.Response(200, json=[]) + if request.method == "GET" and path == f"/projects/{api_ids.project_id}/reviewmessages": + return httpx.Response(200, json=[]) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + projects_api = ProjectsApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="resources_ids"): + projects_api.create( + name="Legacy Project", + description="desc", + resources_ids=[api_ids.resource_id], + return_entity=False, + ) + with pytest.warns(DeprecationWarning, match="resource_id"): + projects_api.get_annotation_statuses(api_ids.project_id, resource_id=api_ids.resource_id) + with pytest.warns(DeprecationWarning, match="annotator"): + projects_api.reset_annotator_status( + api_ids.resource_id, project=api_ids.project_id, annotator=api_ids.email, + ) + with pytest.warns(DeprecationWarning, match="email"): + projects_api.get_annotator_status(email=api_ids.email, project=api_ids.project_id) + with pytest.warns(DeprecationWarning, match="email"): + projects_api.get_annotators_stats(project=api_ids.project_id, email=api_ids.email) + with pytest.warns(DeprecationWarning, match="annotator"): + projects_api.get_review_messages(project=api_ids.project_id, annotator=api_ids.email) + + assert json_body(requests[1])["resource_ids"] == [api_ids.resource_id] + assert requests[2].url.params["resource_id"] == api_ids.resource_id \ No newline at end of file diff --git a/tests/test_resources_api.py b/tests/test_resources_api.py index 2bdffc9e..374609b0 100644 --- a/tests/test_resources_api.py +++ b/tests/test_resources_api.py @@ -1,4 +1,5 @@ import httpx +import pytest from datamint.api.base_api import ApiConfig from datamint.api.endpoints.resources_api import ResourcesApi @@ -39,4 +40,24 @@ def handler(request: httpx.Request) -> httpx.Response: assert requests[1].url.params["offset"] == "0" assert requests[1].url.params["limit"] == "1" assert len(resources) == 1 - assert resources[0].id == sample_resource["id"] \ No newline at end of file + assert resources[0].id == sample_resource["id"] + + +def test_resources_api_upload_resources_ai_model_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, +) -> None: + with make_client(lambda request: httpx.Response(404)) as client: + resources_api = ResourcesApi(api_config, client=client) + + # 'on_error' is intentionally invalid so the call short-circuits with a + # ValueError right after the deprecation warning fires, without needing + # to mock the full async upload pipeline. + with pytest.warns(DeprecationWarning, match="ai_model"): + with pytest.raises(ValueError): + resources_api.upload_resources( + files_path=["a.dcm", "b.dcm"], + on_error="invalid", + ai_model="legacy-model", + ) \ No newline at end of file diff --git a/tests/test_users_api.py b/tests/test_users_api.py index 8212ce89..e743d24f 100644 --- a/tests/test_users_api.py +++ b/tests/test_users_api.py @@ -1,4 +1,5 @@ import httpx +import pytest from datamint.api.base_api import ApiConfig from datamint.api.endpoints.users_api import UsersApi @@ -35,12 +36,12 @@ def handler(request: httpx.Request) -> httpx.Response: firstname="Annotator", lastname="User", return_url="https://app.datamint.io/return", - project_id=api_ids.project_id, + project=api_ids.project_id, project_roles=["PROJECT_ANNOTATOR"], annotation_worklist_id=api_ids.annotation_set_id, ) user = users_api.get_by_email(api_ids.email) - invitations = users_api.get_invitations(project_id=api_ids.project_id) + invitations = users_api.get_invitations(project=api_ids.project_id) users_api.revoke_invitation(api_ids.email) assert invite_result == {"status": "sent"} @@ -60,4 +61,34 @@ def handler(request: httpx.Request) -> httpx.Response: "annotation_worklist_id": api_ids.annotation_set_id, } assert requests[2].url.params["project_id"] == api_ids.project_id - assert requests[3].method == "DELETE" \ No newline at end of file + assert requests[3].method == "DELETE" + + +def test_users_api_project_id_deprecated_alias( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = decoded_path(request) + if request.method == "POST" and path == "/users/invite": + return httpx.Response(200, json={"status": "sent"}) + if request.method == "GET" and path == "/users/invitations": + return httpx.Response(200, json=[]) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + users_api = UsersApi(api_config, client=client) + + with pytest.warns(DeprecationWarning, match="project_id"): + users_api.invite(api_ids.email, project_id=api_ids.project_id) + with pytest.warns(DeprecationWarning, match="project_id"): + users_api.get_invitations(project_id=api_ids.project_id) + + assert json_body(requests[0])["project_id"] == api_ids.project_id + assert requests[1].url.params["project_id"] == api_ids.project_id \ No newline at end of file