Files
confident-ai--deepeval/deepeval/utils.py
T
2026-07-13 13:32:05 +08:00

969 lines
28 KiB
Python

import copy
import os
import json
import time
import webbrowser
import tqdm
import re
import string
import asyncio
import nest_asyncio
import uuid
import math
import logging
from contextvars import ContextVar
from enum import Enum
from importlib import import_module
from typing import Any, Dict, List, Optional, Protocol, Sequence, Union
from collections.abc import Iterable
from dataclasses import asdict, is_dataclass
from pydantic import BaseModel
from rich.progress import Progress
from rich.console import Console, Theme
from deepeval.errors import DeepEvalError
from deepeval.config.settings import get_settings
from deepeval.config.utils import (
get_env_bool,
set_env_bool,
)
#####################
# Pydantic Compat #
#####################
import pydantic
PYDANTIC_V2 = pydantic.VERSION.startswith("2")
def make_model_config(**kwargs):
"""
Create a model configuration that works with both Pydantic v1 and v2.
Usage in a model (Pydantic v2 style):
class MyModel(BaseModel):
model_config = make_model_config(arbitrary_types_allowed=True)
field: str
This will work correctly in both v1 and v2:
- In v2: Returns ConfigDict(**kwargs)
- In v1: Returns a Config class with the attributes set
Args:
**kwargs: Configuration options (e.g., use_enum_values=True, arbitrary_types_allowed=True)
Returns:
ConfigDict (v2) or Config class (v1)
"""
if PYDANTIC_V2:
from pydantic import ConfigDict
return ConfigDict(**kwargs)
else:
# For Pydantic v1, create an inner Config class
class Config:
pass
for key, value in kwargs.items():
setattr(Config, key, value)
return Config
###############
# Local Types #
###############
class TurnLike(Protocol):
order: int
role: str
content: str
user_id: Optional[str]
retrieval_context: Optional[Sequence[str]]
tools_called: Optional[Sequence[Any]]
comments: Optional[str]
def get_lcs(seq1, seq2):
m, n = len(seq1), len(seq2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if seq1[i - 1] == seq2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# Reconstruct the LCS
lcs = []
i, j = m, n
while i > 0 and j > 0:
if seq1[i - 1] == seq2[j - 1]:
lcs.append(seq1[i - 1])
i -= 1
j -= 1
elif dp[i - 1][j] > dp[i][j - 1]:
i -= 1
else:
j -= 1
return lcs[::-1]
def camel_to_snake(name: str) -> str:
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def convert_keys_to_snake_case(data: Any) -> Any:
if isinstance(data, dict):
new_dict = {}
for k, v in data.items():
new_key = camel_to_snake(k)
if k == "additionalMetadata" or k == "metadata":
new_dict[new_key] = (
v # Convert key but do not recurse into value
)
else:
new_dict[new_key] = convert_keys_to_snake_case(v)
return new_dict
elif isinstance(data, list):
return [convert_keys_to_snake_case(i) for i in data]
else:
return data
def prettify_list(lst: List[Any]):
if len(lst) == 0:
return "[]"
formatted_elements = []
for item in lst:
if isinstance(item, str):
formatted_elements.append(f'"{item}"')
elif isinstance(item, BaseModel):
try:
jsonObj = item.model_dump()
except AttributeError:
# Pydantic version below 2.0
jsonObj = item.dict()
formatted_elements.append(
json.dumps(jsonObj, indent=4, ensure_ascii=True).replace(
"\n", "\n "
)
)
else:
formatted_elements.append(repr(item)) # Fallback for other types
formatted_list = ",\n ".join(formatted_elements)
return f"[\n {formatted_list}\n]"
def generate_uuid() -> str:
return str(uuid.uuid4())
def serialize_dict_with_sorting(obj):
if obj is None:
return obj
elif isinstance(obj, dict):
sorted_dict = {
k: serialize_dict_with_sorting(v) for k, v in sorted(obj.items())
}
return sorted_dict
elif isinstance(obj, list):
sorted_list = sorted(
[serialize_dict_with_sorting(item) for item in obj],
key=lambda x: json.dumps(x),
)
return sorted_list
else:
return obj
def serialize(obj) -> Union[str, None]:
return json.dumps(serialize_dict_with_sorting(obj), sort_keys=True)
def serialize_to_json(obj: Any, **kwargs) -> str:
"""Safely serialize an arbitrary object to a JSON string.
Pre-converts ``obj`` via ``make_json_serializable`` so nested pydantic
models, non-finite floats (NaN/Infinity), circular references, and
non-string dict keys are all handled. Prefer this over a bare
``json.dumps`` for any agent/trace/tool/test-case derived data, where a
raw ``json.dumps`` would raise ``TypeError`` or emit invalid JSON.
Extra keyword arguments (e.g. ``indent``, ``ensure_ascii``) are
forwarded to ``json.dumps``.
"""
from deepeval.tracing.utils import make_json_serializable
return json.dumps(make_json_serializable(obj), **kwargs)
def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
try:
loop = asyncio.get_event_loop()
if loop.is_running():
nest_asyncio.apply()
if loop.is_closed():
raise RuntimeError
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def get_or_create_general_event_loop() -> asyncio.AbstractEventLoop:
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
raise RuntimeError
return loop
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def set_should_skip_on_missing_params(yes: bool):
s = get_settings()
with s.edit(persist=False):
s.SKIP_DEEPEVAL_MISSING_PARAMS = yes
def should_ignore_errors() -> bool:
return bool(get_settings().IGNORE_DEEPEVAL_ERRORS)
def should_skip_on_missing_params() -> bool:
return bool(get_settings().SKIP_DEEPEVAL_MISSING_PARAMS)
def set_should_ignore_errors(yes: bool):
s = get_settings()
with s.edit(persist=False):
s.IGNORE_DEEPEVAL_ERRORS = yes
def should_verbose_print() -> bool:
return bool(get_settings().DEEPEVAL_VERBOSE_MODE)
def set_verbose_mode(yes: Optional[bool]):
s = get_settings()
with s.edit(persist=False):
s.DEEPEVAL_VERBOSE_MODE = yes
def set_identifier(identifier: Optional[str]):
if identifier:
s = get_settings()
with s.edit(persist=False):
s.DEEPEVAL_IDENTIFIER = identifier
def get_identifier() -> Optional[str]:
return get_settings().DEEPEVAL_IDENTIFIER
# Whether the next test run should be marked as the official baseline on
# Confident AI. Read in the main process at upload time (TestRunManager.
# wrap_up_test_run), so a plain module global is sufficient.
_test_run_official: bool = False
def set_test_run_official(official: bool):
global _test_run_official
_test_run_official = bool(official)
def get_test_run_official() -> bool:
return _test_run_official
def should_use_cache() -> bool:
return bool(get_settings().ENABLE_DEEPEVAL_CACHE)
def set_should_use_cache(yes: bool):
s = get_settings()
with s.edit(persist=False):
s.ENABLE_DEEPEVAL_CACHE = yes
###################
# Timeout Helpers #
###################
def are_timeouts_disabled() -> bool:
return bool(get_settings().DEEPEVAL_DISABLE_TIMEOUTS)
def get_per_task_timeout_seconds() -> float:
return get_settings().DEEPEVAL_PER_TASK_TIMEOUT_SECONDS
def get_per_task_timeout() -> Optional[float]:
return None if are_timeouts_disabled() else get_per_task_timeout_seconds()
def get_gather_timeout_seconds() -> float:
return (
get_per_task_timeout_seconds()
+ get_settings().DEEPEVAL_TASK_GATHER_BUFFER_SECONDS
)
def get_gather_timeout() -> Optional[float]:
return None if are_timeouts_disabled() else get_gather_timeout_seconds()
def login(api_key: str):
if not api_key or not isinstance(api_key, str):
raise ValueError("Oh no! Please provide an api key string to login.")
elif len(api_key) == 0:
raise ValueError("Unable to login, please provide a non-empty api key.")
from rich import print
from deepeval.confident.api import set_confident_api_key
set_confident_api_key(api_key)
print(
"🎉🥳 Congratulations! You've successfully logged in! :raising_hands: "
)
def set_is_running_deepeval(flag: bool):
set_env_bool("DEEPEVAL", flag)
def get_is_running_deepeval() -> bool:
return get_env_bool("DEEPEVAL")
def is_in_ci_env() -> bool:
ci_env_vars = [
"GITHUB_ACTIONS", # GitHub Actions
"GITLAB_CI", # GitLab CI
"CIRCLECI", # CircleCI
"JENKINS_URL", # Jenkins
"TRAVIS", # Travis CI
"CI", # Generic CI indicator used by many services
"CONTINUOUS_INTEGRATION", # Another generic CI indicator
"TEAMCITY_VERSION", # TeamCity
"BUILDKITE", # Buildkite
"BITBUCKET_BUILD_NUMBER", # Bitbucket Pipelines
"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", # Azure Pipelines
"HEROKU_TEST_RUN_ID", # Heroku CI
]
for var in ci_env_vars:
if os.getenv(var) is not None:
return True
return False
def open_browser(url: str):
if get_settings().CONFIDENT_OPEN_BROWSER:
if not is_in_ci_env():
webbrowser.open(url)
def capture_contextvars(single_obj):
contextvars_dict = {}
for attr in dir(single_obj):
attr_value = getattr(single_obj, attr, None)
if isinstance(attr_value, ContextVar):
contextvars_dict[attr] = (attr_value, attr_value.get())
return contextvars_dict
def update_contextvars(single_obj, contextvars_dict):
for attr, (context_var, value) in contextvars_dict.items():
context_var.set(value)
setattr(single_obj, attr, context_var)
def drop_and_copy(obj, drop_attrs):
# Function to drop attributes from a single object
def drop_attrs_from_single_obj(single_obj, drop_attrs):
temp_attrs = {}
for attr in drop_attrs:
if hasattr(single_obj, attr):
temp_attrs[attr] = getattr(single_obj, attr)
delattr(single_obj, attr)
return temp_attrs
# Function to remove ContextVar attributes from a single object
def remove_contextvars(single_obj):
temp_contextvars = {}
for attr in dir(single_obj):
if isinstance(getattr(single_obj, attr, None), ContextVar):
temp_contextvars[attr] = getattr(single_obj, attr)
delattr(single_obj, attr)
return temp_contextvars
# Function to restore ContextVar attributes to a single object
def restore_contextvars(single_obj, contextvars):
for attr, value in contextvars.items():
setattr(single_obj, attr, value)
# Check if obj is iterable (but not a string)
if isinstance(obj, Iterable) and not isinstance(obj, str):
copied_objs = []
for item in obj:
temp_attrs = drop_attrs_from_single_obj(item, drop_attrs)
temp_contextvars = remove_contextvars(item)
copied_obj = copy.deepcopy(item)
restore_contextvars(copied_obj, temp_contextvars)
# Restore attributes to the original object
for attr, value in temp_attrs.items():
setattr(item, attr, value)
restore_contextvars(item, temp_contextvars)
copied_objs.append(copied_obj)
return copied_objs
else:
temp_attrs = drop_attrs_from_single_obj(obj, drop_attrs)
temp_contextvars = remove_contextvars(obj)
copied_obj = copy.deepcopy(obj)
restore_contextvars(copied_obj, temp_contextvars)
# Restore attributes to the original object
for attr, value in temp_attrs.items():
setattr(obj, attr, value)
restore_contextvars(obj, temp_contextvars)
return copied_obj
def dataclass_to_dict(instance: Any) -> Any:
if is_dataclass(instance):
return {k: dataclass_to_dict(v) for k, v in asdict(instance).items()}
elif isinstance(instance, Enum):
return instance.value
elif isinstance(instance, list):
return [dataclass_to_dict(item) for item in instance]
elif isinstance(instance, tuple):
return tuple(dataclass_to_dict(item) for item in instance)
elif isinstance(instance, dict):
return {k: dataclass_to_dict(v) for k, v in instance.items()}
else:
return instance
def class_to_dict(instance: Any) -> Any:
if isinstance(instance, Enum):
return instance.value
elif isinstance(instance, list):
return [class_to_dict(item) for item in instance]
elif isinstance(instance, tuple):
return tuple(class_to_dict(item) for item in instance)
elif isinstance(instance, dict):
return {k: class_to_dict(v) for k, v in instance.items()}
elif hasattr(instance, "__dict__"):
instance_dict: Dict = instance.__dict__
return {str(k): class_to_dict(v) for k, v in instance_dict.items()}
else:
return instance
def delete_file_if_exists(file_path):
try:
if os.path.exists(file_path):
os.remove(file_path)
except Exception as e:
print(f"An error occurred: {e}")
def softmax(x):
import numpy as np
e_x = np.exp(x - np.max(x, axis=1, keepdims=True))
return e_x / e_x.sum(axis=1, keepdims=True)
def cosine_similarity(vector_a, vector_b):
import numpy as np
dot_product = np.dot(vector_a, vector_b)
norm_a = np.linalg.norm(vector_a)
norm_b = np.linalg.norm(vector_b)
similarity = dot_product / (norm_a * norm_b)
return similarity
def chunk_text(text, chunk_size=20):
words = text.split()
chunks = [
" ".join(words[i : i + chunk_size])
for i in range(0, len(words), chunk_size)
]
return chunks
def normalize_text(text: str) -> str:
"""Lower text and remove punctuation, articles and extra whitespace.
Copied from the [QuAC](http://quac.ai/) evaluation script found at
https://s3.amazonaws.com/my89public/quac/scorer.py"""
def remove_articles(text: str) -> str:
return re.sub(r"\b(a|an|the)\b", " ", text)
def white_space_fix(text: str) -> str:
return " ".join(text.split())
def remove_punc(text: str) -> str:
exclude = set(string.punctuation)
return "".join(ch for ch in text if ch not in exclude)
def lower(text: str) -> str:
return text.lower()
return white_space_fix(remove_articles(remove_punc(lower(text))))
def is_missing(s: Optional[str]) -> bool:
return s is None or (isinstance(s, str) and s.strip() == "")
def len_tiny() -> int:
value = get_settings().DEEPEVAL_MAXLEN_TINY
return value if (isinstance(value, int) and value > 0) else 40
def len_short() -> int:
value = get_settings().DEEPEVAL_MAXLEN_SHORT
return value if (isinstance(value, int) and value > 0) else 60
def len_medium() -> int:
value = get_settings().DEEPEVAL_MAXLEN_MEDIUM
return value if (isinstance(value, int) and value > 0) else 120
def len_long() -> int:
value = get_settings().DEEPEVAL_MAXLEN_LONG
return value if (isinstance(value, int) and value > 0) else 240
def shorten(
text: Optional[object],
max_len: Optional[int] = None,
suffix: Optional[str] = None,
) -> str:
"""
Truncate text to max_len characters, appending `suffix` if truncated.
- Accepts None and returns "", or any object is returned as str().
- Safe when max_len <= len(suffix).
"""
settings = get_settings()
if max_len is None:
max_len = (
settings.DEEPEVAL_SHORTEN_DEFAULT_MAXLEN
if settings.DEEPEVAL_SHORTEN_DEFAULT_MAXLEN is not None
else len_long()
)
if suffix is None:
suffix = (
settings.DEEPEVAL_SHORTEN_SUFFIX
if settings.DEEPEVAL_SHORTEN_SUFFIX is not None
else "..."
)
if text is None:
return ""
stext = str(text)
if max_len <= 0:
return ""
if len(stext) <= max_len:
return stext
cut = max_len - len(suffix)
if cut <= 0:
return suffix[:max_len]
return stext[:cut] + suffix
def convert_to_multi_modal_array(input: Union[str, List[str]]):
from deepeval.test_case import MLLMImage
if isinstance(input, str):
return MLLMImage.parse_multimodal_string(input)
elif isinstance(input, list):
new_list = []
for context in input:
parsed_array = MLLMImage.parse_multimodal_string(context)
new_list.extend(parsed_array)
return new_list
def check_if_multimodal(input: str):
pattern = r"\[DEEPEVAL:(?:IMAGE|PDF):(.*?)\]"
matches = list(re.finditer(pattern, input))
return bool(matches)
def format_turn(
turn: TurnLike,
*,
content_length: Optional[int] = None,
max_context_items: Optional[int] = None,
context_length: Optional[int] = None,
meta_length: Optional[int] = None,
include_tools_in_header: bool = True,
include_order_role_in_header: bool = True,
) -> str:
"""
Build a multi-line, human-readable summary for a conversational turn.
Safe against missing fields and overly long content.
"""
if content_length is None:
content_length = len_long()
if max_context_items is None:
max_context_items = 2
if context_length is None:
context_length = len_medium()
if meta_length is None:
meta_length = len_medium()
tools = turn.tools_called or []
tool_names = ", ".join(getattr(tc, "name", str(tc)) for tc in tools)
content = shorten(turn.content, content_length)
lines = []
if include_order_role_in_header:
header = f"{turn.order:>2}. {turn.role:<9} {content}"
if include_tools_in_header and tool_names:
header += f" | tools: {tool_names}"
if turn.user_id:
header += f" | user: {shorten(turn.user_id, len_tiny())}"
lines.append(header)
indent = " "
else:
# No order or role prefix in this mode
# keep tools out of header as well.
first = content
if turn.user_id:
first += f" | user: {shorten(turn.user_id, len_tiny())}"
lines.append(first)
indent = " " # ctx and meta indent
rctx = list(turn.retrieval_context or [])
if rctx:
show = rctx[:max_context_items]
for i, item in enumerate(show):
item_str = item.context if hasattr(item, "context") else item
lines.append(
f"{indent}↳ ctx[{i}]: {shorten(item_str, context_length)}"
)
hidden = max(0, len(rctx) - len(show))
if hidden:
lines.append(f"{indent}↳ ctx: (+{hidden} more)")
if turn.comments:
lines.append(
f"{indent}↳ comment: {shorten(str(turn.comments), meta_length)}"
)
return "\n".join(lines)
###############################################
# Source: https://github.com/tingofurro/summac
###############################################
# GPU-related business
def get_freer_gpu():
import numpy as np
os.system("nvidia-smi -q -d Memory |grep -A4 GPU|grep Free >tmp_smi")
memory_available = [
int(x.split()[2]) + 5 * i
for i, x in enumerate(open("tmp_smi", "r").readlines())
]
os.remove("tmp_smi")
return np.argmax(memory_available)
def any_gpu_with_space(gb_needed):
os.system("nvidia-smi -q -d Memory |grep -A4 GPU|grep Free >tmp_smi")
memory_available = [
float(x.split()[2]) / 1024.0
for i, x in enumerate(open("tmp_smi", "r").readlines())
]
os.remove("tmp_smi")
return any([mem >= gb_needed for mem in memory_available])
def wait_free_gpu(gb_needed):
while not any_gpu_with_space(gb_needed):
time.sleep(30)
def select_freer_gpu():
freer_gpu = str(get_freer_gpu())
print("Will use GPU: %s" % (freer_gpu))
s = get_settings()
with s.edit(persist=False):
s.CUDA_LAUNCH_BLOCKING = True
s.CUDA_VISIBLE_DEVICES = freer_gpu
return freer_gpu
def batcher(iterator, batch_size=4, progress=False):
if progress:
iterator = tqdm.tqdm(iterator)
batch = []
for elem in iterator:
batch.append(elem)
if len(batch) == batch_size:
final_batch = batch
batch = []
yield final_batch
if len(batch) > 0: # Leftovers
yield batch
def clean_nested_dict(data):
if isinstance(data, dict):
return {key: clean_nested_dict(value) for key, value in data.items()}
elif isinstance(data, list):
return [clean_nested_dict(item) for item in data]
elif isinstance(data, str):
return data.replace("\x00", "")
else:
return data
def update_pbar(
progress: Optional[Progress],
pbar_id: Optional[int],
advance: int = 1,
advance_to_end: bool = False,
remove: bool = True,
total: Optional[int] = None,
):
if progress is None or pbar_id is None:
return
# Get amount to advance
current_task = next((t for t in progress.tasks if t.id == pbar_id), None)
if current_task is None:
return
if advance_to_end:
remaining = current_task.remaining
if remaining is not None:
advance = remaining
# Advance
try:
progress.update(pbar_id, advance=advance, total=total)
except KeyError:
# progress task may be removed concurrently via callbacks which can race with teardown.
return
# Remove if finished and refetch before remove to avoid acting on a stale object
updated_task = next((t for t in progress.tasks if t.id == pbar_id), None)
if updated_task is not None and updated_task.finished and remove:
try:
progress.remove_task(pbar_id)
except KeyError:
pass
def add_pbar(progress: Optional[Progress], description: str, total: int = 1):
if progress is None:
return None
return progress.add_task(description, total=total)
def remove_pbars(
progress: Optional[Progress], pbar_ids: List[int], cascade: bool = True
):
if progress is None:
return
for pbar_id in pbar_ids:
if cascade:
time.sleep(0.1)
progress.remove_task(pbar_id)
def read_env_int(
name: str, default: int, *, min_value: Union[int, None] = None
) -> int:
"""Read an integer from an environment variable with safe fallback.
Attempts to read os.environ[name] and parse it as an int. If the variable
is unset, cannot be parsed, or is less than `min_value` (when provided),
the function returns `default`.
Args:
name: Environment variable name to read.
default: Value to return when the env var is missing/invalid/out of range.
min_value: Optional inclusive lower bound; values < min_value are rejected.
Returns:
The parsed integer, or `default` on any failure.
"""
raw = os.getenv(name)
if raw is None:
return default
try:
v = int(raw)
if min_value is not None and v < min_value:
return default
return v
except Exception:
return default
def read_env_float(
name: str, default: float, *, min_value: Union[float, None] = None
) -> float:
"""Read a float from an environment variable with safe fallback.
Attempts to read os.environ[name] and parse it as a float. If the variable
is unset, cannot be parsed, or is less than `min_value` (when provided),
the function returns `default`.
Args:
name: Environment variable name to read.
default: Value to return when the env var is missing/invalid/out of range.
min_value: Optional inclusive lower bound; values < min_value are rejected.
Returns:
The parsed float, or `default` on any failure.
"""
raw = os.getenv(name)
if raw is None:
return default
try:
v = float(raw)
except Exception:
return default
if not math.isfinite(v):
return default
if min_value is not None and v < min_value:
return default
return v
my_theme = Theme(
{
"bar.complete": "#11ff00",
"progress.percentage": "#00e5ff",
# "progress.data.speed": "#00FF00",
# "progress.remaining": "#00FF00",
"progress.elapsed": "#5703ff",
}
)
custom_console = Console(theme=my_theme)
def format_error_text(
exc: BaseException, *, with_stack: Optional[bool] = None
) -> str:
if with_stack is None:
with_stack = logging.getLogger("deepeval").isEnabledFor(logging.DEBUG)
text = f"{type(exc).__name__}: {exc}"
if with_stack:
import traceback
text += "\n" + "".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
)
elif get_settings().DEEPEVAL_VERBOSE_MODE:
text += " (Run with LOG_LEVEL=DEBUG for stack trace.)"
return text
def is_read_only_env():
return get_settings().DEEPEVAL_FILE_SYSTEM == "READ_ONLY"
##############
# validation #
##############
def require_param(
param: Optional[Any] = None,
*,
provider_label: str,
env_var_name: str,
param_hint: str,
) -> Any:
"""
Ensures that a required parameter is provided. If the parameter is `None`, raises a
`DeepEvalError` with a helpful message indicating the missing parameter and how to resolve it.
Args:
param (Optional[Any]): The parameter to validate.
provider_label (str): A label for the provider to be used in the error message.
env_var_name (str): The name of the environment variable where the parameter can be set.
param_hint (str): A hint for the parameter, usually the name of the argument.
Raises:
DeepEvalError: If the `param` is `None`, indicating that a required parameter is missing.
Returns:
Any: The value of `param` if it is provided.
"""
if param is None:
raise DeepEvalError(
f"{provider_label} is missing a required parameter. "
f"Set {env_var_name} in your environment or pass "
f"{param_hint}."
)
return param
def require_dependency(
module_name: str,
*,
provider_label: str,
install_hint: Optional[str] = None,
) -> Any:
"""
Imports an optional dependency module or raises a `DeepEvalError` if the module is not found.
The error message includes a suggestion on how to install the missing module.
Args:
module_name (str): The name of the module to import.
provider_label (str): A label for the provider to be used in the error message.
install_hint (Optional[str]): A hint on how to install the missing module, usually a pip command.
Raises:
DeepEvalError: If the module cannot be imported, indicating that the dependency is missing.
Returns:
Any: The imported module if successful.
"""
try:
return import_module(module_name)
except ImportError as exc:
hint = install_hint or f"Install it with `pip install {module_name}`."
raise DeepEvalError(
f"{provider_label} requires the `{module_name}` package. {hint}"
) from exc