From 7664e5704b114766fbb8c08d094d45fdf7e2c44b Mon Sep 17 00:00:00 2001 From: luandalmazo Date: Tue, 30 Jun 2026 10:00:02 -0300 Subject: [PATCH] add exceptions and update the exist ones --- datamint/api/base_api.py | 52 +++++++---- datamint/api/client.py | 8 +- datamint/api/endpoints/annotations_api.py | 22 ++--- datamint/api/endpoints/deploy_model_api.py | 4 +- datamint/api/endpoints/inference_api.py | 3 +- datamint/api/endpoints/projects_api.py | 2 +- datamint/api/endpoints/resources_api.py | 19 ++-- datamint/api/entity_base_api.py | 4 +- datamint/dataset/base.py | 4 +- datamint/exceptions.py | 100 +++++++++++++++------ datamint/mlflow/tracking/fluent.py | 6 +- 11 files changed, 142 insertions(+), 82 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 48f2c2a4..dfbdb066 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -3,7 +3,14 @@ from collections.abc import Generator, AsyncGenerator import httpx from dataclasses import dataclass -from datamint.exceptions import DatamintException, ItemNotFoundError +from datamint.exceptions import ( + ItemNotFoundError, + AuthenticationError, + PermissionDeniedError, + ValidationError, + NetworkError, + ServerError, +) import aiohttp import json from PIL import Image @@ -126,7 +133,7 @@ def _raise_ssl_error(self, original_error: Exception) -> None: original_error: The original SSL-related exception Raises: - DatamintException: With helpful troubleshooting information + NetworkError: With helpful troubleshooting information """ error_msg = ( f"SSL Certificate verification failed: {original_error}\n\n" @@ -141,7 +148,7 @@ def _raise_ssl_error(self, original_error: Exception) -> None: " api = Api(verify_ssl=False)\n\n" "For more help, see: https://github.com/SonanceAI/datamint-python-api#-ssl-certificate-troubleshooting" ) - raise DatamintException(error_msg) from original_error + raise NetworkError(error_msg) from original_error def _create_aiohttp_connector(self, force_close: bool = False) -> aiohttp.TCPConnector: """Create aiohttp connector with SSL configuration. @@ -417,7 +424,7 @@ def _check_errors_response_httpx(self, except httpx.ConnectError as e: if "CERTIFICATE_VERIFY_FAILED" in str(e) or "certificate verify failed" in str(e).lower(): self._raise_ssl_error(e) - raise + raise NetworkError(str(e)) from e except httpx.HTTPError as e: try: response_json = response.json() @@ -426,18 +433,21 @@ def _check_errors_response_httpx(self, error_msg = f"{getattr(e, 'message', str(e))} | {getattr(response, 'text', '')}" if response_json: error_msg = f"{error_msg} | {response_json}" - try: - e.message = error_msg - except Exception: - logger.debug("Unable to set message attribute on exception") - pass status_code = response.status_code - if status_code in (400, 404): + if status_code == 401: + raise AuthenticationError(error_msg) from e + if status_code == 403: + raise PermissionDeniedError(error_msg) from e + if status_code in (400, 422): + raise ValidationError(error_msg) from e + if status_code == 404: new_error_msg = error_msg.replace('404 Not Found', '') if ' not found' in new_error_msg.lower() or 'Not Found' in new_error_msg: - # Will be caught by the caller and properly initialized: raise ItemNotFoundError('unknown', {}) + raise ValidationError(error_msg) from e + if status_code >= 500: + raise ServerError(error_msg, status_code=status_code) from e raise return response_json @@ -447,9 +457,10 @@ async def _check_errors_response_aiohttp(self, response_json = None try: response.raise_for_status() + except aiohttp.ClientConnectionError as e: + raise NetworkError(str(e)) from e except aiohttp.ClientError as e: error_msg = str(getattr(e, 'message', e)) - # log the raw response for debugging status_code = BaseApi.get_status_code(e) # Only read the body on error to get detailed message; do NOT read on success # as that would exhaust the stream before callers can iterate over it. @@ -461,15 +472,18 @@ async def _check_errors_response_aiohttp(self, error_msg = f"{error_msg} | {response_json}" logger.error(f"HTTP error {status_code} for {url}: {error_msg}") - try: - e.message = error_msg - except Exception: - logger.debug("Unable to set message attribute on exception") - pass - if status_code in (400, 404): + if status_code == 401: + raise AuthenticationError(error_msg) from e + if status_code == 403: + raise PermissionDeniedError(error_msg) from e + if status_code in (400, 422): + raise ValidationError(error_msg) from e + if status_code == 404: if ' not found' in error_msg.lower() or 'Not Found' in error_msg: - # Will be caught by the caller and properly initialized: raise ItemNotFoundError('unknown', {}) + raise ValidationError(error_msg) from e + if status_code >= 500: + raise ServerError(error_msg, status_code=status_code) from e raise return response_json diff --git a/datamint/api/client.py b/datamint/api/client.py index 40dab27e..247b528d 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -6,7 +6,7 @@ ) from .endpoints.models_api import ModelsApi import datamint.configs -from datamint.exceptions import DatamintException +from datamint.exceptions import AuthenticationError, NetworkError import logging _LOGGER = logging.getLogger(__name__) @@ -58,7 +58,7 @@ def __init__(self, if api_key is None: msg = f"API key not provided! Use the environment variable " + \ f"{Api.DATAMINT_API_VENV_NAME} or pass it as an argument." - raise DatamintException(msg) + raise AuthenticationError(msg) self.config = ApiConfig( server_url=server_url, api_key=api_key, @@ -94,8 +94,8 @@ def check_connection(self): try: self.projects.get_list(limit=1) except Exception as e: - raise DatamintException("Error connecting to the Datamint API." + - f" Please check your api_key and/or other configurations.") from e + raise NetworkError("Error connecting to the Datamint API." + " Please check your api_key and/or other configurations.") from e def close(self) -> None: """Close underlying HTTP clients and any shared aiohttp sessions. diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 19c7b76a..9e5735b6 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -31,7 +31,7 @@ LineAnnotation, annotation_from_dict, ) -from datamint.exceptions import DatamintException, ItemNotFoundError +from datamint.exceptions import ItemNotFoundError, ServerError from datamint.utils.nifti_utils import metadata_to_nifti_obj from ..entity_base_api import ApiConfig, CreatableEntityApi, DeletableEntityApi @@ -453,7 +453,7 @@ async def upload_annotation_file_async(self, the basename of the path will be used. Raises: - DatamintException: If the upload fails. + ServerError: If the upload fails. """ f, filename, close_file, content_type = self._prepare_upload_file(file, @@ -470,7 +470,7 @@ async def upload_annotation_file_async(self, session=session, data=form) if isinstance(respdata, dict) and 'error' in respdata: - raise DatamintException(respdata['error']) + raise ServerError(respdata['error']) finally: if close_file: f.close() @@ -494,7 +494,7 @@ def upload_annotation_file(self, the basename of the path will be used. Raises: - DatamintException: If the upload fails. + ServerError: If the upload fails. """ f, filename, close_file, content_type = self._prepare_upload_file(file, filename, @@ -509,7 +509,7 @@ def upload_annotation_file(self, files=files) respdata = resp.json() if isinstance(respdata, dict) and 'error' in respdata: - raise DatamintException(respdata['error']) + raise ServerError(respdata['error']) finally: if close_file: f.close() @@ -550,7 +550,7 @@ def create( json=annotations_payload).json() for r in respdata: if isinstance(r, dict) and 'error' in r: - raise DatamintException(r['error']) + raise ServerError(r['error']) if is_single_annotation: return respdata[0] return respdata @@ -788,7 +788,7 @@ async def _create_annotations_async(self, json=annotations) for r in respdata: if isinstance(r, dict) and 'error' in r: - raise DatamintException(r['error']) + raise ServerError(r['error']) return respdata @staticmethod @@ -917,7 +917,7 @@ async def _upload_volume_segmentation_async(self, raise if 'error' in respdata: - raise DatamintException(respdata['error']) + raise ServerError(respdata['error']) return respdata else: raise ValueError(f"Volume upload not supported for file format: {file_path}") @@ -951,7 +951,7 @@ async def _upload_volume_segmentation_async(self, raise if 'error' in respdata: - raise DatamintException(respdata['error']) + raise ServerError(respdata['error']) return respdata else: raise ValueError(f"Unsupported file_path type for volume upload: {type(file_path)}") @@ -1460,7 +1460,7 @@ def patch(self, project_id: Optional project ID to associate with the annotation. Raises: - DatamintException: If the update fails. + ServerError: If the update fails. """ annotation_id = self._entid(annotation) @@ -1477,7 +1477,7 @@ def patch(self, respdata = resp.json() if isinstance(respdata, dict) and 'error' in respdata: - raise DatamintException(respdata['error']) + raise ServerError(respdata['error']) def approve(self, annotation: str | Annotation) -> None: """Approve an annotation. diff --git a/datamint/api/endpoints/deploy_model_api.py b/datamint/api/endpoints/deploy_model_api.py index 3e3094f6..429f9573 100644 --- a/datamint/api/endpoints/deploy_model_api.py +++ b/datamint/api/endpoints/deploy_model_api.py @@ -7,7 +7,7 @@ import httpx -from datamint.exceptions import ResourceNotFoundError +from datamint.exceptions import ResourceNotFoundError, JobTimeoutError from ..entity_base_api import EntityBaseApi, ApiConfig from datamint.entities.deployjob import DeployJob @@ -88,7 +88,7 @@ def wait( def _check_timeout() -> None: if deadline is not None and time.monotonic() >= deadline: - raise TimeoutError(f"Deployment job {job_id} did not finish within {timeout}s") + raise JobTimeoutError(f"Deployment job {job_id} did not finish within {timeout}s") def _notify(event: dict) -> None: if on_status is None: diff --git a/datamint/api/endpoints/inference_api.py b/datamint/api/endpoints/inference_api.py index 5858b13f..675d4de5 100644 --- a/datamint/api/endpoints/inference_api.py +++ b/datamint/api/endpoints/inference_api.py @@ -9,6 +9,7 @@ from ..entity_base_api import EntityBaseApi, ApiConfig from datamint.entities.inferencejob import InferenceJob +from datamint.exceptions import JobTimeoutError logger = logging.getLogger(__name__) @@ -186,7 +187,7 @@ def wait( def _check_timeout() -> None: if deadline is not None and time.monotonic() >= deadline: - raise TimeoutError(f"Inference job {job_id} did not finish within {timeout}s") + raise JobTimeoutError(f"Inference job {job_id} did not finish within {timeout}s") def _notify(event: dict) -> None: if on_status is None: diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index d9c1a811..1cdb2e60 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -195,7 +195,7 @@ def add_resources(self, # get the project id by its name project_found = self._get_by_name_or_id(project) if project_found is None: - raise ValueError(f"Project '{project}' not found.") + raise ItemNotFoundError('Project', {'name': project}) project_id = project_found.id else: project_id = project.id diff --git a/datamint/api/endpoints/resources_api.py b/datamint/api/endpoints/resources_api.py index 48a9a666..2e213118 100644 --- a/datamint/api/endpoints/resources_api.py +++ b/datamint/api/endpoints/resources_api.py @@ -4,7 +4,7 @@ from ..entity_base_api import CreatableEntityApi, DeletableEntityApi from datamint.entities import Project, Resource from datamint.entities.annotations.annotation import Annotation -from datamint.exceptions import DatamintException, ItemNotFoundError +from datamint.exceptions import ItemNotFoundError, ServerError, ValidationError from datamint.entities.annotations import AnnotationType from datamint.utils.collection_utils import ChainedSequence import httpx @@ -431,7 +431,7 @@ async def _upload_single_resource_async(self, session=session, timeout=timeout) if 'error' in resp_data: - raise DatamintException(resp_data['error']) + raise ServerError(resp_data['error']) _LOGGER.debug("Response on uploading %s: %s", filename, resp_data) return resp_data['id'] except Exception as e: @@ -845,7 +845,7 @@ def upload_resource(self, Raises: ItemNotFoundError: If `publish_to` is supplied, and the project does not exist. - DatamintException: If the upload fails. + ServerError: If the upload fails. Example: .. code-block:: python @@ -902,7 +902,7 @@ def upload_resource(self, return r else: # This should not happen with single file uploads, but handle it just in case - raise DatamintException(f"Unexpected return from upload_resources: {type(result)} | {result}") + raise ServerError(f"Unexpected return from upload_resources: {type(result)} | {result}") async def _async_download_file(self, resource: str | Resource, @@ -1211,7 +1211,7 @@ def download_resource_frame(self, Raises: ItemNotFoundError: If the resource does not exists. - DatamintException: If the resource is not a video or dicom. + ServerError: If the resource is not a video or dicom. """ # check if the resource is an single frame image (png,jpeg,...) first. # If so, download the whole resource file and return the image. @@ -1219,8 +1219,8 @@ def download_resource_frame(self, resource = self.get_by_id(resource) if resource.is_image(): if frame_index != 0: - raise DatamintException(f"Resource {resource.id} is not a multi-frame resource, " - f"but frame_index is {frame_index}.") + raise ValidationError(f"Resource {resource.id} is not a multi-frame resource, " + f"but frame_index is {frame_index}.") return self.download_resource_file(resource, auto_convert=True) try: @@ -1231,8 +1231,9 @@ def download_resource_frame(self, if response.status_code == 200: return Image.open(io.BytesIO(response.content)) else: - raise DatamintException( - f"Error downloading frame {frame_index} of resource {self._entid(resource)}: {response.text}") + raise ServerError( + f"Error downloading frame {frame_index} of resource {self._entid(resource)}: {response.text}", + status_code=response.status_code) except ItemNotFoundError as e: e.set_params('resource', {'resource_id': self._entid(resource)}) raise e diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index fb3155c7..10461802 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -3,7 +3,7 @@ import logging import httpx from datamint.entities.base_entity import BaseEntity -from datamint.exceptions import DatamintException, ItemNotFoundError +from datamint.exceptions import ItemNotFoundError, ServerError import aiohttp import asyncio from .base_api import ApiConfig, BaseApi @@ -216,7 +216,7 @@ async def _create_async(self, entity_data: dict[str, Any]) -> str | Sequence[str f'/{self.endpoint_base}', json=entity_data) if 'error' in respdata: - raise DatamintException(respdata['error']) + raise ServerError(respdata['error']) if isinstance(respdata, str): return respdata if isinstance(respdata, list): diff --git a/datamint/dataset/base.py b/datamint/dataset/base.py index 52ae2b4d..796c10e3 100644 --- a/datamint/dataset/base.py +++ b/datamint/dataset/base.py @@ -15,7 +15,7 @@ from torch.utils.data import DataLoader, ConcatDataset import numpy as np from datamint.entities.annotation_worklist import AnnotationWorklist -from datamint.exceptions import DatamintException +from datamint.exceptions import DatamintException, ItemNotFoundError from .annotation_processor import AnnotationProcessor, MergeStrategy from datamint.entities.annotations.annotation_spec import AnnotationSpec, CategoryAnnotationSpec from datamint.entities.annotations import AnnotationType @@ -451,7 +451,7 @@ def _initialize_from_project( if isinstance(project, str): project = self._api.projects.get_by_name(project) if project is None: - raise DatamintDatasetException(f"Project '{project}' not found.") + raise ItemNotFoundError('Project', {'name': project}) else: # Attach API to project if not already set if not hasattr(project, '_api') or project._api is None: diff --git a/datamint/exceptions.py b/datamint/exceptions.py index 8433073d..de40484f 100644 --- a/datamint/exceptions.py +++ b/datamint/exceptions.py @@ -1,25 +1,30 @@ class DatamintException(Exception): - """ - Base class for exceptions in this module. - """ + """Base class for all Datamint exceptions.""" pass -class ItemNotFoundError(DatamintException): - """ - Exception raised when an item is not found. - For instance, when trying to get an item by a non-existing id. - """ +# --------------------------------------------------------------------------- +# Auth / access +# --------------------------------------------------------------------------- + +class AuthenticationError(DatamintException): + """Raised when the API key is missing or rejected (HTTP 401).""" + pass + + +class PermissionDeniedError(DatamintException): + """Raised when the authenticated user lacks permission for the requested operation (HTTP 403).""" + pass + - def __init__(self, - item_type: str, - params: dict): - """ Constructor. +# --------------------------------------------------------------------------- +# Resource state +# --------------------------------------------------------------------------- - Args: - item_type (str): An item type. - params (dict): Dict of params identifying the sought item. - """ +class ItemNotFoundError(DatamintException): + """Raised when a requested item does not exist (HTTP 404).""" + + def __init__(self, item_type: str, params: dict): self.item_type = item_type self.params = params @@ -28,7 +33,7 @@ def resource_type(self): return self.item_type @resource_type.setter - def resource_type(self, value: str): # Alias for backward compatibility. To be removed in a future major version. + def resource_type(self, value: str): # Alias kept for backward compatibility. self.item_type = value def set_params(self, resource_type: str, params: dict): @@ -39,25 +44,64 @@ def __str__(self): return f"Item '{self.item_type}' not found for parameters: {self.params}" -ResourceNotFoundError = ItemNotFoundError # Alias for backward compatibility. To be removed in a future major version. +ResourceNotFoundError = ItemNotFoundError # Alias kept for backward compatibility. class EntityAlreadyExistsError(DatamintException): - """ - Exception raised when trying to create an entity that already exists. - For instance, when creating a project with a name that already exists. - """ + """Raised when trying to create an entity that already exists.""" def __init__(self, entity_type: str, params: dict): - """Constructor. - - Args: - entity_type: The type of entity that already exists. - params: Dict of params identifying the existing entity. - """ super().__init__() self.entity_type = entity_type self.params = params def __str__(self) -> str: return f"Entity '{self.entity_type}' already exists for parameters: {self.params}" + + +# --------------------------------------------------------------------------- +# Input validation +# --------------------------------------------------------------------------- + +class ValidationError(DatamintException): + """Raised when the server rejects a request due to invalid input (HTTP 400/422).""" + pass + + +# --------------------------------------------------------------------------- +# Network / connectivity +# --------------------------------------------------------------------------- + +class NetworkError(DatamintException): + """Raised on connection failures, SSL errors, or other transport-level problems.""" + pass + + +# --------------------------------------------------------------------------- +# Server-side failures +# --------------------------------------------------------------------------- + +class ServerError(DatamintException): + """Raised when the server returns an unexpected error (HTTP 5xx).""" + + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + + def __str__(self) -> str: + if self.status_code: + return f"Server error {self.status_code}: {super().__str__()}" + return super().__str__() + + +# --------------------------------------------------------------------------- +# Async job timeouts +# --------------------------------------------------------------------------- + +class JobTimeoutError(DatamintException, TimeoutError): + """Raised when a deployment or inference job does not finish within the allowed time. + + Subclasses both DatamintException and the built-in TimeoutError so callers + catching either one will handle it correctly. + """ + pass diff --git a/datamint/mlflow/tracking/fluent.py b/datamint/mlflow/tracking/fluent.py index 5072e61c..6bf6115f 100644 --- a/datamint/mlflow/tracking/fluent.py +++ b/datamint/mlflow/tracking/fluent.py @@ -2,7 +2,7 @@ import threading import logging from datamint import Api -from datamint.exceptions import DatamintException +from datamint.exceptions import ItemNotFoundError import os from datamint.mlflow.env_vars import EnvVars from datamint.mlflow.env_utils import ensure_mlflow_configured @@ -43,7 +43,7 @@ def _find_project_by_name(project_name: str): dt_client = Api(check_connection=False) project = dt_client.projects.get_by_name(project_name) if project is None: - raise DatamintException(f"Project with name '{project_name}' does not exist.") + raise ItemNotFoundError('Project', {'name': project_name}) return project @@ -60,7 +60,7 @@ def _get_project_by_name_or_id(project_name_or_id: str) -> 'Project': pass # Not a valid UUID, treat as name project = dt_client.projects.get_by_name(project_name_or_id) if project is None: - raise DatamintException(f"Project '{project_name_or_id}' does not exist.") + raise ItemNotFoundError('Project', {'name_or_id': project_name_or_id}) return project