diff --git a/ads/aqua/app.py b/ads/aqua/app.py index 25c055b26..9121cd10f 100644 --- a/ads/aqua/app.py +++ b/ads/aqua/app.py @@ -22,10 +22,9 @@ is_valid_ocid, load_config, ) -from ads.aqua.constants import UNKNOWN from ads.common import oci_client as oc from ads.common.auth import default_signer -from ads.common.utils import extract_region, is_path_exists +from ads.common.utils import UNKNOWN, extract_region, is_path_exists from ads.config import ( AQUA_TELEMETRY_BUCKET, AQUA_TELEMETRY_BUCKET_NS, diff --git a/ads/aqua/common/utils.py b/ads/aqua/common/utils.py index a299ef297..1272b4329 100644 --- a/ads/aqua/common/utils.py +++ b/ads/aqua/common/utils.py @@ -19,7 +19,6 @@ from string import Template from typing import List, Union -import fsspec import oci from cachetools import TTLCache, cached from huggingface_hub.constants import HF_HUB_CACHE @@ -58,7 +57,6 @@ SUPPORTED_FILE_FORMATS, TEI_CONTAINER_DEFAULT_HOST, TGI_INFERENCE_RESTRICTED_PARAMS, - UNKNOWN, UNKNOWN_JSON_STR, VLLM_INFERENCE_RESTRICTED_PARAMS, ) @@ -68,7 +66,13 @@ from ads.common.extended_enum import ExtendedEnum from ads.common.object_storage_details import ObjectStorageDetails from ads.common.oci_resource import SEARCH_TYPE, OCIResource -from ads.common.utils import copy_file, get_console_link, upload_to_os +from ads.common.utils import ( + UNKNOWN, + copy_file, + get_console_link, + read_file, + upload_to_os, +) from ads.config import ( AQUA_MODEL_DEPLOYMENT_FOLDER, AQUA_SERVICE_MODELS_BUCKET, @@ -228,15 +232,6 @@ def get_artifact_path(custom_metadata_list: List) -> str: return UNKNOWN -def read_file(file_path: str, **kwargs) -> str: - try: - with fsspec.open(file_path, "r", **kwargs.get("auth", {})) as f: - return f.read() - except Exception as e: - logger.debug(f"Failed to read file {file_path}. {e}") - return UNKNOWN - - @threaded() def load_config(file_path: str, config_file_name: str, **kwargs) -> dict: artifact_path = f"{file_path.rstrip('/')}/{config_file_name}" diff --git a/ads/aqua/constants.py b/ads/aqua/constants.py index 8e0d5ca76..107570478 100644 --- a/ads/aqua/constants.py +++ b/ads/aqua/constants.py @@ -3,7 +3,6 @@ # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ """This module defines constants used in ads.aqua module.""" -UNKNOWN = "" UNKNOWN_VALUE = "" READY_TO_IMPORT_STATUS = "TRUE" UNKNOWN_DICT = {} diff --git a/ads/aqua/evaluation/evaluation.py b/ads/aqua/evaluation/evaluation.py index f265c0821..64acddb63 100644 --- a/ads/aqua/evaluation/evaluation.py +++ b/ads/aqua/evaluation/evaluation.py @@ -55,7 +55,6 @@ JOB_INFRASTRUCTURE_TYPE_DEFAULT_NETWORKING, LIFECYCLE_DETAILS_MISSING_JOBRUN, NB_SESSION_IDENTIFIER, - UNKNOWN, ) from ads.aqua.evaluation.constants import ( EVAL_TERMINATION_STATE, @@ -79,7 +78,7 @@ from ads.aqua.evaluation.errors import EVALUATION_JOB_EXIT_CODE_MESSAGE from ads.common.auth import default_signer from ads.common.object_storage_details import ObjectStorageDetails -from ads.common.utils import get_console_link, get_files, get_log_links +from ads.common.utils import UNKNOWN, get_console_link, get_files, get_log_links from ads.config import ( AQUA_JOB_SUBNET_ID, COMPARTMENT_OCID, diff --git a/ads/aqua/finetuning/finetuning.py b/ads/aqua/finetuning/finetuning.py index 229f4deae..02d73c526 100644 --- a/ads/aqua/finetuning/finetuning.py +++ b/ads/aqua/finetuning/finetuning.py @@ -30,7 +30,6 @@ DEFAULT_FT_REPLICA, DEFAULT_FT_VALIDATION_SET_SIZE, JOB_INFRASTRUCTURE_TYPE_DEFAULT_NETWORKING, - UNKNOWN, UNKNOWN_DICT, ) from ads.aqua.data import AquaResourceIdentifier @@ -45,7 +44,7 @@ ) from ads.common.auth import default_signer from ads.common.object_storage_details import ObjectStorageDetails -from ads.common.utils import get_console_link +from ads.common.utils import UNKNOWN, get_console_link from ads.config import ( AQUA_FINETUNING_CONTAINER_OVERRIDE_FLAG_METADATA_NAME, AQUA_JOB_SUBNET_ID, diff --git a/ads/aqua/model/model.py b/ads/aqua/model/model.py index 40a954f06..b224112fe 100644 --- a/ads/aqua/model/model.py +++ b/ads/aqua/model/model.py @@ -59,7 +59,6 @@ READY_TO_IMPORT_STATUS, TRAINING_METRICS_FINAL, TRINING_METRICS, - UNKNOWN, VALIDATION_METRICS, VALIDATION_METRICS_FINAL, ) @@ -80,7 +79,7 @@ ) from ads.common.auth import default_signer from ads.common.oci_resource import SEARCH_TYPE, OCIResource -from ads.common.utils import get_console_link +from ads.common.utils import UNKNOWN, get_console_link from ads.config import ( AQUA_DEPLOYMENT_CONTAINER_CMD_VAR_METADATA_NAME, AQUA_DEPLOYMENT_CONTAINER_METADATA_NAME, diff --git a/ads/aqua/modeldeployment/deployment.py b/ads/aqua/modeldeployment/deployment.py index 4743399bc..4ec0d0d93 100644 --- a/ads/aqua/modeldeployment/deployment.py +++ b/ads/aqua/modeldeployment/deployment.py @@ -27,7 +27,6 @@ AQUA_MODEL_TYPE_CUSTOM, AQUA_MODEL_TYPE_SERVICE, MODEL_BY_REFERENCE_OSS_PATH_KEY, - UNKNOWN, UNKNOWN_DICT, ) from ads.aqua.data import AquaResourceIdentifier @@ -35,7 +34,7 @@ from ads.aqua.model import AquaModelApp from ads.aqua.modeldeployment.entities import AquaDeployment, AquaDeploymentDetail from ads.common.object_storage_details import ObjectStorageDetails -from ads.common.utils import get_log_links +from ads.common.utils import UNKNOWN, get_log_links from ads.config import ( AQUA_DEPLOYMENT_CONTAINER_CMD_VAR_METADATA_NAME, AQUA_DEPLOYMENT_CONTAINER_METADATA_NAME, diff --git a/ads/aqua/modeldeployment/entities.py b/ads/aqua/modeldeployment/entities.py index 9a6eb5b7b..8e34f986d 100644 --- a/ads/aqua/modeldeployment/entities.py +++ b/ads/aqua/modeldeployment/entities.py @@ -11,10 +11,10 @@ ) from ads.aqua.common.enums import Tags -from ads.aqua.constants import UNKNOWN, UNKNOWN_DICT +from ads.aqua.constants import UNKNOWN_DICT from ads.aqua.data import AquaResourceIdentifier from ads.common.serializer import DataClassSerializable -from ads.common.utils import get_console_link +from ads.common.utils import UNKNOWN, get_console_link @dataclass diff --git a/ads/common/utils.py b/ads/common/utils.py index 203c49821..f2fd992b3 100644 --- a/ads/common/utils.py +++ b/ads/common/utils.py @@ -22,7 +22,7 @@ from enum import Enum from io import DEFAULT_BUFFER_SIZE from textwrap import fill -from typing import Dict, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union from urllib import request from urllib.parse import urlparse @@ -63,6 +63,8 @@ # Maximum distinct values by cardinality will be used for plotting MAX_DISPLAY_VALUES = 10 +UNKNOWN = "" + # par link of the index json file. PAR_LINK = "https://objectstorage.us-ashburn-1.oraclecloud.com/p/WyjtfVIG0uda-P3-2FmAfwaLlXYQZbvPZmfX1qg0-sbkwEQO6jpwabGr2hMDBmBp/n/ociodscdev/b/service-conda-packs/o/service_pack/index.json" @@ -82,6 +84,7 @@ color=["teal", "blueviolet", "forestgreen", "peru", "y", "dodgerblue", "r"] ) + # sqlalchemy engines _engines = {} @@ -149,6 +152,22 @@ def oci_key_location(): ) +def text_sanitizer(content): + if isinstance(content, str): + return ( + content.replace("“", '"') + .replace("”", '"') + .replace("’", "'") + .replace("‘", "'") + .replace("—", "-") + .encode("utf-8", "ignore") + .decode("utf-8", "ignore") + ) + if isinstance(content, dict): + return json.dumps(content) + return str(content) + + @deprecated( "2.5.10", details="Deprecated, use: from ads.common.auth import AuthState; AuthState().oci_config_path", @@ -212,6 +231,37 @@ def random_valid_ocid(prefix="ocid1.dataflowapplication.oc1.iad"): return f"{left}.{fake}" +def parse_bool(value: Any) -> bool: + """ + Converts a value to boolean. For strings, it interprets 'true', '1', or 'yes' + (case insensitive) as True; everything else as False. + + Parameters + ---------- + value : Any + The value to convert to boolean. + + Returns + ------- + bool + The boolean interpretation of the value. + """ + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes") + return bool(value) + + +def read_file(file_path: str, **kwargs) -> str: + try: + with fsspec.open(file_path, "r", **kwargs.get("auth", {})) as f: + return f.read() + except Exception as e: + logger.debug(f"Failed to read file {file_path}. {e}") + return UNKNOWN + + def get_dataframe_styles(max_width=75): """Styles used for dataframe, example usage: diff --git a/ads/config.py b/ads/config.py index cd4ee2b07..53342988f 100644 --- a/ads/config.py +++ b/ads/config.py @@ -80,6 +80,9 @@ DEBUG_TELEMETRY = os.environ.get("DEBUG_TELEMETRY", None) AQUA_SERVICE_NAME = "aqua" DATA_SCIENCE_SERVICE_NAME = "data-science" +USER = "USER" +SERVICE = "SERVICE" + THREADED_DEFAULT_TIMEOUT = 5 diff --git a/ads/model/common/utils.py b/ads/model/common/utils.py index fc4cd8533..3a943b408 100644 --- a/ads/model/common/utils.py +++ b/ads/model/common/utils.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*-- # Copyright (c) 2022, 2023 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ @@ -7,16 +6,29 @@ import json import os import tempfile -import yaml from typing import Any, Dict, Optional from zipfile import ZipFile -from ads.common import utils +import yaml + +from ads.common import utils +from ads.common.extended_enum import ExtendedEnum DEPRECATE_AS_ONNX_WARNING = "This attribute `as_onnx` will be deprecated in the future. You can choose specific format by setting `model_save_serializer`." DEPRECATE_USE_TORCH_SCRIPT_WARNING = "This attribute `use_torch_script` will be deprecated in the future. You can choose specific format by setting `model_save_serializer`." +class MetadataArtifactPathType(ExtendedEnum): + """ + Enum for defining metadata artifact path type. + Can be either local path or OSS path. It can also be the content itself. + """ + + LOCAL = "local" + OSS = "oss" + CONTENT = "content" + + def _extract_locals( locals: Dict[str, Any], filter_out_nulls: Optional[bool] = True ) -> Dict[str, Any]: diff --git a/ads/model/datascience_model.py b/ads/model/datascience_model.py index f94564966..2d682fb9b 100644 --- a/ads/model/datascience_model.py +++ b/ads/model/datascience_model.py @@ -19,12 +19,14 @@ from ads.common import utils from ads.common.extended_enum import ExtendedEnum from ads.common.object_storage_details import ObjectStorageDetails +from ads.common.utils import is_path_exists from ads.config import ( AQUA_SERVICE_MODELS_BUCKET as SERVICE_MODELS_BUCKET, ) from ads.config import ( COMPARTMENT_OCID, PROJECT_OCID, + USER, ) from ads.feature_engineering.schema import Schema from ads.jobs.builders.base import Builder @@ -33,6 +35,7 @@ SmallArtifactDownloader, ) from ads.model.artifact_uploader import LargeArtifactUploader, SmallArtifactUploader +from ads.model.common.utils import MetadataArtifactPathType from ads.model.model_metadata import ( MetadataCustomCategory, ModelCustomMetadata, @@ -41,6 +44,7 @@ ModelTaxonomyMetadata, ) from ads.model.service.oci_datascience_model import ( + ModelMetadataArtifactDetails, ModelProvenanceNotFoundError, OCIDataScienceModel, ) @@ -71,6 +75,11 @@ def __init__( super().__init__(msg) +class PathNotFoundError(Exception): + def __init__(self, msg="The given path doesn't exist."): + super().__init__(msg) + + class ModelFileDescriptionError(Exception): # pragma: no cover def __init__(self, msg="Model File Description file is not set up."): super().__init__(msg) @@ -1590,7 +1599,11 @@ def delete( @classmethod def list( - cls, compartment_id: str = None, project_id: str = None, **kwargs + cls, + compartment_id: str = None, + project_id: str = None, + category: str = USER, + **kwargs, ) -> List["DataScienceModel"]: """Lists datascience models in a given compartment. @@ -1600,6 +1613,8 @@ def list( The compartment OCID. project_id: (str, optional). Defaults to `None`. The project OCID. + category: (str, optional). Defaults to `USER`. + The category of Model. Allowed values are: "USER", "SERVICE" kwargs Additional keyword arguments for filtering models. @@ -1611,13 +1626,17 @@ def list( return [ cls()._update_from_oci_dsc_model(model) for model in OCIDataScienceModel.list_resource( - compartment_id, project_id=project_id, **kwargs + compartment_id, project_id=project_id, category=category, **kwargs ) ] @classmethod def list_df( - cls, compartment_id: str = None, project_id: str = None, **kwargs + cls, + compartment_id: str = None, + project_id: str = None, + category: str = USER, + **kwargs, ) -> "pandas.DataFrame": """Lists datascience models in a given compartment. @@ -1627,6 +1646,8 @@ def list_df( The compartment OCID. project_id: (str, optional). Defaults to `None`. The project OCID. + category: (str, optional). Defaults to `None`. + The category of Model. kwargs Additional keyword arguments for filtering models. @@ -1637,7 +1658,7 @@ def list_df( """ records = [] for model in OCIDataScienceModel.list_resource( - compartment_id, project_id=project_id, **kwargs + compartment_id, project_id=project_id, category=category, **kwargs ): records.append( { @@ -1788,7 +1809,6 @@ def _update_from_oci_dsc_model( self.set_spec(self.CONST_ARTIFACT, file_name_info["filename"]) except: pass - return self def to_dict(self) -> Dict: @@ -2214,3 +2234,313 @@ def find_model_idx(): else: # model found case self.model_file_description["models"].pop(modelSearchIdx) + + def create_custom_metadata_artifact( + self, + metadata_key_name: str, + artifact_path_or_content: str, + path_type: MetadataArtifactPathType = MetadataArtifactPathType.LOCAL, + ) -> ModelMetadataArtifactDetails: + """Creates model custom metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model custom metadata key + + artifact_path_or_content: str + The model custom metadata artifact path to be upload. It can also be the actual content of the custom metadata + + path_type: MetadataArtifactPathType + Can be either of MetadataArtifactPathType.LOCAL , MetadataArtifactPathType.OSS , MetadataArtifactPathType.CONTENT + Specifies what type of path is to be provided for metadata artifact. + Can be either local , oss or the actual content itself + + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact creation info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + return self.dsc_model.create_custom_metadata_artifact( + metadata_key_name=metadata_key_name, + artifact_path=artifact_path_or_content, + path_type=path_type, + ) + + def create_defined_metadata_artifact( + self, + metadata_key_name: str, + artifact_path_or_content: str, + path_type: MetadataArtifactPathType = MetadataArtifactPathType.LOCAL, + ) -> ModelMetadataArtifactDetails: + """Creates model defined metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model defined metadata key + + artifact_path_or_content: str + The model defined metadata artifact path to be upload. It can also be the actual content of the defined metadata + + path_type: MetadataArtifactPathType + Can be either of MetadataArtifactPathType.LOCAL , MetadataArtifactPathType.OSS , MetadataArtifactPathType.CONTENT + Specifies what type of path is to be provided for metadata artifact. + Can be either local , oss or the actual content itself + + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact creation info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + return self.dsc_model.create_defined_metadata_artifact( + metadata_key_name=metadata_key_name, + artifact_path=artifact_path_or_content, + path_type=path_type, + ) + + def update_custom_metadata_artifact( + self, + metadata_key_name: str, + artifact_path_or_content: str, + path_type: MetadataArtifactPathType = MetadataArtifactPathType.LOCAL, + ) -> ModelMetadataArtifactDetails: + """Update model custom metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model custom metadata key + + artifact_path_or_content: str + The model custom metadata artifact path. It can also be the actual content of the custom metadata + + path_type: MetadataArtifactPathType + Can be either of MetadataArtifactPathType.LOCAL , MetadataArtifactPathType.OSS , MetadataArtifactPathType.CONTENT + Specifies what type of path is to be provided for metadata artifact. + Can be either local , oss or the actual content itself + + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact update info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + return self.dsc_model.update_custom_metadata_artifact( + metadata_key_name=metadata_key_name, + artifact_path=artifact_path_or_content, + path_type=path_type, + ) + + def update_defined_metadata_artifact( + self, + metadata_key_name: str, + artifact_path_or_content: str, + path_type: MetadataArtifactPathType = MetadataArtifactPathType.LOCAL, + ) -> ModelMetadataArtifactDetails: + """Update model defined metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model defined metadata key + + artifact_path_or_content: str + The model defined metadata artifact path. It can also be the actual content of the defined metadata + + path_type: MetadataArtifactPathType + Can be either of MetadataArtifactPathType.LOCAL , MetadataArtifactPathType.OSS , MetadataArtifactPathType.CONTENT + Specifies what type of path is to be provided for metadata artifact. + Can be either local , oss or the actual content itself + + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact update info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + return self.dsc_model.update_defined_metadata_artifact( + metadata_key_name=metadata_key_name, + artifact_path=artifact_path_or_content, + path_type=path_type, + ) + + def get_custom_metadata_artifact( + self, metadata_key_name: str, target_dir: str, override: bool = False + ) -> bytes: + """Downloads model custom metadata artifact content for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the custom metadata key of the model + + target_dir: str + The local file path where downloaded model custom metadata artifact will be saved. + + override: bool + A boolean flag that controls downloaded metadata artifact file overwriting + - If True, overwrites the file if it already exists. + - If False (default), raises a `FileExistsError` if the file exists. + Returns + ------- + bytes + File content of the custom metadata artifact + + """ + if not is_path_exists(target_dir): + raise PathNotFoundError(f"Path : {target_dir} does not exist") + + file_content = self.dsc_model.get_custom_metadata_artifact( + metadata_key_name=metadata_key_name + ) + artifact_file_path = os.path.join(target_dir, f"{metadata_key_name}") + + if not override and os.path.exists(artifact_file_path): + raise FileExistsError(f"File already exists: {artifact_file_path}") + + with open(artifact_file_path, "wb") as _file: + _file.write(file_content) + logger.debug(f"Artifact downloaded to location - {artifact_file_path}") + return file_content + + def get_defined_metadata_artifact( + self, metadata_key_name: str, target_dir: str, override: bool = False + ) -> bytes: + """Downloads model defined metadata artifact content for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + + target_dir: str + The local file path where downloaded model defined metadata artifact will be saved. + + override: bool + A boolean flag that controls downloaded metadata artifact file overwriting + - If True, overwrites the file if it already exists. + - If False (default), raises a `FileExistsError` if the file exists. + Returns + ------- + bytes + File content of the custom metadata artifact + + """ + if not is_path_exists(target_dir): + raise PathNotFoundError(f"Path : {target_dir} does not exist") + + file_content = self.dsc_model.get_defined_metadata_artifact( + metadata_key_name=metadata_key_name + ) + artifact_file_path = os.path.join(target_dir, f"{metadata_key_name}") + + if not override and os.path.exists(artifact_file_path): + raise FileExistsError(f"File already exists: {artifact_file_path}") + + with open(artifact_file_path, "wb") as _file: + _file.write(file_content) + logger.debug(f"Artifact downloaded to location - {artifact_file_path}") + return file_content + + def delete_custom_metadata_artifact( + self, metadata_key_name: str + ) -> ModelMetadataArtifactDetails: + """Deletes model custom metadata artifact for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact delete call info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'X-Content-Type-Options': 'nosniff', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + return self.dsc_model.delete_custom_metadata_artifact( + metadata_key_name=metadata_key_name + ) + + def delete_defined_metadata_artifact( + self, metadata_key_name: str + ) -> ModelMetadataArtifactDetails: + """Deletes model defined metadata artifact for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact delete call info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'X-Content-Type-Options': 'nosniff', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + return self.dsc_model.delete_defined_metadata_artifact( + metadata_key_name=metadata_key_name + ) diff --git a/ads/model/model_metadata.py b/ads/model/model_metadata.py index 708cfc8de..2d9644670 100644 --- a/ads/model/model_metadata.py +++ b/ads/model/model_metadata.py @@ -24,6 +24,7 @@ from ads.common.extended_enum import ExtendedEnum from ads.common.object_storage_details import ObjectStorageDetails from ads.common.serializer import DataClassSerializable +from ads.common.utils import parse_bool from ads.dataset import factory try: @@ -86,11 +87,13 @@ class MetadataCustomPrintColumns(ExtendedEnum): VALUE = "Value" DESCRIPTION = "Description" CATEGORY = "Category" + HAS_ARTIFACT = "HasArtifact" class MetadataTaxonomyPrintColumns(ExtendedEnum): KEY = "Key" VALUE = "Value" + HAS_ARTIFACT = "HasArtifact" class MetadataTaxonomyKeys(ExtendedEnum): @@ -100,6 +103,10 @@ class MetadataTaxonomyKeys(ExtendedEnum): ALGORITHM = "Algorithm" HYPERPARAMETERS = "Hyperparameters" ARTIFACT_TEST_RESULT = "ArtifactTestResults" + README = "Readme" + LICENSE = "License" + DEPLOYMENT_CONFIGURATION = "DeploymentConfiguration" + FINETUNE_CONFIGURATION = "FineTuneConfiguration" class MetadataCustomKeys(ExtendedEnum): @@ -336,13 +343,14 @@ def _from_oci_metadata(cls, oci_metadata_item) -> "ModelMetadataItem": """Creates a new metadata item from the OCI metadata item.""" oci_metadata_item = to_dict(oci_metadata_item) key_value_map = {field: oci_metadata_item.get(field) for field in cls._FIELDS} - if isinstance(key_value_map["value"], str): try: key_value_map["value"] = json.loads(oci_metadata_item.get("value")) + key_value_map["has_artifact"] = parse_bool( + oci_metadata_item.get("has_artifact") + ) except Exception: pass - return cls(**key_value_map) def __hash__(self): @@ -398,15 +406,12 @@ class ModelTaxonomyMetadataItem(ModelMetadataItem): Validates metadata item. """ - _FIELDS = ["key", "value"] + _FIELDS = ["key", "value", "has_artifact"] - def __init__( - self, - key: str, - value: str = None, - ): + def __init__(self, key: str, value: str = None, has_artifact: bool = False): self.key = key self.value = value + self.has_artifact = has_artifact @property def key(self) -> str: @@ -432,6 +437,14 @@ def key(self, key: str): raise ValueError("The key cannot be empty.") self._key = key + @property + def has_artifact(self) -> bool: + return self._has_artifact + + @has_artifact.setter + def has_artifact(self, has_artifact: bool): + self._has_artifact = has_artifact is True + @property def value(self) -> str: return self._value @@ -471,7 +484,7 @@ def reset(self) -> None: """ self.update(value=None) - def update(self, value: str) -> None: + def update(self, value: str, has_artifact: bool = False) -> None: """Updates metadata item value. Parameters @@ -485,6 +498,7 @@ def update(self, value: str) -> None: Nothing. """ self.value = value + self.has_artifact = has_artifact def validate(self) -> bool: """Validates metadata item. @@ -555,7 +569,7 @@ class ModelCustomMetadataItem(ModelTaxonomyMetadataItem): Validates metadata item. """ - _FIELDS = ["key", "value", "description", "category"] + _FIELDS = ["key", "value", "description", "category", "has_artifact"] def __init__( self, @@ -563,10 +577,12 @@ def __init__( value: str = None, description: str = None, category: str = None, + has_artifact: bool = False, ): super().__init__(key=key, value=value) self.description = description self.category = category + self.has_artifact = has_artifact @property def description(self) -> str: @@ -586,6 +602,17 @@ def description(self, description: str): self._description = description + @property + def has_artifact(self) -> bool: + return self._has_artifact + + @has_artifact.setter + def has_artifact(self, has_artifact: bool): + if not has_artifact: + self._has_artifact = False + else: + self._has_artifact = has_artifact + @property def category(self) -> str: return self._category @@ -631,7 +658,9 @@ def reset(self) -> None: """ self.update(value=None, description=None, category=None) - def update(self, value: str, description: str, category: str) -> None: + def update( + self, value: str, description: str, category: str, has_artifact: bool = False + ) -> None: """Updates metadata item. Parameters @@ -651,6 +680,7 @@ def update(self, value: str, description: str, category: str) -> None: self.value = value self.description = description self.category = category + self.has_artifact = has_artifact def _to_oci_metadata(self): """Converts metadata item to OCI metadata item.""" @@ -659,6 +689,8 @@ def _to_oci_metadata(self): oci_metadata_item.value = _METADATA_EMPTY_VALUE if not oci_metadata_item.category: oci_metadata_item.category = MetadataCustomCategory.OTHER + if not oci_metadata_item.has_artifact: + oci_metadata_item.has_artifact = False return oci_metadata_item def validate(self) -> bool: @@ -1368,7 +1400,13 @@ def to_dataframe(self) -> pd.DataFrame: return ( pd.DataFrame( ( - (item.key, item.value, item.description, item.category) + ( + item.key, + item.value, + item.description, + item.category, + item.has_artifact, + ) for item in self._items ), columns=[value for value in MetadataCustomPrintColumns.values()], @@ -1510,7 +1548,9 @@ def _from_oci_metadata(cls, metadata_list): for oci_item in metadata_list: item = ModelTaxonomyMetadataItem._from_oci_metadata(oci_item) if item.key in metadata.keys: - metadata[item.key].update(value=item.value) + metadata[item.key].update( + value=item.value, has_artifact=item.has_artifact + ) else: metadata._items.add(item) return metadata @@ -1525,7 +1565,7 @@ def to_dataframe(self) -> pd.DataFrame: """ return ( pd.DataFrame( - ((item.key, item.value) for item in self._items), + ((item.key, item.value, item.has_artifact) for item in self._items), columns=[value for value in MetadataTaxonomyPrintColumns.values()], ) .sort_values(by=[MetadataTaxonomyPrintColumns.KEY]) diff --git a/ads/model/model_version_set.py b/ads/model/model_version_set.py index 459a75635..82dce320a 100644 --- a/ads/model/model_version_set.py +++ b/ads/model/model_version_set.py @@ -14,7 +14,7 @@ import oci.data_science from ads.common.utils import batch_convert_case, get_value, snake_to_camel -from ads.config import COMPARTMENT_OCID, OCI_REGION_METADATA, PROJECT_OCID +from ads.config import COMPARTMENT_OCID, OCI_REGION_METADATA, PROJECT_OCID, USER from ads.jobs.builders.base import Builder from ads.model.datascience_model import DataScienceModel from ads.model.service.oci_datascience_model_version_set import ( @@ -454,7 +454,7 @@ def kind(self) -> str: return "modelVersionSet" @classmethod - def list(cls, compartment_id: str = None, **kwargs) -> List["ModelVersionSet"]: + def list(cls, compartment_id: str = None, category: str = USER, **kwargs) -> List["ModelVersionSet"]: """ List model version sets in a given compartment. @@ -462,6 +462,8 @@ def list(cls, compartment_id: str = None, **kwargs) -> List["ModelVersionSet"]: ---------- compartment_id: str The OCID of compartment. + category: (str, optional). Defaults to `USER`. + The category of Model. Allowed values are: "USER", "SERVICE" kwargs Additional keyword arguments for filtering model version sets. @@ -473,7 +475,7 @@ def list(cls, compartment_id: str = None, **kwargs) -> List["ModelVersionSet"]: return [ cls.from_dsc_model_version_set(model_version_set) for model_version_set in DataScienceModelVersionSet.list_resource( - compartment_id, **kwargs + compartment_id, category=category, **kwargs ) ] diff --git a/ads/model/service/oci_datascience_model.py b/ads/model/service/oci_datascience_model.py index c84397cf4..5522522bd 100644 --- a/ads/model/service/oci_datascience_model.py +++ b/ads/model/service/oci_datascience_model.py @@ -4,9 +4,10 @@ # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ import logging +from dataclasses import dataclass from functools import wraps from io import BytesIO -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Union import oci.data_science from oci.data_science.models import ( @@ -18,13 +19,18 @@ UpdateModelDetails, ) from oci.exceptions import ServiceError +from requests.structures import CaseInsensitiveDict +from ads.common import utils +from ads.common.auth import default_signer from ads.common.object_storage_details import ObjectStorageDetails from ads.common.oci_datascience import OCIDataScienceMixin from ads.common.oci_mixin import OCIWorkRequestMixin from ads.common.oci_resource import SEARCH_TYPE, OCIResource -from ads.common.utils import extract_region +from ads.common.serializer import DataClassSerializable +from ads.common.utils import extract_region, read_file, text_sanitizer from ads.common.work_request import DataScienceWorkRequest +from ads.model.common.utils import MetadataArtifactPathType from ads.model.deployment import ModelDeployment logger = logging.getLogger(__name__) @@ -54,6 +60,21 @@ class ModelWithActiveDeploymentError(Exception): # pragma: no cover pass +class ModelMetadataArtifactNotFoundError(Exception): # pragma: no cover + def __init__(self, model_ocid, metadata_key: str): + super().__init__( + f"The model {model_ocid} does not contain the metadata with key {metadata_key}." + ) + + +@dataclass(repr=False) +class ModelMetadataArtifactDetails(DataClassSerializable): + """Represents a details of Model Metadata .""" + + headers: Union[Dict, CaseInsensitiveDict] + status: str + + def check_for_model_id(msg: str = MODEL_NEEDS_TO_BE_SAVED): """The decorator helping to check if the ID attribute sepcified for a datascience model. @@ -86,6 +107,12 @@ def wrapper(self, *args, **kwargs): return decorator +def convert_model_metadata_response( + headers: Union[Dict, CaseInsensitiveDict], status: int +) -> ModelMetadataArtifactDetails: + return ModelMetadataArtifactDetails(headers=headers, status=str(status)) + + class OCIDataScienceModel( OCIDataScienceMixin, OCIWorkRequestMixin, @@ -529,8 +556,6 @@ def model_deployment( Parameters ---------- - model_id: str - The model ID. config: (Dict, optional). Defaults to `None`. Configuration keys and values as per SDK and Tool Configuration. The from_file() method can be used to load configuration from a file. @@ -594,3 +619,450 @@ def is_model_created_by_reference(self): ): return True return False + + def get_metadata_content( + self, artifact_path_or_content: str, path_type: MetadataArtifactPathType + ): + """ + returns the content of the metadata artifact + + Parameters + ---------- + artifact_path_or_content: str + The path of the file (local or oss) containing metadata artifact or content. + path_type: str + can be one of local , oss or actual content itself + + Returns + ------- + metadata artifact content + """ + + if path_type == MetadataArtifactPathType.CONTENT: + return artifact_path_or_content + + elif path_type == MetadataArtifactPathType.LOCAL: + if not utils.is_path_exists(artifact_path_or_content): + raise FileNotFoundError( + f"File not found: {artifact_path_or_content} . " + ) + + with open(artifact_path_or_content, "rb") as f: + contents = f.read() + logger.info(f"The metadata artifact content - {contents}") + + return contents + + elif path_type == MetadataArtifactPathType.OSS: + if not utils.is_path_exists(artifact_path_or_content): + raise FileNotFoundError(f"File not found: {artifact_path_or_content}") + + contents = str( + read_file(file_path=artifact_path_or_content, auth=default_signer()) + ) + logger.debug(f"The metadata artifact content - {contents}") + + return contents + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before the creating custom metadata artifact corresponding to that model" + ) + def create_custom_metadata_artifact( + self, + metadata_key_name: str, + artifact_path: str, + path_type: MetadataArtifactPathType, + ) -> ModelMetadataArtifactDetails: + """Creates model custom metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + + artifact_path: str + The model custom metadata artifact path to be upload. + + path_type: MetadataArtifactPathType + can be one of local , oss or actual content itself + + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact creation info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + contents = self.get_metadata_content( + artifact_path_or_content=artifact_path, path_type=path_type + ) + response = self.client.create_model_custom_metadatum_artifact( + self.id, + metadata_key_name, + text_sanitizer(contents), + content_disposition="form" '-data; name="file"; filename="readme.*"', + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before creating defined metadata artifact corresponding to that model" + ) + def create_defined_metadata_artifact( + self, + metadata_key_name: str, + artifact_path: str, + path_type: MetadataArtifactPathType, + ) -> ModelMetadataArtifactDetails: + """Creates model defined metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + + artifact_path: str + The model custom metadata artifact path to be upload. + + path_type: MetadataArtifactPathType + can be one of local , oss or actual content itself. + + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact creation info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + contents = self.get_metadata_content( + artifact_path_or_content=artifact_path, path_type=path_type + ) + response = self.client.create_model_defined_metadatum_artifact( + self.id, + metadata_key_name, + text_sanitizer(contents), + content_disposition='form-data; name="file"; filename="readme.*"', + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before updating defined metadata artifact corresponding to that model" + ) + def update_defined_metadata_artifact( + self, + metadata_key_name: str, + artifact_path: str, + path_type: MetadataArtifactPathType, + ) -> ModelMetadataArtifactDetails: + """Update model defined metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + + artifact_path: str + The model defined metadata artifact path to be upload. + + path_type:MetadataArtifactPathType + can be one of local , oss or actual content itself. + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact update info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + contents = self.get_metadata_content( + artifact_path_or_content=artifact_path, path_type=path_type + ) + response = self.client.update_model_defined_metadatum_artifact( + self.id, + metadata_key_name, + text_sanitizer(contents), + content_disposition='form-data; name="file"; filename="readme.*"', + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before updating custom metadata artifact corresponding to that model" + ) + def update_custom_metadata_artifact( + self, + metadata_key_name: str, + artifact_path: str, + path_type: MetadataArtifactPathType, + ) -> ModelMetadataArtifactDetails: + """Update model custom metadata artifact for specified model. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + + artifact_path: str + The model custom metadata artifact path to be upload. + + path_type: MetadataArtifactPathType + can be one of local , oss or actual content itself. + + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact update info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + contents = self.get_metadata_content( + artifact_path_or_content=artifact_path, path_type=path_type + ) + response = self.client.update_model_custom_metadatum_artifact( + self.id, + metadata_key_name, + text_sanitizer(contents), + content_disposition="form" '-data; name="file"; filename="readme.*"', + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before fetching custom metadata artifact corresponding to that model" + ) + def get_custom_metadata_artifact(self, metadata_key_name: str) -> bytes: + """Downloads model custom metadata artifact content for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + bytes + custom metadata artifact content + + """ + try: + return self.client.get_model_custom_metadatum_artifact_content( + self.id, metadata_key_name + ).data.content + except ServiceError as ex: + if ex.status == 404: + raise ModelMetadataArtifactNotFoundError(self.id, metadata_key_name) + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before fetching defined metadata artifact corresponding to that model" + ) + def get_defined_metadata_artifact(self, metadata_key_name: str) -> bytes: + """Downloads model defined metadata artifact content for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + bytes + Defined metadata artifact content + + """ + try: + return self.client.get_model_defined_metadatum_artifact_content( + self.id, metadata_key_name + ).data.content + except ServiceError as ex: + if ex.status == 404 or ex.status == 400: + raise ModelMetadataArtifactNotFoundError(self.id, metadata_key_name) + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before fetching custom metadata artifact corresponding to that model" + ) + def head_custom_metadata_artifact( + self, metadata_key_name: str + ) -> ModelMetadataArtifactDetails: + """Gets custom metadata artifact metadata for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact head call info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + response = self.client.head_model_custom_metadatum_artifact( + self.id, metadata_key_name + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before fetching defined metadata artifact corresponding to that model" + ) + def head_defined_metadata_artifact( + self, metadata_key_name: str + ) -> ModelMetadataArtifactDetails: + """Gets defined metadata artifact metadata for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact head call info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'ETag': '77156317-8bb9-4c4a-882b-0d85f8140d93', + 'X-Content-Type-Options': 'nosniff', + 'Content-Length': '4029958', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + response = self.client.head_model_defined_metadatum_artifact( + self.id, metadata_key_name + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before the deleting custom metadata artifact corresponding to that model" + ) + def delete_custom_metadata_artifact( + self, metadata_key_name: str + ) -> ModelMetadataArtifactDetails: + """Deletes model custom metadata artifact for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + ModelMetadataArtifactDetails + The model custom metadata artifact delete call info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'X-Content-Type-Options': 'nosniff', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + response = self.client.delete_model_custom_metadatum_artifact( + self.id, metadata_key_name + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before the deleting defined metadata artifact corresponding to that model" + ) + def delete_defined_metadata_artifact( + self, metadata_key_name: str + ) -> ModelMetadataArtifactDetails: + """Deletes model defined metadata artifact for specified model metadata key. + + Parameters + ---------- + metadata_key_name: str + The name of the model metadatum in the metadata. + Returns + ------- + ModelMetadataArtifactDetails + The model defined metadata artifact delete call info. + Example: + { + 'Date': 'Mon, 02 Dec 2024 06:38:24 GMT', + 'opc-request-id': 'E4F7', + 'X-Content-Type-Options': 'nosniff', + 'Vary': 'Origin', + 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + 'status': 204 + } + + """ + response = self.client.delete_model_defined_metadatum_artifact( + self.id, metadata_key_name + ) + response_data = convert_model_metadata_response( + response.headers, response.status + ) + return response_data diff --git a/tests/unitary/default_setup/model/test_datascience_model.py b/tests/unitary/default_setup/model/test_datascience_model.py index 58229628c..dc0b2dbb0 100644 --- a/tests/unitary/default_setup/model/test_datascience_model.py +++ b/tests/unitary/default_setup/model/test_datascience_model.py @@ -64,16 +64,25 @@ "value": "oci://bucket@namespace/service_pack/cpu/Data_Exploration_and_Manipulation_for_CPU_Python_3.7/3.0/dataexpl_p37_cpu_v3", "description": "The conda environment where the model was trained.", "category": "Training Environment", + "hasArtifact": False, }, ], "defined_metadata_list": [ - {"key": "Algorithm", "value": "test"}, - {"key": "Framework"}, - {"key": "FrameworkVersion"}, - {"key": "UseCaseType", "value": "multinomial_classification"}, - {"key": "Hyperparameters"}, - {"key": "ArtifactTestResults"}, - {"key": "UnexpectedKey", "value": "unexpected_value"}, + {"key": "Algorithm", "value": "test", "hasArtifact": False}, + {"key": "Framework", "hasArtifact": False}, + {"key": "FrameworkVersion", "hasArtifact": False}, + { + "key": "UseCaseType", + "value": "multinomial_classification", + "hasArtifact": False, + }, + {"key": "Hyperparameters", "hasArtifact": False}, + {"key": "ArtifactTestResults", "hasArtifact": False}, + {"key": "UnexpectedKey", "value": "unexpected_value", "hasArtifact": False}, + {"key": "License", "hasArtifact": False}, + {"key": "Readme", "hasArtifact": False}, + {"key": "FineTuneConfiguration", "hasArtifact": False}, + {"key": "DeploymentConfiguration", "hasArtifact": False}, ], "backup_setting": { "is_backup_enabled": True, @@ -140,18 +149,31 @@ "value": "oci://bucket@namespace/service_pack/cpu/Data_Exploration_and_Manipulation_for_CPU_Python_3.7/3.0/dataexpl_p37_cpu_v3", "description": "The conda environment where the model was trained.", "category": "Training Environment", + "has_artifact": False, }, ] }, "definedMetadataList": { "data": [ - {"key": "Algorithm", "value": "test"}, - {"key": "Framework", "value": None}, - {"key": "FrameworkVersion", "value": None}, - {"key": "UseCaseType", "value": "multinomial_classification"}, - {"key": "Hyperparameters", "value": None}, - {"key": "ArtifactTestResults", "value": None}, - {"key": "UnexpectedKey", "value": "unexpected_value"}, + {"key": "Algorithm", "value": "test", "has_artifact": False}, + {"key": "Framework", "value": None, "has_artifact": False}, + {"key": "FrameworkVersion", "value": None, "has_artifact": False}, + { + "key": "UseCaseType", + "value": "multinomial_classification", + "has_artifact": False, + }, + {"key": "Hyperparameters", "value": None, "has_artifact": False}, + {"key": "ArtifactTestResults", "value": None, "has_artifact": False}, + { + "key": "UnexpectedKey", + "value": "unexpected_value", + "has_artifact": False, + }, + {"key": "License", "value": None, "has_artifact": False}, + {"key": "Readme", "value": None, "has_artifact": False}, + {"key": "FineTuneConfiguration", "value": None, "has_artifact": False}, + {"key": "DeploymentConfiguration", "value": None, "has_artifact": False}, ] }, "provenanceMetadata": { @@ -393,6 +415,7 @@ def test_list(self, mock_list_resource, mock__update_from_oci_dsc_model): mock_list_resource.assert_called_with( "test_compartment_id", project_id="test_project_id", + category="USER", **{"extra_tag": "test_cvalue"}, ) assert len(result) == 1 @@ -427,6 +450,7 @@ def test_list_df(self, mock_list_resource): mock_list_resource.assert_called_with( "test_compartment_id", project_id="test_project_id", + category="USER", **{"extra_tag": "test_cvalue"}, ) assert expected_result.equals(result) @@ -600,7 +624,11 @@ def test__to_oci_dsc_model(self): assert self.prepare_dict(test_oci_dsc_model.to_dict()) == self.prepare_dict( self.mock_dsc_model._to_oci_dsc_model().to_dict() ) - + print("test_oci_dsc_model.to_dict(): ", test_oci_dsc_model.to_dict()) + print( + "mock_dsc_model._to_oci_dsc_model().to_dict(): ", + self.mock_dsc_model._to_oci_dsc_model().to_dict(), + ) test_oci_dsc_model.display_name = "new_name" assert self.prepare_dict(test_oci_dsc_model.to_dict()) == self.prepare_dict( self.mock_dsc_model._to_oci_dsc_model(display_name="new_name").to_dict() @@ -648,6 +676,10 @@ def test__update_from_oci_dsc_model( {"key": "UseCaseType", "value": "multinomial_classification"}, {"key": "Hyperparameters", "value": "new test"}, {"key": "ArtifactTestResults", "value": "new test"}, + {"key": "License", "value": None}, + {"key": "Readme", "value": None}, + {"key": "FineTuneConfiguration", "value": None}, + {"key": "DeploymentConfiguration", "value": None}, ], "backup_setting": { "is_backup_enabled": True, @@ -710,17 +742,46 @@ def test__update_from_oci_dsc_model( "value": "new oci://bucket@namespace/service_pack/cpu/Data_Exploration_and_Manipulation_for_CPU_Python_3.7/3.0/dataexpl_p37_cpu_v3", "description": "new The conda environment where the model was trained.", "category": "Training Environment", + "has_artifact": False, }, ] }, "definedMetadataList": { "data": [ - {"key": "Algorithm", "value": "new test"}, - {"key": "Framework", "value": "new test"}, - {"key": "FrameworkVersion", "value": "new test"}, - {"key": "UseCaseType", "value": "multinomial_classification"}, - {"key": "Hyperparameters", "value": "new test"}, - {"key": "ArtifactTestResults", "value": "new test"}, + {"key": "Algorithm", "value": "new test", "has_artifact": False}, + {"key": "Framework", "value": "new test", "has_artifact": False}, + { + "key": "FrameworkVersion", + "value": "new test", + "has_artifact": False, + }, + { + "key": "UseCaseType", + "value": "multinomial_classification", + "has_artifact": False, + }, + { + "key": "Hyperparameters", + "value": "new test", + "has_artifact": False, + }, + { + "key": "ArtifactTestResults", + "value": "new test", + "has_artifact": False, + }, + {"key": "License", "value": None, "has_artifact": False}, + {"key": "Readme", "value": None, "has_artifact": False}, + { + "key": "FineTuneConfiguration", + "value": None, + "has_artifact": False, + }, + { + "key": "DeploymentConfiguration", + "value": None, + "has_artifact": False, + }, ] }, "backupSetting": { diff --git a/tests/unitary/default_setup/model/test_files/metadata_test_artifact_test.json b/tests/unitary/default_setup/model/test_files/metadata_test_artifact_test.json new file mode 100644 index 000000000..1f2b1b954 --- /dev/null +++ b/tests/unitary/default_setup/model/test_files/metadata_test_artifact_test.json @@ -0,0 +1,4 @@ +{ + "type": "metadata_test_artifact_files", + "version": "1.0" +} diff --git a/tests/unitary/default_setup/model/test_files/model.pkl b/tests/unitary/default_setup/model/test_files/model.pkl new file mode 100644 index 000000000..fe657d646 Binary files /dev/null and b/tests/unitary/default_setup/model/test_files/model.pkl differ diff --git a/tests/unitary/default_setup/model/test_model_metadata.py b/tests/unitary/default_setup/model/test_model_metadata.py index f726c65df..ca2f2b812 100644 --- a/tests/unitary/default_setup/model/test_model_metadata.py +++ b/tests/unitary/default_setup/model/test_model_metadata.py @@ -119,6 +119,7 @@ def test_to_dict(self): expected_result = { "key": self.test_item.key, "value": self.test_item.value, + "has_artifact": False, } assert item_dict == expected_result @@ -151,6 +152,7 @@ def test_validate(self): test_item = ModelTaxonomyMetadataItem( key=MetadataTaxonomyKeys.USE_CASE_TYPE, value=UseCaseType.CLUSTERING, + has_artifact=False, ) assert test_item.validate() == True @@ -178,7 +180,7 @@ def test_validate(self): # Any other key test_item = ModelTaxonomyMetadataItem( - key=MetadataTaxonomyKeys.ALGORITHM, value="any value" + key=MetadataTaxonomyKeys.ALGORITHM, value="any value", has_artifact=False ) assert test_item.validate() == True @@ -197,13 +199,11 @@ def test__to_oci_metadata(self, test_value, expected_value): """Tests converting metadata item to OCI metadata item.""" # case with non empty string value test_metadata_item = ModelTaxonomyMetadataItem( - key=self.test_key, - value=test_value, + key=self.test_key, value=test_value, has_artifact=False ) expected_oci_metadata_item = OciMetadataItem( - key=self.test_key, - value=expected_value, + key=self.test_key, value=expected_value, has_artifact=False ) result_oci_metadata_item = test_metadata_item._to_oci_metadata() assert expected_oci_metadata_item == result_oci_metadata_item @@ -224,11 +224,10 @@ def test__to_oci_metadata(self, test_value, expected_value): def test__from_oci_metadata(self, test_value, expected_value): """Tests creating a new metadata item from the OCI metadata item.""" test_oci_metadata_item = OciMetadataItem( - key=self.test_key, - value=test_value, + key=self.test_key, value=test_value, has_artifact=False ) expected_model_metadata_item = ModelTaxonomyMetadataItem( - key=self.test_key, value=expected_value + key=self.test_key, value=expected_value, has_artifact=False ) result_metadata_item = ModelTaxonomyMetadataItem._from_oci_metadata( test_oci_metadata_item @@ -258,11 +257,13 @@ def test_item(self): value=self.VALUE, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) assert item.key == self.KEY assert item.value == self.VALUE assert item.description == self.DESCRIPTION assert item.category == self.CATEGORY + assert item.has_artifact == False def test_item_description(self): # test replace description @@ -299,6 +300,7 @@ def test_item_update(self): value=self.VALUE, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) # test update @@ -315,6 +317,7 @@ def test_item_to_dict(self): value=self.VALUE, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) # test to dictionary format item_dict = item.to_dict() @@ -329,6 +332,7 @@ def test_item_to_yaml(self): value=self.VALUE, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) # test to yaml format item_yaml = item.to_yaml() @@ -340,6 +344,7 @@ def test_item_size(self): value=self.VALUE, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) # test size item.size() == len(json.dumps(item.to_dict()).encode("utf-16")) @@ -415,6 +420,7 @@ def test__to_oci_metadata(self, test_value, expected_value): value=test_value, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) expected_oci_metadata_item = OciMetadataItem( @@ -422,6 +428,7 @@ def test__to_oci_metadata(self, test_value, expected_value): value=expected_value, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) result_oci_metadata_item = test_metadata_item._to_oci_metadata() assert expected_oci_metadata_item == result_oci_metadata_item @@ -446,12 +453,14 @@ def test__from_oci_metadata(self, test_value, expected_value): value=test_value, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) expected_model_metadata_item = ModelCustomMetadataItem( key=self.KEY, value=expected_value, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) result_metadata_item = ModelCustomMetadataItem._from_oci_metadata( test_oci_metadata_item @@ -466,6 +475,7 @@ def test_validate(self): value=self.VALUE, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) assert metadata_item.validate() == True @@ -484,6 +494,7 @@ def test_validate(self): value=[1] * METADATA_VALUE_LENGTH_LIMIT, description=self.DESCRIPTION, category=self.CATEGORY, + has_artifact=False, ) with pytest.raises(MetadataValueTooLong) as exc: metadata_item.validate() @@ -494,6 +505,7 @@ def test_validate(self): value="test", description="ab" * METADATA_DESCRIPTION_LENGTH_LIMIT, category=self.CATEGORY, + has_artifact=False, ) with pytest.raises(MetadataDescriptionTooLong) as exc: metadata_item.validate() @@ -507,6 +519,7 @@ class TestModelCustomMetadata: value="pyspark30_p37_cpu_v1", description="The slug name which was uesd to train the model.", category=MetadataCustomCategory.PERFORMANCE, + has_artifact=False, ) user_defined_item = ModelCustomMetadataItem( @@ -514,6 +527,7 @@ class TestModelCustomMetadata: value="My own Meta", description="This is my own meta", category=MetadataCustomCategory.OTHER, + has_artifact=False, ) dict_item = ModelCustomMetadataItem( @@ -521,7 +535,7 @@ class TestModelCustomMetadata: ) empty_value_item = ModelCustomMetadataItem( - key="My Meta With Empty Value", value=None + key="My Meta With Empty Value", value=None, has_artifact=False ) def test__add(self): @@ -633,6 +647,7 @@ def test_to_dataframe(self): assert df["Value"].iloc[0] == self.performance_item.value assert df["Description"].iloc[0] == self.performance_item.description assert df["Category"].iloc[0] == self.performance_item.category + assert df["HasArtifact"].iloc[0] == self.performance_item.has_artifact def test_size(self): # test check size of model metadata diff --git a/tests/unitary/default_setup/model/test_model_version_set.py b/tests/unitary/default_setup/model/test_model_version_set.py index ea9cea19a..a452d753c 100644 --- a/tests/unitary/default_setup/model/test_model_version_set.py +++ b/tests/unitary/default_setup/model/test_model_version_set.py @@ -322,7 +322,7 @@ def test_list(self): test_result = mvs.list(compartment_id="test_compartment_id") for i, item in enumerate(test_result): assert item.to_dict() == expected_result[i].to_dict() - mock_list_resource.assert_called_with("test_compartment_id") + mock_list_resource.assert_called_with("test_compartment_id", category="USER") @patch.object(DataScienceModel, "list") def test_models(self, mock_list_models): diff --git a/tests/unitary/default_setup/model/test_oci_datascience_model.py b/tests/unitary/default_setup/model/test_oci_datascience_model.py index 27d9f861d..c95464da3 100644 --- a/tests/unitary/default_setup/model/test_oci_datascience_model.py +++ b/tests/unitary/default_setup/model/test_oci_datascience_model.py @@ -2,7 +2,8 @@ # Copyright (c) 2022, 2024 Oracle and/or its affiliates. # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ - +import logging +import os from unittest.mock import MagicMock, patch, call, PropertyMock import pytest @@ -20,6 +21,7 @@ from ads.common.object_storage_details import ObjectStorageDetails from ads.common.oci_mixin import OCIModelMixin from ads.common.oci_resource import SEARCH_TYPE, OCIResource +from ads.model.common.utils import MetadataArtifactPathType from ads.model.datascience_model import _MAX_ARTIFACT_SIZE_IN_BYTES from ads.model.service.oci_datascience_model import ( ModelArtifactNotFoundError, @@ -91,6 +93,7 @@ def setup_class(cls): cls.mock_delete_model_response = Response( data=None, status=None, headers=None, request=None ) + cls.curr_dir = os.path.dirname(os.path.abspath(__file__)) # Mock create/update model response cls.mock_create_model_response = Response( @@ -484,3 +487,93 @@ def test_is_model_by_reference(self): self.mock_model.custom_metadata_list = [metadata_item] assert self.mock_model.is_model_created_by_reference() + + @patch.object(OCIDataScienceModel, "client") + def test_create_defined_metadata_artifact(self, mock_client): + """Tests create defined metadata artifact for specified model.""" + self.mock_artifact_file_path = os.path.join( + self.curr_dir, "test_files/metadata_test_artifact_test.json" + ) + response = Response(headers={}, status=204, data=None, request=None) + mock_client.create_model_defined_metadatum_artifact.return_value = response + data = self.mock_model.create_defined_metadata_artifact( + "metadata_key_name", + self.mock_artifact_file_path, + MetadataArtifactPathType.LOCAL, + ) + assert data.status == "204" + + @patch.object(OCIDataScienceModel, "client") + def test_create_custom_metadata_artifact(self, mock_client): + """Tests create defined metadata artifact for specified model.""" + self.mock_artifact_file_path = os.path.join( + self.curr_dir, "test_files/metadata_test_artifact_test.json" + ) + response = Response(headers={}, status=204, data=None, request=None) + mock_client.create_model_defined_metadatum_artifact.return_value = response + data = self.mock_model.create_defined_metadata_artifact( + "metadata_key_name", + self.mock_artifact_file_path, + MetadataArtifactPathType.LOCAL, + ) + assert data.status == "204" + + @patch.object(OCIDataScienceModel, "client") + def test_update_defined_metadata_artifact(self, mock_client): + """Tests create defined metadata artifact for specified model.""" + self.mock_artifact_file_path = os.path.join( + self.curr_dir, "test_files/metadata_test_artifact_test.json" + ) + response = Response(headers={}, status=204, data=None, request=None) + mock_client.update_model_defined_metadatum_artifact.return_value = response + data = self.mock_model.update_defined_metadata_artifact( + "metadata_key_name", + self.mock_artifact_file_path, + MetadataArtifactPathType.LOCAL, + ) + assert data.status == "204" + + @patch.object(OCIDataScienceModel, "client") + def test_update_custom_metadata_artifact(self, mock_client): + """Tests create defined metadata artifact for specified model.""" + self.mock_artifact_file_path = os.path.join( + self.curr_dir, "test_files/metadata_test_artifact_test.json" + ) + response = Response(headers={}, status=204, data=None, request=None) + mock_client.update_model_custom_metadatum_artifact.return_value = response + data = self.mock_model.update_custom_metadata_artifact( + "metadata_key_name", + self.mock_artifact_file_path, + MetadataArtifactPathType.LOCAL, + ) + assert data.status == "204" + + @patch.object(OCIDataScienceModel, "client") + def test_delete_defined_metadata_artifact(self, mock_client): + """Tests delete defined metadata artifact for specified model.""" + response = Response(headers={}, status=204, data=None, request=None) + mock_client.delete_model_defined_metadatum_artifact.return_value = response + data = self.mock_model.delete_defined_metadata_artifact("metadata_key_name") + assert data.status == "204" + + @patch.object(OCIDataScienceModel, "client") + def test_delete_custom_metadata_artifact(self, mock_client): + """Tests delete defined metadata artifact for specified model.""" + response = Response(headers={}, status=204, data=None, request=None) + mock_client.delete_model_custom_metadatum_artifact.return_value = response + data = self.mock_model.delete_custom_metadata_artifact("metadata_key_name") + assert data.status == "204" + + @patch.object(OCIDataScienceModel, "client") + def test_get_custom_metadata_artifact(self, mock_client): + """Tests gets defined metadata artifact for specified model.""" + mock_client.get_model_custom_metadatum_artifact_content.return_value.data.content = b"some file" + data = self.mock_model.get_custom_metadata_artifact("metadata_key_name") + assert data == b"some file" + + @patch.object(OCIDataScienceModel, "client") + def test_get_defined_metadata_artifact(self, mock_client): + """Tests gets defined metadata artifact for specified model.""" + mock_client.get_model_defined_metadatum_artifact_content.return_value.data.content = b"some file" + data = self.mock_model.get_defined_metadata_artifact("metadata_key_name") + assert data == b"some file" diff --git a/tests/unitary/with_extras/aqua/test_evaluation.py b/tests/unitary/with_extras/aqua/test_evaluation.py index ef3475184..e05e4717f 100644 --- a/tests/unitary/with_extras/aqua/test_evaluation.py +++ b/tests/unitary/with_extras/aqua/test_evaluation.py @@ -28,7 +28,8 @@ ShapeConfig, UIConfig, ) -from ads.aqua.constants import EVALUATION_REPORT_JSON, EVALUATION_REPORT_MD, UNKNOWN +from ads.aqua.constants import EVALUATION_REPORT_JSON, EVALUATION_REPORT_MD +from ads.common.utils import UNKNOWN from ads.aqua.evaluation import AquaEvaluationApp from ads.aqua.evaluation.entities import ( AquaEvalMetrics,