From dce571ea04066180fac4c2f0c3cfea4b93f78c9f Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 3 Dec 2025 17:23:58 -0300 Subject: [PATCH 1/7] Enhance BaseApi and ApiConfig to support optional port configuration and improve client initialization --- datamint/api/__init__.py | 1 + datamint/api/base_api.py | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/datamint/api/__init__.py b/datamint/api/__init__.py index e69de29b..79c58267 100644 --- a/datamint/api/__init__.py +++ b/datamint/api/__init__.py @@ -0,0 +1 @@ +from .client import Api \ No newline at end of file diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 78cd3885..0ac0ecbc 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -22,6 +22,7 @@ _PAGE_LIMIT = 5000 + @dataclass class ApiConfig: """Configuration for API client. @@ -31,18 +32,26 @@ class ApiConfig: api_key: Optional API key for authentication. timeout: Request timeout in seconds. max_retries: Maximum number of retries for requests. + port: Optional port number for the API server. """ server_url: str api_key: str | None = None timeout: float = 30.0 max_retries: int = 3 + port: int | None = None @property def web_app_url(self) -> str: """Get the base URL for the web application.""" - if self.server_url.startswith('http://localhost:3001'): + base_url = self.server_url + + # Add port to base_url if specified + if self.port is not None: + base_url = f"{self.server_url.rstrip('/')}:{self.port}" + + if base_url.startswith('http://localhost'): return 'http://localhost:3000' - if self.server_url.startswith('https://stagingapi.datamint.io'): + if base_url.startswith('https://stagingapi.datamint.io'): return 'https://staging.datamint.io' return 'https://app.datamint.io' @@ -68,15 +77,29 @@ def __init__(self, @staticmethod def _create_client(config: ApiConfig) -> httpx.Client: """Create and configure HTTP client with authentication and timeouts. - + The client is designed to be long-lived and reused across multiple requests. It maintains connection pooling for improved performance. Default limits: max_keepalive_connections=20, max_connections=100 """ - headers = {"apikey": config.api_key} if config.api_key else None + headers = {"apikey": config.api_key, 'Authorization': f"Bearer {config.api_key}"} if config.api_key else None + + # Add port to base_url if specified + base_url = config.server_url.rstrip('/').strip() + if config.port is not None: + # if the port is already in the URL, replace it + if ':' in base_url.split('//')[-1]: + parts = base_url.rsplit(':', 1) + # confirm parts[1] is numeric + if parts[1].isdigit(): + base_url = f"{parts[0]}:{config.port}" + else: + logger.warning(f"Invalid port detected in server_url: {config.server_url}") + else: + base_url = f"{base_url}:{config.port}" return httpx.Client( - base_url=config.server_url, + base_url=base_url, headers=headers, timeout=config.timeout, limits=httpx.Limits( @@ -88,7 +111,7 @@ def _create_client(config: ApiConfig) -> httpx.Client: def close(self) -> None: """Close the HTTP client and release resources. - + Should be called when the API instance is no longer needed. Only closes the client if it was created by this instance. """ @@ -379,7 +402,7 @@ def _make_request_with_pagination(self, """ offset = 0 total_fetched = 0 - + use_json_pagination = method.upper() == 'POST' and 'json' in kwargs and isinstance(kwargs['json'], dict) if not use_json_pagination: From e0086aebf3d6247f69ed66ad787df64a417180c3 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Wed, 3 Dec 2025 17:25:12 -0300 Subject: [PATCH 2/7] Add DeployModelApi for managing model deployment and enhance Api class for MLflow integration --- datamint/api/client.py | 40 +- datamint/api/endpoints/__init__.py | 4 +- datamint/api/endpoints/deploy_model_api.py | 96 +++++ datamint/api/endpoints/models_api.py | 1 + datamint/api/entity_base_api.py | 3 +- notebooks/deploy_model_demo.ipynb | 474 +++++++++++++++++++++ 6 files changed, 605 insertions(+), 13 deletions(-) create mode 100644 datamint/api/endpoints/deploy_model_api.py create mode 100644 notebooks/deploy_model_demo.ipynb diff --git a/datamint/api/client.py b/datamint/api/client.py index 3e230ee6..b13e5037 100644 --- a/datamint/api/client.py +++ b/datamint/api/client.py @@ -1,9 +1,9 @@ -from typing import Optional from .base_api import ApiConfig, BaseApi from .endpoints import (ProjectsApi, ResourcesApi, AnnotationsApi, - ChannelsApi, UsersApi, DatasetsInfoApi, ModelsApi, - AnnotationSetsApi + ChannelsApi, UsersApi, DatasetsInfoApi, + AnnotationSetsApi, DeployModelApi ) +from .endpoints.models_api import ModelsApi import datamint.configs from datamint.exceptions import DatamintException @@ -13,7 +13,7 @@ class Api: DEFAULT_SERVER_URL = 'https://api.datamint.io' DATAMINT_API_VENV_NAME = datamint.configs.ENV_VARS[datamint.configs.APIKEY_KEY] - _API_MAP : dict[str, type[BaseApi]] = { + _API_MAP: dict[str, type[BaseApi]] = { 'projects': ProjectsApi, 'resources': ResourcesApi, 'annotations': AnnotationsApi, @@ -22,11 +22,12 @@ class Api: 'datasets': DatasetsInfoApi, 'models': ModelsApi, 'annotationsets': AnnotationSetsApi, + 'deploy': DeployModelApi, } def __init__(self, server_url: str | None = None, - api_key: Optional[str] = None, + api_key: str | None = None, timeout: float = 60.0, max_retries: int = 2, check_connection: bool = True) -> None: """Initialize the API client. @@ -55,8 +56,16 @@ def __init__(self, timeout=timeout, max_retries=max_retries ) + self.mlflow_config = ApiConfig( + server_url=server_url, + api_key=api_key, + timeout=timeout, + max_retries=max_retries, + port=5000 + ) self._client = None - self._endpoints = {} + self._mlclient = None + self._endpoints: dict[str, BaseApi] = {} if check_connection: self.check_connection() @@ -67,12 +76,18 @@ def check_connection(self): raise DatamintException("Error connecting to the Datamint API." + f" Please check your api_key and/or other configurations.") from e - def _get_endpoint(self, name: str): - if self._client is None: - self._client = BaseApi._create_client(self.config) + def _get_endpoint(self, name: str, is_mlflow: bool = False): + if is_mlflow: + if self._mlclient is None: + self._mlclient = BaseApi._create_client(self.mlflow_config) + client = self._mlclient + else: + if self._client is None: + self._client = BaseApi._create_client(self.config) + client = self._client if name not in self._endpoints: api_class = self._API_MAP[name] - endpoint = api_class(self.config, self._client) + endpoint = api_class(self.config, client) # Inject this API instance into the endpoint so it can inject into entities endpoint._api_instance = self self._endpoints[name] = endpoint @@ -110,3 +125,8 @@ def models(self) -> ModelsApi: @property def annotationsets(self) -> AnnotationSetsApi: return self._get_endpoint('annotationsets') + + @property + def deploy(self) -> DeployModelApi: + """Access deployment management endpoints.""" + return self._get_endpoint('deploy', is_mlflow=True) diff --git a/datamint/api/endpoints/__init__.py b/datamint/api/endpoints/__init__.py index d53d5dae..9d5d9bc9 100644 --- a/datamint/api/endpoints/__init__.py +++ b/datamint/api/endpoints/__init__.py @@ -6,8 +6,8 @@ from .resources_api import ResourcesApi from .users_api import UsersApi from .datasetsinfo_api import DatasetsInfoApi -from .models_api import ModelsApi from .annotationsets_api import AnnotationSetsApi +from .deploy_model_api import DeployModelApi __all__ = [ 'AnnotationsApi', @@ -16,6 +16,6 @@ 'ResourcesApi', 'UsersApi', 'DatasetsInfoApi', - 'ModelsApi', 'AnnotationSetsApi', + 'DeployModelApi', ] diff --git a/datamint/api/endpoints/deploy_model_api.py b/datamint/api/endpoints/deploy_model_api.py new file mode 100644 index 00000000..fc2d2e28 --- /dev/null +++ b/datamint/api/endpoints/deploy_model_api.py @@ -0,0 +1,96 @@ +from typing import Optional +import httpx +from ..entity_base_api import EntityBaseApi, ApiConfig +from datamint.entities.base_entity import BaseEntity + + +class DeployJob(BaseEntity): + status: str + model_name: str + model_version: Optional[int] = None + model_alias: Optional[str] = None + image_name: Optional[str] = None + image_tag: Optional[str] = None + error_message: Optional[str] = None + progress_percentage: int = 0 + current_step: Optional[str] = None + with_gpu: bool = False + recent_logs: Optional[list[str]] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + + +class DeployModelApi(EntityBaseApi[DeployJob]): + """API handler for model deployment endpoints.""" + + def __init__(self, + config: ApiConfig, + client: httpx.Client | None = None) -> None: + super().__init__(config, DeployJob, 'datamint/api/v1/deploy-model', client) + + def get_by_id(self, entity_id: str) -> DeployJob: + """Get deployment job status by ID.""" + response = self._make_request('GET', f'/{self.endpoint_base}/status/{entity_id}') + data = response.json() + if 'job_id' in data: + data['id'] = data.pop('job_id') + return self._init_entity_obj(**data) + + def start(self, + model_name: str, + model_version: int | None = None, + model_alias: str | None = None, + image_name: str | None = None, + with_gpu: bool = False, + convert_to_onnx: bool = False, + input_shape: list[int] | None = None) -> DeployJob: + """Start a new deployment job.""" + payload = { + "model_name": model_name, + "model_version": model_version, + "model_alias": model_alias, + "image_name": image_name, + "with_gpu": with_gpu, + "convert_to_onnx": convert_to_onnx, + "input_shape": input_shape + } + # Remove None values + payload = {k: v for k, v in payload.items() if v is not None} + + response = self._make_request('POST', f'/{self.endpoint_base}/start', json=payload) + data = response.json() + return self.get_by_id(data['job_id']) + + def cancel(self, job: str | DeployJob) -> bool: + """Cancel a deployment job.""" + job_id = self._entid(job) + response = self._make_request('POST', f'/{self.endpoint_base}/cancel/{job_id}') + return response.json().get('success', False) + + def list_active_jobs(self) -> dict: + """List active deployment jobs count.""" + response = self._make_request('GET', f'/{self.endpoint_base}/jobs') + return response.json() + + def list_images(self, model_name: str | None = None) -> list[dict]: + """List deployed model images.""" + params = {} + if model_name: + params['model_name'] = model_name + response = self._make_request('GET', f'/{self.endpoint_base}/images', params=params) + return response.json() + + def remove_image(self, model_name: str, tag: str | None = None) -> dict: + """Remove a deployed model image.""" + params = {} + if tag: + params['tag'] = tag + response = self._make_request('DELETE', f'/{self.endpoint_base}/image/{model_name}', params=params) + return response.json() + + def image_exists(self, model_name: str, tag: str = "champion") -> bool: + """Check if a model image exists.""" + params = {'tag': tag} + response = self._make_request('GET', f'/{self.endpoint_base}/image/{model_name}/exists', params=params) + return response.json().get('exists', False) + diff --git a/datamint/api/endpoints/models_api.py b/datamint/api/endpoints/models_api.py index f796176c..e18ee225 100644 --- a/datamint/api/endpoints/models_api.py +++ b/datamint/api/endpoints/models_api.py @@ -1,3 +1,4 @@ +"""Deprecated: Use MLFlow API instead.""" from typing import Sequence from ..entity_base_api import ApiConfig, BaseApi import httpx diff --git a/datamint/api/entity_base_api.py b/datamint/api/entity_base_api.py index 4254d48d..3dbd968b 100644 --- a/datamint/api/entity_base_api.py +++ b/datamint/api/entity_base_api.py @@ -25,7 +25,8 @@ class EntityBaseApi(BaseApi, Generic[T]): def __init__(self, config: ApiConfig, entity_class: Type[T], endpoint_base: str, - client: httpx.Client | None = None) -> None: + client: httpx.Client | None = None + ) -> None: """Initialize the entity API handler. Args: diff --git a/notebooks/deploy_model_demo.ipynb b/notebooks/deploy_model_demo.ipynb new file mode 100644 index 00000000..4e2e72b4 --- /dev/null +++ b/notebooks/deploy_model_demo.ipynb @@ -0,0 +1,474 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "47301b3d", + "metadata": {}, + "source": [ + "# Model Deployment API Demo\n", + "\n", + "This notebook demonstrates how to use the Datamint API to deploy machine learning models as Docker images.\n", + "\n", + "> **IMPORTANT:**\n", + "> Registered Model it not the same as a deployed model!\n", + ">\n", + "> A Registered Model is a model that has been uploaded to the platform, that might or might not be deployed.\n", + "> Is a requirement to have a Registered Model before deploying it.\n", + "> In this demo we will show how to deploy a registered model.\n", + "\n", + "## Features Covered:\n", + "- Starting deployment jobs\n", + "- Monitoring deployment status\n", + "- Cancelling jobs\n", + "- Managing deployed images\n", + "- Checking image existence" + ] + }, + { + "cell_type": "markdown", + "id": "bb59d9f3", + "metadata": {}, + "source": [ + "## Setup and Initialization\n", + "\n", + "First, import the necessary libraries and initialize the API client." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "639e0b84", + "metadata": {}, + "outputs": [], + "source": [ + "from datamint import Api\n", + "import datamint.mlflow\n", + "import mlflow\n", + "import time\n", + "\n", + "# Initialize the API client\n", + "# The API key can be set via environment variable DATAMINT_API_KEY\n", + "# or passed directly to the Api constructor\n", + "api = Api()\n", + "\n", + "print(\"API client initialized successfully!\")" + ] + }, + { + "cell_type": "markdown", + "id": "3ebce00a", + "metadata": {}, + "source": [ + "## List current registered models" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a7f6a3ac", + "metadata": {}, + "outputs": [], + "source": [ + "# get all versions of all registered models\n", + "from mlflow import MlflowClient\n", + "from datetime import datetime\n", + "\n", + "client = MlflowClient()\n", + "all_registered_models = client.search_registered_models()\n", + "for rm in all_registered_models:\n", + " print(f\"Registered Model: {rm.name}\")\n", + " model_versions = client.search_model_versions(f\"name='{rm.name}'\")\n", + " # get all all attributes of each model version\n", + " for mv in model_versions:\n", + " created_datetime = datetime.fromtimestamp(mv.creation_timestamp / 1000.0) # converting from milliseconds\n", + " created_datetime = created_datetime.strftime(\"%Y-%m-%d %H:%M:%S\") # formatting\n", + " print(f\" Version: {mv.version} | Created at: {created_datetime}\")\n", + " # INFO: do try to access ``mv.aliases``, since mlflow does not populate this field with the ``search_model_versions``.\n", + " # If you need aliases, use ``get_model_version(mv.name, mv.version)`` method instead." + ] + }, + { + "cell_type": "markdown", + "id": "9da152da", + "metadata": {}, + "source": [ + "## Starting a Deployment Job\n", + "\n", + "Deploy a model by specifying the model name and optionally a version or alias." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e122f4df", + "metadata": {}, + "outputs": [], + "source": [ + "# Start a deployment job\n", + "# You can specify either model_version (int) or model_alias (str), but not both\n", + "job = api.deploy.start(\n", + " model_name=all_registered_models[1].name,\n", + " model_alias=\"latest\",\n", + " # or use model_version=1\n", + " with_gpu=False,\n", + " convert_to_onnx=False\n", + ")\n", + "\n", + "print(f\"Deployment job started!\")\n", + "print(f\"Job ID: {job.id}\")\n", + "print(f\"Status: {job.status}\")\n", + "print(f\"Model: {job.model_name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "6481deaa", + "metadata": {}, + "source": [ + "## Monitoring Job Status\n", + "\n", + "Check the status of a deployment job and monitor its progress." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8bc975d8", + "metadata": {}, + "outputs": [], + "source": [ + "# Get job status by ID\n", + "job_status = api.deploy.get_by_id(job.id)\n", + "\n", + "print(f\"Job Status: {job_status.status}\")\n", + "print(f\"Progress: {job_status.progress_percentage}%\")\n", + "print(f\"Current Step: {job_status.current_step}\")\n", + "print(f\"Image Name: {job_status.image_name}\")\n", + "print(f\"Image Tag: {job_status.image_tag}\")\n", + "\n", + "if job_status.error_message:\n", + " print(f\"Error: {job_status.error_message}\")\n", + "\n", + "if job_status.recent_logs:\n", + " print(\"\\nRecent Logs:\")\n", + " for log in job_status.recent_logs[-5:]: # Show last 5 logs\n", + " print(f\" {log}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b647d70e", + "metadata": {}, + "source": [ + "## Polling for Job Completion\n", + "\n", + "Monitor a job until it completes (or fails)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25608260", + "metadata": {}, + "outputs": [], + "source": [ + "def wait_for_job_completion(job_id, max_wait_seconds=600, poll_interval=2):\n", + " \"\"\"\n", + " Wait for a deployment job to complete.\n", + " \n", + " Args:\n", + " job_id: The job ID to monitor\n", + " max_wait_seconds: Maximum time to wait (default 10 minutes)\n", + " poll_interval: How often to check status (default 2 seconds)\n", + " \"\"\"\n", + " start_time = time.time()\n", + " last_msg = \"\"\n", + " while time.time() - start_time < max_wait_seconds:\n", + " job_status = api.deploy.get_by_id(job_id)\n", + " \n", + " msg = f\"Status: {job_status.status} | Progress: {job_status.progress_percentage}% | Step: {job_status.current_step}\"\n", + " if msg != last_msg:\n", + " print(msg)\n", + " last_msg = msg\n", + " \n", + " if job_status.status in ['completed', 'failed', 'cancelled']:\n", + " return job_status\n", + " \n", + " time.sleep(poll_interval)\n", + " \n", + " print(\"Timeout waiting for job completion\")\n", + " return None\n", + "\n", + "# Wait for the job to complete\n", + "final_status = wait_for_job_completion(job.id)\n", + "\n", + "if final_status:\n", + " print(f\"\\nFinal Status: {final_status.status}\")\n", + " if final_status.status == 'completed':\n", + " print(f\"Image built successfully: {final_status.image_name}:{final_status.image_tag}\")\n", + " elif final_status.status == 'failed':\n", + " print(f\"Error: {final_status.error_message}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b86955df", + "metadata": {}, + "source": [ + "## Cancelling a Job\n", + "\n", + "Cancel a running deployment job." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa5a8a44", + "metadata": {}, + "outputs": [], + "source": [ + "# Cancel a job\n", + "cancelled = api.deploy.cancel(job.id)\n", + "\n", + "if cancelled:\n", + " print(f\"Job {job.id} cancelled successfully\")\n", + "else:\n", + " print(f\"Job {job.id} could not be cancelled (may have already completed)\")" + ] + }, + { + "cell_type": "markdown", + "id": "9db375cc", + "metadata": {}, + "source": [ + "## Listing Active Jobs\n", + "\n", + "Get information about active deployment jobs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20b697c9", + "metadata": {}, + "outputs": [], + "source": [ + "# List active jobs\n", + "active_jobs = api.deploy.list_active_jobs()\n", + "\n", + "print(f\"Active jobs count: {active_jobs['active_jobs_count']}\")\n", + "active_jobs" + ] + }, + { + "cell_type": "markdown", + "id": "220d072a", + "metadata": {}, + "source": [ + "## Managing Deployed Images\n", + "\n", + "List and manage Docker images that have been deployed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6022e586", + "metadata": {}, + "outputs": [], + "source": [ + "# List all deployed images\n", + "all_images = api.deploy.list_images()\n", + "\n", + "print(f\"Total deployed images: {len(all_images)}\")\n", + "for img in all_images[:5]: # Show first 5\n", + " print(f\" - {img['full_name']} ({img['size_mb']:.2f} MB)\")\n", + " print(f\" Created: {img['created']}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0b0d1100", + "metadata": {}, + "outputs": [], + "source": [ + "# List images for a specific model\n", + "model_images = api.deploy.list_images(model_name=\"FracAtlas_adapted\")\n", + "\n", + "print(f\"Images for 'FracAtlas_adapted': {len(model_images)}\")\n", + "for img in model_images:\n", + " print(f\" - {img['name']}:{img['tag']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ecaa3783", + "metadata": {}, + "source": [ + "## Checking if an Image Exists\n", + "\n", + "Verify if a specific model image exists." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f6eba6c4", + "metadata": {}, + "outputs": [], + "source": [ + "# Check if an image exists\n", + "exists = api.deploy.image_exists(\n", + " model_name=\"FracAtlas_adapted\",\n", + " tag=\"champion\"\n", + ")\n", + "\n", + "if exists:\n", + " print(\"Image exists!\")\n", + "else:\n", + " print(\"Image not found\")" + ] + }, + { + "cell_type": "markdown", + "id": "c9875937", + "metadata": {}, + "source": [ + "## Removing Deployed Images\n", + "\n", + "Remove Docker images that are no longer needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1cf2c108", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove a specific image tag\n", + "result = api.deploy.remove_image(\n", + " model_name=\"my_model\",\n", + " tag=\"v1.0\"\n", + ")\n", + "\n", + "print(f\"Removal {'successful' if result['success'] else 'failed'}\")\n", + "print(f\"Message: {result['message']}\")\n", + "if result.get('removed_tags'):\n", + " print(f\"Removed tags: {result['removed_tags']}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d8f2513", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove all images for a model\n", + "result = api.deploy.remove_image(\n", + " model_name=\"my_model\"\n", + " # No tag specified = remove all tags\n", + ")\n", + "\n", + "print(f\"Removal {'successful' if result['success'] else 'failed'}\")\n", + "print(f\"Message: {result['message']}\")\n", + "if result.get('removed_tags'):\n", + " print(f\"Removed tags: {', '.join(result['removed_tags'])}\")" + ] + }, + { + "cell_type": "markdown", + "id": "89701dd9", + "metadata": {}, + "source": [ + "## Complete Workflow Example\n", + "\n", + "Here's a complete workflow from deployment to cleanup." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da42289b", + "metadata": {}, + "outputs": [], + "source": [ + "def deploy_and_monitor_model(model_name, model_alias=\"champion\"):\n", + " \"\"\"\n", + " Complete workflow: deploy, monitor, and verify a model.\n", + " \"\"\"\n", + " print(f\"1. Starting deployment for {model_name}@{model_alias}\")\n", + " job = api.deploy.start(\n", + " model_name=model_name,\n", + " model_alias=model_alias,\n", + " with_gpu=False\n", + " )\n", + " print(f\" Job ID: {job.id}\")\n", + " \n", + " print(\"\\n2. Monitoring deployment progress...\")\n", + " final_status = wait_for_job_completion(job.id, poll_interval=5)\n", + " \n", + " if final_status and final_status.status == 'completed':\n", + " print(f\"\\n3. Deployment completed successfully!\")\n", + " print(f\" Image: {final_status.image_name}:{final_status.image_tag}\")\n", + " \n", + " # Verify image exists\n", + " exists = api.deploy.image_exists(\n", + " model_name=model_name,\n", + " tag=final_status.image_tag\n", + " )\n", + " print(f\" Image verification: {'✓' if exists else '✗'}\")\n", + " \n", + " return final_status\n", + " else:\n", + " print(f\"\\n3. Deployment failed or timed out\")\n", + " if final_status and final_status.error_message:\n", + " print(f\" Error: {final_status.error_message}\")\n", + " return None\n", + "\n", + "# Run the complete workflow\n", + "result = deploy_and_monitor_model(\"my_production_model\", \"champion\")" + ] + }, + { + "cell_type": "markdown", + "id": "d3be1d9b", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This notebook covered:\n", + "- ✓ Starting deployment jobs with various configurations\n", + "- ✓ Monitoring job progress and status\n", + "- ✓ Cancelling running jobs\n", + "- ✓ Listing and managing deployed images\n", + "- ✓ Checking image existence\n", + "- ✓ Removing images\n", + "- ✓ Complete workflow examples" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From bd3d82b9a633c58135f45167d70ef2f0dfd8e699 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 4 Dec 2025 15:01:30 -0300 Subject: [PATCH 3/7] Remove Api import from __init__.py --- datamint/api/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/datamint/api/__init__.py b/datamint/api/__init__.py index 79c58267..e69de29b 100644 --- a/datamint/api/__init__.py +++ b/datamint/api/__init__.py @@ -1 +0,0 @@ -from .client import Api \ No newline at end of file From 1517de75b0ee398a5e5233e31ee6b55ea25ba87c Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 4 Dec 2025 16:37:01 -0300 Subject: [PATCH 4/7] Refactor model deployment API and add deployment example to FracAtlas classification notebook - Moved DeployJob class to a new file for better organization. - Updated deploy_model_api.py to import DeployJob from the new location. - Removed unused parameters from the deployment job example in the notebook. - Added a new section in the FracAtlas classification notebook for model deployment, including starting a deployment job and checking job status. --- datamint/api/endpoints/deploy_model_api.py | 20 +------- datamint/entities/deployjob.py | 18 +++++++ notebooks/deploy_model_demo.ipynb | 1 - .../use_cases/fracatlas_classification.ipynb | 50 ++++++++++++++++++- 4 files changed, 68 insertions(+), 21 deletions(-) create mode 100644 datamint/entities/deployjob.py diff --git a/datamint/api/endpoints/deploy_model_api.py b/datamint/api/endpoints/deploy_model_api.py index fc2d2e28..91979f38 100644 --- a/datamint/api/endpoints/deploy_model_api.py +++ b/datamint/api/endpoints/deploy_model_api.py @@ -1,24 +1,6 @@ -from typing import Optional import httpx from ..entity_base_api import EntityBaseApi, ApiConfig -from datamint.entities.base_entity import BaseEntity - - -class DeployJob(BaseEntity): - status: str - model_name: str - model_version: Optional[int] = None - model_alias: Optional[str] = None - image_name: Optional[str] = None - image_tag: Optional[str] = None - error_message: Optional[str] = None - progress_percentage: int = 0 - current_step: Optional[str] = None - with_gpu: bool = False - recent_logs: Optional[list[str]] = None - started_at: Optional[str] = None - completed_at: Optional[str] = None - +from datamint.entities.deployjob import DeployJob class DeployModelApi(EntityBaseApi[DeployJob]): """API handler for model deployment endpoints.""" diff --git a/datamint/entities/deployjob.py b/datamint/entities/deployjob.py new file mode 100644 index 00000000..7f55ddef --- /dev/null +++ b/datamint/entities/deployjob.py @@ -0,0 +1,18 @@ +from datamint.entities.base_entity import BaseEntity + + +class DeployJob(BaseEntity): + id: str + status: str + model_name: str + model_version: int | None = None + model_alias: str | None = None + image_name: str | None = None + image_tag: str | None = None + error_message: str | None = None + progress_percentage: int = 0 + current_step: str | None = None + with_gpu: bool = False + recent_logs: list[str] | None = None + started_at: str | None = None + completed_at: str | None = None \ No newline at end of file diff --git a/notebooks/deploy_model_demo.ipynb b/notebooks/deploy_model_demo.ipynb index 4e2e72b4..e1e45892 100644 --- a/notebooks/deploy_model_demo.ipynb +++ b/notebooks/deploy_model_demo.ipynb @@ -111,7 +111,6 @@ " model_alias=\"latest\",\n", " # or use model_version=1\n", " with_gpu=False,\n", - " convert_to_onnx=False\n", ")\n", "\n", "print(f\"Deployment job started!\")\n", diff --git a/notebooks/use_cases/fracatlas_classification.ipynb b/notebooks/use_cases/fracatlas_classification.ipynb index 4ac2fa48..5dadb40a 100644 --- a/notebooks/use_cases/fracatlas_classification.ipynb +++ b/notebooks/use_cases/fracatlas_classification.ipynb @@ -374,7 +374,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "da4a5dde", "metadata": {}, "outputs": [], @@ -961,6 +961,54 @@ "response.json()" ] }, + { + "cell_type": "markdown", + "id": "0f673617", + "metadata": {}, + "source": [ + "# 7 Deployment" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3328ca7a", + "metadata": {}, + "outputs": [], + "source": [ + "# Start a deployment job\n", + "# You can specify either model_version (int) or model_alias (str), but not both\n", + "job = api.deploy.start(\n", + " model_name=\"FracAtlas_adapted\",\n", + " model_alias=\"latest\",\n", + " # or use model_version=1\n", + " with_gpu=False,\n", + ")\n", + "\n", + "print(f\"Deployment job started!\")\n", + "print(f\"Job ID: {job.id}\")\n", + "print(f\"Status: {job.status}\")\n", + "print(f\"Model: {job.model_name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f38939d5", + "metadata": {}, + "outputs": [], + "source": [ + "# Get job status by ID\n", + "job = api.deploy.get_by_id(job.id)\n", + "\n", + "print(f\"Job Status: {job.status}\")\n", + "print(f\"Progress: {job.progress_percentage}%\")\n", + "print(f\"Image Name: {job.image_name}\")\n", + "\n", + "if job.error_message:\n", + " print(f\"Error: {job.error_message}\")" + ] + }, { "cell_type": "markdown", "id": "905cea65", From 8e43fb7a70795a3b1430c92e01cb5edfb8086669 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Thu, 4 Dec 2025 16:39:54 -0300 Subject: [PATCH 5/7] Refactor imports in AnnotationsApi for clarity and organization --- datamint/api/endpoints/annotations_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/datamint/api/endpoints/annotations_api.py b/datamint/api/endpoints/annotations_api.py index 1c9f8862..81f16c2a 100644 --- a/datamint/api/endpoints/annotations_api.py +++ b/datamint/api/endpoints/annotations_api.py @@ -1,9 +1,9 @@ -from typing import Any, Sequence, Literal, BinaryIO, Generator, IO +from typing import Literal, BinaryIO, IO +from collections.abc import Sequence, Generator import httpx from datetime import date import logging from ..entity_base_api import ApiConfig, CreatableEntityApi, DeletableEntityApi -from .models_api import ModelsApi from datamint.entities.annotations.annotation import Annotation from datamint.entities.resource import Resource from datamint.api.dto import AnnotationType, CreateAnnotationDto, LineGeometry, BoxGeometry, CoordinateSystem, Geometry @@ -42,6 +42,8 @@ def __init__(self, client: Optional HTTP client instance. If None, a new one will be created. """ from .resources_api import ResourcesApi + from .models_api import ModelsApi + super().__init__(config, Annotation, 'annotations', client) self._models_api = ModelsApi(config, client=client) if models_api is None else models_api self._resources_api = ResourcesApi( From 72c2919e68c36ec2a0e70f8befd7b91765b41306 Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 8 Dec 2025 11:27:07 -0300 Subject: [PATCH 6/7] fixed get_experiment_by_name in DatamintStore --- datamint/api/base_api.py | 1 - datamint/mlflow/tracking/datamint_store.py | 34 ++++++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/datamint/api/base_api.py b/datamint/api/base_api.py index 0ac0ecbc..78cf5928 100644 --- a/datamint/api/base_api.py +++ b/datamint/api/base_api.py @@ -503,7 +503,6 @@ def convert_format(bytes_array: bytes, raise ValueError("Could not determine mimetype from content.") content_io = BytesIO(bytes_array) if mimetype.endswith('/dicom'): - import pydicom return pydicom.dcmread(content_io) elif mimetype.startswith('image/'): return Image.open(content_io) diff --git a/datamint/mlflow/tracking/datamint_store.py b/datamint/mlflow/tracking/datamint_store.py index 82e96bb1..8acc1a72 100644 --- a/datamint/mlflow/tracking/datamint_store.py +++ b/datamint/mlflow/tracking/datamint_store.py @@ -1,6 +1,9 @@ from mlflow.store.tracking.rest_store import RestStore +from mlflow.exceptions import MlflowException +from mlflow.utils.proto_json_utils import message_to_json from functools import partial import json +from typing_extensions import override class DatamintStore(RestStore): @@ -14,7 +17,7 @@ def __init__(self, store_uri: str, artifact_uri=None, force_valid=True): from datamint.mlflow.env_utils import setup_mlflow_environment from mlflow.utils.credentials import get_default_host_creds setup_mlflow_environment() - + if store_uri.startswith('datamint://') or 'datamint.io' in store_uri or force_valid: self.invalid = False else: @@ -26,7 +29,6 @@ def __init__(self, store_uri: str, artifact_uri=None, force_valid=True): def create_experiment(self, name, artifact_location=None, tags=None, project_id: str | None = None) -> str: from mlflow.protos.service_pb2 import CreateExperiment - from mlflow.utils.proto_json_utils import message_to_json from datamint.mlflow.tracking.fluent import get_active_project_id if self.invalid: @@ -44,3 +46,31 @@ def create_experiment(self, name, artifact_location=None, tags=None, project_id: response_proto = self._call_endpoint(CreateExperiment, req_body) return response_proto.experiment_id + + @override + def get_experiment_by_name(self, experiment_name, project_id: str | None = None): + from datamint.mlflow.tracking.fluent import get_active_project_id + from mlflow.protos.service_pb2 import GetExperimentByName + from mlflow.entities import Experiment + from mlflow.protos import databricks_pb2 + + if self.invalid: + return super().get_experiment_by_name(experiment_name) + if project_id is None: + project_id = get_active_project_id() + try: + req_body = message_to_json(GetExperimentByName(experiment_name=experiment_name)) + if project_id: + body = json.loads(req_body) + body["project_id"] = project_id + req_body = json.dumps(body) + + response_proto = self._call_endpoint(GetExperimentByName, req_body) + return Experiment.from_proto(response_proto.experiment) + except MlflowException as e: + if e.error_code == databricks_pb2.ErrorCode.Name( + databricks_pb2.RESOURCE_DOES_NOT_EXIST + ): + return None + else: + raise From 0d83b09b0b3f201d41f9076f7a8fa2fb1ae66fde Mon Sep 17 00:00:00 2001 From: Lucashsmello Date: Mon, 8 Dec 2025 11:34:29 -0300 Subject: [PATCH 7/7] updated docs in notebook --- .../use_cases/fracatlas_classification.ipynb | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/notebooks/use_cases/fracatlas_classification.ipynb b/notebooks/use_cases/fracatlas_classification.ipynb index 5dadb40a..4df2b635 100644 --- a/notebooks/use_cases/fracatlas_classification.ipynb +++ b/notebooks/use_cases/fracatlas_classification.ipynb @@ -7,15 +7,27 @@ "source": [ "# Fracture Classification with Datamint and FracAtlas Dataset\n", "\n", - "This notebook demonstrates how to build an end-to-end binary classification pipeline using **Datamint** and the **FracAtlas** dataset. You will learn how to:\n", + "This notebook demonstrates how to build an end-to-end binary classification pipeline using **Datamint** and the **FracAtlas** dataset.\n", "\n", - "1. **Set up a Datamint project** for managing medical imaging data\n", - "2. **Download and upload** the FracAtlas dataset to Datamint\n", - "3. **Create annotations** for classification tasks\n", - "4. **Build a PyTorch Dataset** that integrates with Datamint\n", - "5. **Train a ResNet-18 model** using PyTorch Lightning\n", - "6. **Track experiments** with MLflow integration\n", - "7. **Deploy the model** for inference using Datamint's model serving\n", + "## Overview\n", + "\n", + "You will learn how to:\n", + "- **Set up a Datamint project** for managing medical imaging data\n", + "- **Set up the FracAtlas dataset** to Datamint\n", + "- **Build a PyTorch Dataset** that integrates with Datamint\n", + "- **Train a model** using Pytorch Lightning\n", + "- **Track experiments** with MLflow integration\n", + "- **Deploy the model** for inference using Datamint's model serving\n", + "\n", + "## Table of Contents\n", + "\n", + "1. [Setup: Create Project and Upload Dataset](#1-setup-create-project-and-upload-dataset)\n", + "2. [Dataset Preparation](#2-dataset-preparation)\n", + "3. [Model Training](#3-model-training)\n", + "4. [Model Inference](#4-model-inference)\n", + "5. [Model Deployment](#5-model-deployment)\n", + "6. [Testing and Serving](#6-testing-and-serving)\n", + "7. [Deployment](#7-deployment)\n", "\n", "## Required Dependencies\n", "\n", @@ -966,7 +978,7 @@ "id": "0f673617", "metadata": {}, "source": [ - "# 7 Deployment" + "# 7. Deployment" ] }, {