import ast import base64 import json import math import operator import re import shlex from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Any, Callable import sqlparse from packaging.version import Version from sqlparse.sql import ( Comparison, Identifier, Parenthesis, Statement, Token, TokenList, ) from sqlparse.tokens import Token as TokenType from mlflow.entities import LoggedModel, Metric, RunInfo from mlflow.entities.model_registry.model_version_stages import STAGE_DELETED_INTERNAL from mlflow.entities.model_registry.prompt_version import IS_PROMPT_TAG_KEY from mlflow.exceptions import MlflowException from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE from mlflow.store.db.db_types import MSSQL, MYSQL, POSTGRES, SQLITE from mlflow.tracing.constant import ( AssessmentMetricSearchKey, SpanMetricSearchKey, TraceMetadataKey, TraceMetricSearchKey, TraceTagKey, ) from mlflow.utils.mlflow_tags import ( MLFLOW_DATASET_CONTEXT, ) if TYPE_CHECKING: from sqlalchemy.sql.elements import ClauseElement, ColumnElement # MSSQL collation for case-sensitive string comparisons _MSSQL_CASE_SENSITIVE_COLLATION = "Japanese_Bushu_Kakusu_100_CS_AS_KS_WS" def _convert_like_pattern_to_regex(pattern: str, flags: int = 0): regex = re.escape(pattern) regex = regex.replace("%", ".*").replace("_", ".") if not pattern.startswith("%"): regex = "^" + regex if not pattern.endswith("%"): regex = regex + "$" return re.compile(regex, flags) def _like(string, pattern): return _convert_like_pattern_to_regex(pattern).match(string) is not None def _ilike(string, pattern): return _convert_like_pattern_to_regex(pattern, flags=re.IGNORECASE).match(string) is not None def _join_in_comparison_tokens(tokens, search_traces=False): """ Find a sequence of tokens that matches the pattern of an IN comparison or a NOT IN comparison, join the tokens into a single Comparison token. Otherwise, return the original list of tokens. """ if Version(sqlparse.__version__) < Version("0.4.4"): # In sqlparse < 0.4.4, IN is treated as a comparison, we don't need to join tokens return tokens non_whitespace_tokens = [t for t in tokens if not t.is_whitespace] joined_tokens = [] num_tokens = len(non_whitespace_tokens) iterator = enumerate(non_whitespace_tokens) while elem := next(iterator, None): index, first = elem # We need at least 3 tokens to form an IN comparison or a NOT IN comparison if num_tokens - index < 3: joined_tokens.extend(non_whitespace_tokens[index:]) break if search_traces: # timestamp if first.match(ttype=TokenType.Name.Builtin, values=["timestamp", "timestamp_ms"]): (_, second) = next(iterator, (None, None)) (_, third) = next(iterator, (None, None)) if any(x is None for x in [second, third]): raise MlflowException( f"Invalid comparison clause with token `{first}, {second}, {third}`, " "expected 3 tokens", error_code=INVALID_PARAMETER_VALUE, ) if ( second.match( ttype=TokenType.Operator.Comparison, values=SearchTraceUtils.VALID_NUMERIC_ATTRIBUTE_COMPARATORS, ) and third.ttype == TokenType.Literal.Number.Integer ): joined_tokens.append(Comparison(TokenList([first, second, third]))) continue else: joined_tokens.extend([first, second, third]) # Wait until we encounter an identifier token if not isinstance(first, Identifier): joined_tokens.append(first) continue (_, second) = next(iterator) (_, third) = next(iterator) # IN if ( isinstance(first, Identifier) and second.match(ttype=TokenType.Keyword, values=["IN"]) and isinstance(third, Parenthesis) ): joined_tokens.append(Comparison(TokenList([first, second, third]))) continue # IS NULL if ( isinstance(first, Identifier) and second.match(ttype=TokenType.Keyword, values=["IS"]) and third.match(ttype=TokenType.Keyword, values=["NULL"]) ): joined_tokens.append( Comparison(TokenList([first, Token(TokenType.Keyword, "IS NULL")])) ) continue # IS NOT NULL if ( isinstance(first, Identifier) and second.match(ttype=TokenType.Keyword, values=["IS"]) and third.ttype == TokenType.Keyword and third.value.upper() == "NOT NULL" ): joined_tokens.append( Comparison(TokenList([first, Token(TokenType.Keyword, "IS NOT NULL")])) ) continue (_, fourth) = next(iterator, (None, None)) if fourth is None: joined_tokens.extend([first, second, third]) break # NOT IN if ( isinstance(first, Identifier) and second.match(ttype=TokenType.Keyword, values=["NOT"]) and third.match(ttype=TokenType.Keyword, values=["IN"]) and isinstance(fourth, Parenthesis) ): joined_tokens.append( Comparison(TokenList([first, Token(TokenType.Keyword, "NOT IN"), fourth])) ) continue joined_tokens.extend([first, second, third, fourth]) return joined_tokens class SearchUtils: LIKE_OPERATOR = "LIKE" ILIKE_OPERATOR = "ILIKE" ASC_OPERATOR = "asc" DESC_OPERATOR = "desc" VALID_ORDER_BY_TAGS = [ASC_OPERATOR, DESC_OPERATOR] VALID_METRIC_COMPARATORS = {">", ">=", "!=", "=", "<", "<="} VALID_PARAM_COMPARATORS = {"!=", "=", LIKE_OPERATOR, ILIKE_OPERATOR, "IS NULL", "IS NOT NULL"} VALID_TAG_COMPARATORS = {"!=", "=", LIKE_OPERATOR, ILIKE_OPERATOR, "IS NULL", "IS NOT NULL"} VALID_STRING_ATTRIBUTE_COMPARATORS = {"!=", "=", LIKE_OPERATOR, ILIKE_OPERATOR, "IN", "NOT IN"} VALID_NUMERIC_ATTRIBUTE_COMPARATORS = VALID_METRIC_COMPARATORS VALID_DATASET_COMPARATORS = {"!=", "=", LIKE_OPERATOR, ILIKE_OPERATOR, "IN", "NOT IN"} _BUILTIN_NUMERIC_ATTRIBUTES = {"start_time", "end_time"} _ALTERNATE_NUMERIC_ATTRIBUTES = {"created", "Created"} _ALTERNATE_STRING_ATTRIBUTES = {"run name", "Run name", "Run Name"} NUMERIC_ATTRIBUTES = set( list(_BUILTIN_NUMERIC_ATTRIBUTES) + list(_ALTERNATE_NUMERIC_ATTRIBUTES) ) DATASET_ATTRIBUTES = {"name", "digest", "context"} VALID_SEARCH_ATTRIBUTE_KEYS = set( RunInfo.get_searchable_attributes() + list(_ALTERNATE_NUMERIC_ATTRIBUTES) + list(_ALTERNATE_STRING_ATTRIBUTES) ) VALID_ORDER_BY_ATTRIBUTE_KEYS = set( RunInfo.get_orderable_attributes() + list(_ALTERNATE_NUMERIC_ATTRIBUTES) ) _METRIC_IDENTIFIER = "metric" _ALTERNATE_METRIC_IDENTIFIERS = {"metrics"} _PARAM_IDENTIFIER = "parameter" _ALTERNATE_PARAM_IDENTIFIERS = {"parameters", "param", "params"} _TAG_IDENTIFIER = "tag" _ALTERNATE_TAG_IDENTIFIERS = {"tags"} _ATTRIBUTE_IDENTIFIER = "attribute" _ALTERNATE_ATTRIBUTE_IDENTIFIERS = {"attr", "attributes", "run"} _DATASET_IDENTIFIER = "dataset" _ALTERNATE_DATASET_IDENTIFIERS = {"datasets"} _IDENTIFIERS = [ _METRIC_IDENTIFIER, _PARAM_IDENTIFIER, _TAG_IDENTIFIER, _ATTRIBUTE_IDENTIFIER, _DATASET_IDENTIFIER, ] _VALID_IDENTIFIERS = set( _IDENTIFIERS + list(_ALTERNATE_METRIC_IDENTIFIERS) + list(_ALTERNATE_PARAM_IDENTIFIERS) + list(_ALTERNATE_TAG_IDENTIFIERS) + list(_ALTERNATE_ATTRIBUTE_IDENTIFIERS) + list(_ALTERNATE_DATASET_IDENTIFIERS) ) STRING_VALUE_TYPES = {TokenType.Literal.String.Single} DELIMITER_VALUE_TYPES = {TokenType.Punctuation} WHITESPACE_VALUE_TYPE = TokenType.Text.Whitespace NUMERIC_VALUE_TYPES = {TokenType.Literal.Number.Integer, TokenType.Literal.Number.Float} # Registered Models Constants ORDER_BY_KEY_TIMESTAMP = "timestamp" ORDER_BY_KEY_LAST_UPDATED_TIMESTAMP = "last_updated_timestamp" ORDER_BY_KEY_MODEL_NAME = "name" VALID_ORDER_BY_KEYS_REGISTERED_MODELS = { ORDER_BY_KEY_TIMESTAMP, ORDER_BY_KEY_LAST_UPDATED_TIMESTAMP, ORDER_BY_KEY_MODEL_NAME, } VALID_TIMESTAMP_ORDER_BY_KEYS = {ORDER_BY_KEY_TIMESTAMP, ORDER_BY_KEY_LAST_UPDATED_TIMESTAMP} # We encourage users to use timestamp for order-by RECOMMENDED_ORDER_BY_KEYS_REGISTERED_MODELS = {ORDER_BY_KEY_MODEL_NAME, ORDER_BY_KEY_TIMESTAMP} @staticmethod def get_comparison_func(comparator): return { ">": operator.gt, ">=": operator.ge, "=": operator.eq, "!=": operator.ne, "<=": operator.le, "<": operator.lt, "LIKE": _like, "ILIKE": _ilike, "IN": lambda x, y: x in y, "NOT IN": lambda x, y: x not in y, }[comparator] @staticmethod def get_sql_comparison_func(comparator, dialect): import sqlalchemy as sa def comparison_func(column, value): if comparator == "LIKE": return column.like(value) elif comparator == "ILIKE": return column.ilike(value) elif comparator == "IN": return column.in_(value) elif comparator == "NOT IN": return ~column.in_(value) return SearchUtils.get_comparison_func(comparator)(column, value) def mssql_comparison_func(column, value): if comparator == "RLIKE": raise MlflowException( "RLIKE operator is not supported for MSSQL database dialect. " "Consider using LIKE or ILIKE operators instead.", error_code=INVALID_PARAMETER_VALUE, ) if not isinstance(column.type, sa.types.String): return comparison_func(column, value) collated = column.collate(_MSSQL_CASE_SENSITIVE_COLLATION) return comparison_func(collated, value) def mysql_comparison_func(column, value): if not isinstance(column.type, sa.types.String): return comparison_func(column, value) # MySQL is case insensitive by default, so we need to use the binary operator to # perform case sensitive comparisons. templates = { # Use non-binary ahead of binary comparison for runtime performance "=": "({column} = :value AND BINARY {column} = :value)", "!=": "({column} != :value OR BINARY {column} != :value)", "LIKE": "({column} LIKE :value AND BINARY {column} LIKE :value)", # we need to cast the column to binary to perform a case sensitive comparison # to avoid error like: `Character set 'utf8mb4_0900_ai_ci' cannot be used in # conjunction with 'binary' in call to regexp_like` "RLIKE": "(CAST({column} AS BINARY) REGEXP BINARY :value)", } if comparator in templates: column = f"{column.class_.__tablename__}.{column.key}" return sa.text(templates[comparator].format(column=column)).bindparams( sa.bindparam("value", value=value, unique=True) ) return comparison_func(column, value) def sqlite_comparison_func(column, value): if comparator == "RLIKE": # SQLite requires a custom regexp function to be registered # Use the built-in function if available return column.op("REGEXP")(value) return comparison_func(column, value) def postgres_comparison_func(column, value): if comparator == "RLIKE": return column.op("~")(value) return comparison_func(column, value) return { POSTGRES: postgres_comparison_func, SQLITE: sqlite_comparison_func, MSSQL: mssql_comparison_func, MYSQL: mysql_comparison_func, }[dialect] @staticmethod def translate_key_alias(key): if key in ["created", "Created"]: return "start_time" if key in ["run name", "Run name", "Run Name"]: return "run_name" return key @classmethod def _trim_ends(cls, string_value): return string_value[1:-1] @classmethod def _is_quoted(cls, value, pattern): return len(value) >= 2 and value.startswith(pattern) and value.endswith(pattern) @classmethod def _trim_backticks(cls, entity_type): """Remove backticks from identifier like `param`, if they exist.""" if cls._is_quoted(entity_type, "`"): return cls._trim_ends(entity_type) return entity_type @classmethod def _strip_quotes(cls, value, expect_quoted_value=False): """ Remove quotes for input string. Values of type strings are expected to have quotes. Keys containing special characters are also expected to be enclose in quotes. """ if cls._is_quoted(value, "'") or cls._is_quoted(value, '"'): return cls._trim_ends(value) elif expect_quoted_value: raise MlflowException( "Parameter value is either not quoted or unidentified quote " f"types used for string value {value}. Use either single or double " "quotes.", error_code=INVALID_PARAMETER_VALUE, ) else: return value @classmethod def _valid_entity_type(cls, entity_type): entity_type = cls._trim_backticks(entity_type) if entity_type not in cls._VALID_IDENTIFIERS: raise MlflowException( f"Invalid entity type '{entity_type}'. Valid values are {cls._IDENTIFIERS}", error_code=INVALID_PARAMETER_VALUE, ) if entity_type in cls._ALTERNATE_PARAM_IDENTIFIERS: return cls._PARAM_IDENTIFIER elif entity_type in cls._ALTERNATE_METRIC_IDENTIFIERS: return cls._METRIC_IDENTIFIER elif entity_type in cls._ALTERNATE_TAG_IDENTIFIERS: return cls._TAG_IDENTIFIER elif entity_type in cls._ALTERNATE_ATTRIBUTE_IDENTIFIERS: return cls._ATTRIBUTE_IDENTIFIER elif entity_type in cls._ALTERNATE_DATASET_IDENTIFIERS: return cls._DATASET_IDENTIFIER else: # one of ("metric", "parameter", "tag", or "attribute") since it a valid type return entity_type @classmethod def _get_identifier(cls, identifier, valid_attributes): try: tokens = identifier.split(".", 1) if len(tokens) == 1: key = tokens[0] entity_type = cls._ATTRIBUTE_IDENTIFIER else: entity_type, key = tokens except ValueError: raise MlflowException( f"Invalid identifier {identifier!r}. Columns should be specified as " "'attribute.', 'metric.', 'tag.', 'dataset.', or " "'param.'.", error_code=INVALID_PARAMETER_VALUE, ) identifier = cls._valid_entity_type(entity_type) key = cls._trim_backticks(cls._strip_quotes(key)) if identifier == cls._ATTRIBUTE_IDENTIFIER and key not in valid_attributes: raise MlflowException.invalid_parameter_value( f"Invalid attribute key '{key}' specified. Valid keys are '{valid_attributes}'" ) elif identifier == cls._DATASET_IDENTIFIER and key not in cls.DATASET_ATTRIBUTES: raise MlflowException.invalid_parameter_value( f"Invalid dataset key '{key}' specified. Valid keys are '{cls.DATASET_ATTRIBUTES}'" ) return {"type": identifier, "key": key} @classmethod def validate_list_supported(cls, key: str) -> None: if key != "run_id": raise MlflowException( "Only the 'run_id' attribute supports comparison with a list of quoted " "string values.", error_code=INVALID_PARAMETER_VALUE, ) @classmethod def _get_value(cls, identifier_type, key, token): if identifier_type == cls._METRIC_IDENTIFIER: if token.ttype not in cls.NUMERIC_VALUE_TYPES: raise MlflowException( f"Expected numeric value type for metric. Found {token.value}", error_code=INVALID_PARAMETER_VALUE, ) return token.value elif identifier_type in (cls._PARAM_IDENTIFIER, cls._TAG_IDENTIFIER): if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._ATTRIBUTE_IDENTIFIER: if key in cls.NUMERIC_ATTRIBUTES: if token.ttype not in cls.NUMERIC_VALUE_TYPES: raise MlflowException( f"Expected numeric value type for numeric attribute: {key}. " f"Found {token.value}", error_code=INVALID_PARAMETER_VALUE, ) return token.value elif token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): cls.validate_list_supported(key) return cls._parse_run_ids(token) else: raise MlflowException( f"Expected a quoted string value for attributes. Got value {token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._DATASET_IDENTIFIER: if key in cls.DATASET_ATTRIBUTES and ( token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier) ): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): if key not in ("name", "digest", "context"): raise MlflowException( "Only the dataset 'name' and 'digest' supports comparison with a list of " "quoted string values.", error_code=INVALID_PARAMETER_VALUE, ) return cls._parse_run_ids(token) else: raise MlflowException( "Expected a quoted string value for dataset attributes. " f"Got value {token.value}", error_code=INVALID_PARAMETER_VALUE, ) else: # Expected to be either "param" or "metric". raise MlflowException( "Invalid identifier type. Expected one of " f"{[cls._METRIC_IDENTIFIER, cls._PARAM_IDENTIFIER]}." ) @classmethod def _validate_comparison(cls, tokens): base_error_string = "Invalid comparison clause" if len(tokens) == 2: comparator = tokens[1].value.upper() if comparator in ("IS NULL", "IS NOT NULL"): if not isinstance(tokens[0], Identifier): raise MlflowException( f"{base_error_string}. Expected 'Identifier' found '{tokens[0]}'", error_code=INVALID_PARAMETER_VALUE, ) return if len(tokens) != 3: raise MlflowException( f"{base_error_string}. Expected 3 tokens found {len(tokens)}", error_code=INVALID_PARAMETER_VALUE, ) if not isinstance(tokens[0], Identifier): raise MlflowException( f"{base_error_string}. Expected 'Identifier' found '{tokens[0]}'", error_code=INVALID_PARAMETER_VALUE, ) if not isinstance(tokens[1], Token) and tokens[1].ttype != TokenType.Operator.Comparison: raise MlflowException( f"{base_error_string}. Expected comparison found '{tokens[1]}'", error_code=INVALID_PARAMETER_VALUE, ) if not isinstance(tokens[2], Token) and ( tokens[2].ttype not in cls.STRING_VALUE_TYPES.union(cls.NUMERIC_VALUE_TYPES) or isinstance(tokens[2], Identifier) ): raise MlflowException( f"{base_error_string}. Expected value token found '{tokens[2]}'", error_code=INVALID_PARAMETER_VALUE, ) @classmethod def _get_comparison(cls, comparison): stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] cls._validate_comparison(stripped_comparison) # Handle IS NULL / IS NOT NULL (2 tokens: identifier + comparator, no value) if len(stripped_comparison) == 2: comparator = stripped_comparison[1].value.upper() comp = cls._get_identifier( stripped_comparison[0].value, cls.VALID_SEARCH_ATTRIBUTE_KEYS ) if comp["type"] not in (cls._TAG_IDENTIFIER, cls._PARAM_IDENTIFIER): raise MlflowException( "IS NULL / IS NOT NULL is only supported for tags and params, " f"not for '{comp['type']}' '{comp['key']}'", error_code=INVALID_PARAMETER_VALUE, ) comp["comparator"] = comparator comp["value"] = None return comp comp = cls._get_identifier(stripped_comparison[0].value, cls.VALID_SEARCH_ATTRIBUTE_KEYS) comp["comparator"] = stripped_comparison[1].value comp["value"] = cls._get_value(comp.get("type"), comp.get("key"), stripped_comparison[2]) return comp @classmethod def _invalid_statement_token_search_runs(cls, token): if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True @classmethod def _process_statement(cls, statement): # check validity tokens = _join_in_comparison_tokens(statement.tokens) invalids = list(filter(cls._invalid_statement_token_search_runs, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(f"'{token}'" for token in invalids) raise MlflowException( f"Invalid clause(s) in filter string: {invalid_clauses}", error_code=INVALID_PARAMETER_VALUE, ) return [cls._get_comparison(si) for si in tokens if isinstance(si, Comparison)] @classmethod def parse_search_filter(cls, filter_string): if not filter_string: return [] try: parsed = sqlparse.parse(filter_string) except Exception: raise MlflowException( f"Error on parsing filter '{filter_string}'", error_code=INVALID_PARAMETER_VALUE ) if len(parsed) == 0 or not isinstance(parsed[0], Statement): raise MlflowException( f"Invalid filter '{filter_string}'. Could not be parsed.", error_code=INVALID_PARAMETER_VALUE, ) elif len(parsed) > 1: raise MlflowException( f"Search filter contained multiple expression {filter_string!r}. " "Provide AND-ed expression list.", error_code=INVALID_PARAMETER_VALUE, ) return cls._process_statement(parsed[0]) @classmethod def is_metric(cls, key_type, comparator): if key_type == cls._METRIC_IDENTIFIER: if comparator not in cls.VALID_METRIC_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of '{cls.VALID_METRIC_COMPARATORS}", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_param(cls, key_type, comparator): if key_type == cls._PARAM_IDENTIFIER: if comparator not in cls.VALID_PARAM_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of '{cls.VALID_PARAM_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_tag(cls, key_type, comparator): if key_type == cls._TAG_IDENTIFIER: if comparator not in cls.VALID_TAG_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of '{cls.VALID_TAG_COMPARATORS}", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_attribute(cls, key_type, key_name, comparator): return cls.is_string_attribute(key_type, key_name, comparator) or cls.is_numeric_attribute( key_type, key_name, comparator ) @classmethod def is_string_attribute(cls, key_type, key_name, comparator): if key_type == cls._ATTRIBUTE_IDENTIFIER and key_name not in cls.NUMERIC_ATTRIBUTES: if comparator not in cls.VALID_STRING_ATTRIBUTE_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of " f"'{cls.VALID_STRING_ATTRIBUTE_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_numeric_attribute(cls, key_type, key_name, comparator): if key_type == cls._ATTRIBUTE_IDENTIFIER and key_name in cls.NUMERIC_ATTRIBUTES: if comparator not in cls.VALID_NUMERIC_ATTRIBUTE_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of " f"'{cls.VALID_STRING_ATTRIBUTE_COMPARATORS}", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_dataset(cls, key_type, comparator): if key_type == cls._DATASET_IDENTIFIER: if comparator not in cls.VALID_DATASET_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' " f"not one of '{cls.VALID_DATASET_COMPARATORS}", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def _is_metric_on_dataset(cls, metric: Metric, dataset: dict[str, Any]) -> bool: return metric.dataset_name == dataset.get("dataset_name") and ( dataset.get("dataset_digest") is None or dataset.get("dataset_digest") == metric.dataset_digest ) @classmethod def _does_run_match_clause(cls, run, sed): key_type = sed.get("type") key = sed.get("key") value = sed.get("value") comparator = sed.get("comparator").upper() key = SearchUtils.translate_key_alias(key) if cls.is_metric(key_type, comparator): lhs = run.data.metrics.get(key, None) value = float(value) elif cls.is_param(key_type, comparator): lhs = run.data.params.get(key, None) elif cls.is_tag(key_type, comparator): lhs = run.data.tags.get(key, None) elif cls.is_string_attribute(key_type, key, comparator): lhs = getattr(run.info, key) elif cls.is_numeric_attribute(key_type, key, comparator): lhs = getattr(run.info, key) value = int(value) elif cls.is_dataset(key_type, comparator): if key == "context": return any( SearchUtils.get_comparison_func(comparator)(tag.value if tag else None, value) for dataset_input in run.inputs.dataset_inputs for tag in dataset_input.tags if tag.key == MLFLOW_DATASET_CONTEXT ) else: return any( SearchUtils.get_comparison_func(comparator)( getattr(dataset_input.dataset, key), value ) for dataset_input in run.inputs.dataset_inputs ) else: raise MlflowException( f"Invalid search expression type '{key_type}'", error_code=INVALID_PARAMETER_VALUE ) if comparator in ("IS NULL", "IS NOT NULL"): return (lhs is None) if comparator == "IS NULL" else (lhs is not None) if lhs is None: return False return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def _does_model_match_clause(cls, model, sed): key_type = sed.get("type") key = sed.get("key") value = sed.get("value") comparator = sed.get("comparator").upper() key = SearchUtils.translate_key_alias(key) if cls.is_metric(key_type, comparator): matching_metrics = [metric for metric in model.metrics if metric.key == key] lhs = matching_metrics[0].value if matching_metrics else None value = float(value) elif cls.is_param(key_type, comparator): lhs = model.params.get(key, None) elif cls.is_tag(key_type, comparator): lhs = model.tags.get(key, None) elif cls.is_string_attribute(key_type, key, comparator): lhs = getattr(model.info, key) elif cls.is_numeric_attribute(key_type, key, comparator): lhs = getattr(model.info, key) value = int(value) else: raise MlflowException( f"Invalid model search expression type '{key_type}'", error_code=INVALID_PARAMETER_VALUE, ) if lhs is None: return False return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def filter(cls, runs, filter_string): """Filters a set of runs based on a search filter string.""" if not filter_string: return runs parsed = cls.parse_search_filter(filter_string) def run_matches(run): return all(cls._does_run_match_clause(run, s) for s in parsed) return [run for run in runs if run_matches(run)] @classmethod def _validate_order_by_and_generate_token(cls, order_by): try: parsed = sqlparse.parse(order_by) except Exception: raise MlflowException( f"Error on parsing order_by clause '{order_by}'", error_code=INVALID_PARAMETER_VALUE, ) if len(parsed) != 1 or not isinstance(parsed[0], Statement): raise MlflowException( f"Invalid order_by clause '{order_by}'. Could not be parsed.", error_code=INVALID_PARAMETER_VALUE, ) statement = parsed[0] ttype_for_timestamp = ( TokenType.Name.Builtin if Version(sqlparse.__version__) >= Version("0.4.3") else TokenType.Keyword ) if len(statement.tokens) == 1 and isinstance(statement[0], Identifier): token_value = statement.tokens[0].value elif len(statement.tokens) == 1 and statement.tokens[0].match( ttype=ttype_for_timestamp, values=[cls.ORDER_BY_KEY_TIMESTAMP] ): token_value = cls.ORDER_BY_KEY_TIMESTAMP elif ( statement.tokens[0].match( ttype=ttype_for_timestamp, values=[cls.ORDER_BY_KEY_TIMESTAMP] ) and all(token.is_whitespace for token in statement.tokens[1:-1]) and statement.tokens[-1].ttype == TokenType.Keyword.Order ): token_value = cls.ORDER_BY_KEY_TIMESTAMP + " " + statement.tokens[-1].value else: raise MlflowException( f"Invalid order_by clause '{order_by}'. Could not be parsed.", error_code=INVALID_PARAMETER_VALUE, ) return token_value @classmethod def _parse_order_by_string(cls, order_by): token_value = cls._validate_order_by_and_generate_token(order_by) is_ascending = True tokens = shlex.split(token_value.replace("`", '"')) if len(tokens) > 2: raise MlflowException( f"Invalid order_by clause '{order_by}'. Could not be parsed.", error_code=INVALID_PARAMETER_VALUE, ) elif len(tokens) == 2: order_token = tokens[1].lower() if order_token not in cls.VALID_ORDER_BY_TAGS: raise MlflowException( f"Invalid ordering key in order_by clause '{order_by}'.", error_code=INVALID_PARAMETER_VALUE, ) is_ascending = order_token == cls.ASC_OPERATOR token_value = tokens[0] return token_value, is_ascending @classmethod def parse_order_by_for_search_runs(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) identifier = cls._get_identifier(token_value.strip(), cls.VALID_ORDER_BY_ATTRIBUTE_KEYS) return identifier["type"], identifier["key"], is_ascending @classmethod def parse_order_by_for_search_registered_models(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) token_value = token_value.strip() if token_value not in cls.VALID_ORDER_BY_KEYS_REGISTERED_MODELS: raise MlflowException( f"Invalid order by key '{token_value}' specified. Valid keys " f"are '{cls.RECOMMENDED_ORDER_BY_KEYS_REGISTERED_MODELS}'", error_code=INVALID_PARAMETER_VALUE, ) return token_value, is_ascending @classmethod def _get_value_for_sort(cls, run, key_type, key, ascending): """Returns a tuple suitable to be used as a sort key for runs.""" sort_value = None key = SearchUtils.translate_key_alias(key) if key_type == cls._METRIC_IDENTIFIER: sort_value = run.data.metrics.get(key) elif key_type == cls._PARAM_IDENTIFIER: sort_value = run.data.params.get(key) elif key_type == cls._TAG_IDENTIFIER: sort_value = run.data.tags.get(key) elif key_type == cls._ATTRIBUTE_IDENTIFIER: sort_value = getattr(run.info, key) else: raise MlflowException( f"Invalid order_by entity type '{key_type}'", error_code=INVALID_PARAMETER_VALUE ) # Return a key such that None values are always at the end. is_none = sort_value is None is_nan = isinstance(sort_value, float) and math.isnan(sort_value) fill_value = (1 if ascending else -1) * math.inf if is_none: sort_value = fill_value elif is_nan: sort_value = -fill_value is_none_or_nan = is_none or is_nan return (is_none_or_nan, sort_value) if ascending else (not is_none_or_nan, sort_value) @classmethod def _get_model_value_for_sort(cls, model, key_type, key, ascending): """Returns a tuple suitable to be used as a sort key for models.""" sort_value = None key = SearchUtils.translate_key_alias(key) if key_type == cls._METRIC_IDENTIFIER: matching_metrics = [metric for metric in model.metrics if metric.key == key] sort_value = float(matching_metrics[0].value) if matching_metrics else None elif key_type == cls._PARAM_IDENTIFIER: sort_value = model.params.get(key) elif key_type == cls._TAG_IDENTIFIER: sort_value = model.tags.get(key) elif key_type == cls._ATTRIBUTE_IDENTIFIER: sort_value = getattr(model, key) else: raise MlflowException( f"Invalid models order_by entity type '{key_type}'", error_code=INVALID_PARAMETER_VALUE, ) # Return a key such that None values are always at the end. is_none = sort_value is None is_nan = isinstance(sort_value, float) and math.isnan(sort_value) fill_value = (1 if ascending else -1) * math.inf if is_none: sort_value = fill_value elif is_nan: sort_value = -fill_value is_none_or_nan = is_none or is_nan return (is_none_or_nan, sort_value) if ascending else (not is_none_or_nan, sort_value) @classmethod def sort(cls, runs, order_by_list): """Sorts a set of runs based on their natural ordering and an overriding set of order_bys. Runs are naturally ordered first by start time descending, then by run id for tie-breaking. """ runs = sorted(runs, key=lambda run: (-run.info.start_time, run.info.run_id)) if not order_by_list: return runs # NB: We rely on the stability of Python's sort function, so that we can apply # the ordering conditions in reverse order. for order_by_clause in reversed(order_by_list): (key_type, key, ascending) = cls.parse_order_by_for_search_runs(order_by_clause) runs = sorted( runs, key=lambda run: cls._get_value_for_sort(run, key_type, key, ascending), reverse=not ascending, ) return runs @classmethod def parse_start_offset_from_page_token(cls, page_token): # Note: the page_token is expected to be a base64-encoded JSON that looks like # { "offset": xxx }. However, this format is not stable, so it should not be # relied upon outside of this method. if not page_token: return 0 try: decoded_token = base64.b64decode(page_token) except TypeError: raise MlflowException( "Invalid page token, could not base64-decode", error_code=INVALID_PARAMETER_VALUE ) except base64.binascii.Error: raise MlflowException( "Invalid page token, could not base64-decode", error_code=INVALID_PARAMETER_VALUE ) try: parsed_token = json.loads(decoded_token) except ValueError: raise MlflowException( f"Invalid page token, decoded value={decoded_token}", error_code=INVALID_PARAMETER_VALUE, ) offset_str = parsed_token.get("offset") if not offset_str: raise MlflowException( f"Invalid page token, parsed value={parsed_token}", error_code=INVALID_PARAMETER_VALUE, ) try: offset = int(offset_str) except ValueError: raise MlflowException( f"Invalid page token, not stringable {offset_str}", error_code=INVALID_PARAMETER_VALUE, ) return offset @classmethod def create_page_token(cls, offset): return base64.b64encode(json.dumps({"offset": offset}).encode("utf-8")) @classmethod def paginate(cls, runs, page_token, max_results): """Paginates a set of runs based on an offset encoded into the page_token and a max results limit. Returns a pair containing the set of paginated runs, followed by an optional next_page_token if there are further results that need to be returned. """ start_offset = cls.parse_start_offset_from_page_token(page_token) final_offset = start_offset + max_results paginated_runs = runs[start_offset:final_offset] next_page_token = None if final_offset < len(runs): next_page_token = cls.create_page_token(final_offset) return (paginated_runs, next_page_token) # Model Registry specific parser # TODO: Tech debt. Refactor search code into common utils, tracking server, and model # registry specific code. VALID_SEARCH_KEYS_FOR_MODEL_VERSIONS = {"name", "run_id", "source_path"} VALID_SEARCH_KEYS_FOR_REGISTERED_MODELS = {"name"} @classmethod def _check_valid_identifier_list(cls, tup: tuple[Any, ...]) -> None: """ Validate that `tup` is a non-empty tuple of strings. """ if len(tup) == 0: raise MlflowException( "While parsing a list in the query," " expected a non-empty list of string values, but got empty list", error_code=INVALID_PARAMETER_VALUE, ) if not all(isinstance(x, str) for x in tup): raise MlflowException( "While parsing a list in the query, expected string value, punctuation, " f"or whitespace, but got different type in list: {tup}", error_code=INVALID_PARAMETER_VALUE, ) @classmethod def _parse_list_from_sql_token(cls, token): try: parsed = ast.literal_eval(token.value) except SyntaxError as e: raise MlflowException( "While parsing a list in the query," " expected a non-empty list of string values, but got ill-formed list.", error_code=INVALID_PARAMETER_VALUE, ) from e parsed = parsed if isinstance(parsed, tuple) else (parsed,) cls._check_valid_identifier_list(parsed) return parsed @classmethod def _parse_run_ids(cls, token): run_id_list = cls._parse_list_from_sql_token(token) # Because MySQL IN clause is case-insensitive, but all run_ids only contain lower # case letters, so that we filter out run_ids containing upper case letters here. return [run_id for run_id in run_id_list if run_id.islower()] class SearchExperimentsUtils(SearchUtils): VALID_SEARCH_ATTRIBUTE_KEYS = {"name", "creation_time", "last_update_time"} VALID_ORDER_BY_ATTRIBUTE_KEYS = {"name", "experiment_id", "creation_time", "last_update_time"} NUMERIC_ATTRIBUTES = {"creation_time", "last_update_time"} VALID_TAG_COMPARATORS = {"!=", "=", "LIKE", "ILIKE", "IS NULL", "IS NOT NULL"} @classmethod def _invalid_statement_token_search_experiments(cls, token): if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True @classmethod def _process_statement(cls, statement): tokens = _join_in_comparison_tokens(statement.tokens) invalids = list(filter(cls._invalid_statement_token_search_experiments, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(map(str, invalids)) raise MlflowException.invalid_parameter_value( f"Invalid clause(s) in filter string: {invalid_clauses}" ) return [cls._get_comparison(t) for t in tokens if isinstance(t, Comparison)] @classmethod def _get_identifier(cls, identifier, valid_attributes): tokens = identifier.split(".", maxsplit=1) if len(tokens) == 1: key = tokens[0] identifier = cls._ATTRIBUTE_IDENTIFIER else: entity_type, key = tokens valid_entity_types = ("attribute", "tag", "tags") if entity_type not in valid_entity_types: raise MlflowException.invalid_parameter_value( f"Invalid entity type '{entity_type}'. " f"Valid entity types are {valid_entity_types}" ) identifier = cls._valid_entity_type(entity_type) key = cls._trim_backticks(cls._strip_quotes(key)) if identifier == cls._ATTRIBUTE_IDENTIFIER and key not in valid_attributes: raise MlflowException.invalid_parameter_value( f"Invalid attribute key '{key}' specified. Valid keys are '{valid_attributes}'" ) return {"type": identifier, "key": key} @classmethod def _validate_comparison(cls, tokens): # Allow 2-token IS NULL / IS NOT NULL comparisons for tags if len(tokens) == 2: comparator = tokens[1].value.upper() if comparator in ("IS NULL", "IS NOT NULL"): if not isinstance(tokens[0], Identifier): raise MlflowException( f"Invalid comparison clause. Expected 'Identifier' found '{tokens[0]}'", error_code=INVALID_PARAMETER_VALUE, ) return super()._validate_comparison(tokens) @classmethod def _get_comparison(cls, comparison): stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] cls._validate_comparison(stripped_comparison) # Handle IS NULL / IS NOT NULL (2 tokens: identifier + comparator, no value) if len(stripped_comparison) == 2: comparator = stripped_comparison[1].value.upper() comp = cls._get_identifier( stripped_comparison[0].value, cls.VALID_SEARCH_ATTRIBUTE_KEYS ) if comp["type"] != cls._TAG_IDENTIFIER: raise MlflowException.invalid_parameter_value( f"IS NULL / IS NOT NULL is only supported for tags, " f"not for attribute '{comp['key']}'" ) comp["comparator"] = comparator comp["value"] = None return comp left, comparator, right = stripped_comparison comp = cls._get_identifier(left.value, cls.VALID_SEARCH_ATTRIBUTE_KEYS) comp["comparator"] = comparator.value comp["value"] = cls._get_value(comp.get("type"), comp.get("key"), right) return comp @classmethod def parse_order_by_for_search_experiments(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) identifier = cls._get_identifier(token_value.strip(), cls.VALID_ORDER_BY_ATTRIBUTE_KEYS) return identifier["type"], identifier["key"], is_ascending @classmethod def is_attribute(cls, key_type, comparator): if key_type == cls._ATTRIBUTE_IDENTIFIER: if comparator not in cls.VALID_STRING_ATTRIBUTE_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of " f"'{cls.VALID_STRING_ATTRIBUTE_COMPARATORS}'" ) return True return False @classmethod def _does_experiment_match_clause(cls, experiment, sed): key_type = sed.get("type") key = sed.get("key") value = sed.get("value") comparator = sed.get("comparator").upper() if cls.is_string_attribute(key_type, key, comparator): lhs = getattr(experiment, key) elif cls.is_numeric_attribute(key_type, key, comparator): lhs = getattr(experiment, key) value = float(value) elif cls.is_tag(key_type, comparator): if comparator == "IS NULL": return key not in experiment.tags elif comparator == "IS NOT NULL": return key in experiment.tags if key not in experiment.tags: return False lhs = experiment.tags.get(key, None) if lhs is None: return experiment else: raise MlflowException( f"Invalid search expression type '{key_type}'", error_code=INVALID_PARAMETER_VALUE ) return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def filter(cls, experiments, filter_string): if not filter_string: return experiments parsed = cls.parse_search_filter(filter_string) def experiment_matches(experiment): return all(cls._does_experiment_match_clause(experiment, s) for s in parsed) return list(filter(experiment_matches, experiments)) @classmethod def _get_sort_key(cls, order_by_list): order_by = [] parsed_order_by = map(cls.parse_order_by_for_search_experiments, order_by_list) for type_, key, ascending in parsed_order_by: if type_ == "attribute": order_by.append((key, ascending)) else: raise MlflowException.invalid_parameter_value(f"Invalid order_by entity: {type_}") # Add a tie-breaker if not any(key == "experiment_id" for key, _ in order_by): order_by.append(("experiment_id", False)) # https://stackoverflow.com/a/56842689 class _Sorter: def __init__(self, obj, ascending): self.obj = obj self.ascending = ascending # Only need < and == are needed for use as a key parameter in the sorted function def __eq__(self, other): return other.obj == self.obj def __lt__(self, other): if self.obj is None: return False elif other.obj is None: return True elif self.ascending: return self.obj < other.obj else: return other.obj < self.obj def _apply_sorter(experiment, key, ascending): attr = getattr(experiment, key) return _Sorter(attr, ascending) return lambda experiment: tuple(_apply_sorter(experiment, k, asc) for (k, asc) in order_by) @classmethod def sort(cls, experiments, order_by_list): return sorted(experiments, key=cls._get_sort_key(order_by_list)) # https://stackoverflow.com/a/56842689 class _Reversor: def __init__(self, obj): self.obj = obj # Only need < and == are needed for use as a key parameter in the sorted function def __eq__(self, other): return other.obj == self.obj def __lt__(self, other): if self.obj is None: return False if other.obj is None: return True return other.obj < self.obj def _apply_reversor(model, key, ascending): attr = getattr(model, key) return attr if ascending else _Reversor(attr) class SearchModelUtils(SearchUtils): NUMERIC_ATTRIBUTES = {"creation_timestamp", "last_updated_timestamp"} VALID_SEARCH_ATTRIBUTE_KEYS = {"name"} VALID_ORDER_BY_KEYS_REGISTERED_MODELS = {"name", "creation_timestamp", "last_updated_timestamp"} VALID_TAG_COMPARATORS = {"!=", "=", "LIKE", "ILIKE"} @classmethod def _does_registered_model_match_clauses(cls, model, sed): key_type = sed.get("type") key = sed.get("key") value = sed.get("value") comparator = sed.get("comparator").upper() # what comparators do we support here? if cls.is_string_attribute(key_type, key, comparator): lhs = getattr(model, key) elif cls.is_numeric_attribute(key_type, key, comparator): lhs = getattr(model, key) value = int(value) elif cls.is_tag(key_type, comparator): # NB: We should use the private attribute `_tags` instead of the `tags` property # to consider all tags including reserved ones. lhs = model._tags.get(key, None) else: raise MlflowException( f"Invalid search expression type '{key_type}'", error_code=INVALID_PARAMETER_VALUE ) # NB: Handling the special `mlflow.prompt.is_prompt` tag. This tag is used for # distinguishing between prompt models and normal models. For example, we want to # search for models only by the following filter string: # # tags.`mlflow.prompt.is_prompt` != 'true' # tags.`mlflow.prompt.is_prompt` = 'false' # # However, models do not have this tag, so lhs is None in this case. Instead of returning # False like normal tag filter, we need to return True here. if key == IS_PROMPT_TAG_KEY and lhs is None: return (comparator == "=" and value == "false") or ( comparator == "!=" and value == "true" ) if lhs is None: return False return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def filter(cls, registered_models, filter_string): """Filters a set of registered models based on a search filter string.""" if not filter_string: return registered_models parsed = cls.parse_search_filter(filter_string) def registered_model_matches(model): return all(cls._does_registered_model_match_clauses(model, s) for s in parsed) return [ registered_model for registered_model in registered_models if registered_model_matches(registered_model) ] @classmethod def parse_order_by_for_search_registered_models(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) identifier = SearchExperimentsUtils._get_identifier( token_value.strip(), cls.VALID_ORDER_BY_KEYS_REGISTERED_MODELS ) return identifier["type"], identifier["key"], is_ascending @classmethod def _get_sort_key(cls, order_by_list): order_by = [] parsed_order_by = map(cls.parse_order_by_for_search_registered_models, order_by_list or []) for type_, key, ascending in parsed_order_by: if type_ == "attribute": order_by.append((key, ascending)) else: raise MlflowException.invalid_parameter_value(f"Invalid order_by entity: {type_}") # Add a tie-breaker if not any(key == "name" for key, _ in order_by): order_by.append(("name", True)) return lambda model: tuple(_apply_reversor(model, k, asc) for (k, asc) in order_by) @classmethod def sort(cls, models, order_by_list): return sorted(models, key=cls._get_sort_key(order_by_list)) @classmethod def _process_statement(cls, statement): tokens = _join_in_comparison_tokens(statement.tokens) invalids = list(filter(cls._invalid_statement_token_search_model_registry, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(map(str, invalids)) raise MlflowException.invalid_parameter_value( f"Invalid clause(s) in filter string: {invalid_clauses}" ) return [cls._get_comparison(t) for t in tokens if isinstance(t, Comparison)] @classmethod def _get_model_search_identifier(cls, identifier, valid_attributes): tokens = identifier.split(".", maxsplit=1) if len(tokens) == 1: key = tokens[0] identifier = cls._ATTRIBUTE_IDENTIFIER else: entity_type, key = tokens valid_entity_types = ("attribute", "tag", "tags") if entity_type not in valid_entity_types: raise MlflowException.invalid_parameter_value( f"Invalid entity type '{entity_type}'. " f"Valid entity types are {valid_entity_types}" ) identifier = ( cls._TAG_IDENTIFIER if entity_type in ("tag", "tags") else cls._ATTRIBUTE_IDENTIFIER ) if identifier == cls._ATTRIBUTE_IDENTIFIER and key not in valid_attributes: raise MlflowException.invalid_parameter_value( f"Invalid attribute key '{key}' specified. Valid keys are '{valid_attributes}'" ) key = cls._trim_backticks(cls._strip_quotes(key)) return {"type": identifier, "key": key} @classmethod def _get_comparison(cls, comparison): stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] cls._validate_comparison(stripped_comparison) left, comparator, right = stripped_comparison comp = cls._get_model_search_identifier(left.value, cls.VALID_SEARCH_ATTRIBUTE_KEYS) comp["comparator"] = comparator.value.upper() comp["value"] = cls._get_value(comp.get("type"), comp.get("key"), right) return comp @classmethod def _get_value(cls, identifier_type, key, token): if identifier_type == cls._TAG_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._ATTRIBUTE_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): if key != "run_id": raise MlflowException( "Only the 'run_id' attribute supports comparison with a list of quoted " "string values.", error_code=INVALID_PARAMETER_VALUE, ) return cls._parse_run_ids(token) else: raise MlflowException( "Expected a quoted string value or a list of quoted string values for " f"attributes. Got value {token.value}", error_code=INVALID_PARAMETER_VALUE, ) else: # Expected to be either "param" or "metric". raise MlflowException( "Invalid identifier type. Expected one of " f"{[cls._ATTRIBUTE_IDENTIFIER, cls._TAG_IDENTIFIER]}.", error_code=INVALID_PARAMETER_VALUE, ) @classmethod def _invalid_statement_token_search_model_registry(cls, token): if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True class SearchModelVersionUtils(SearchUtils): NUMERIC_ATTRIBUTES = {"version_number", "creation_timestamp", "last_updated_timestamp"} VALID_SEARCH_ATTRIBUTE_KEYS = { "name", "version_number", "run_id", "source_path", } VALID_ORDER_BY_ATTRIBUTE_KEYS = { "name", "version_number", "creation_timestamp", "last_updated_timestamp", } VALID_STRING_ATTRIBUTE_COMPARATORS = {"!=", "=", "LIKE", "ILIKE", "IN"} VALID_TAG_COMPARATORS = {"!=", "=", "LIKE", "ILIKE"} @classmethod def _does_model_version_match_clauses(cls, mv, sed): key_type = sed.get("type") key = sed.get("key") value = sed.get("value") comparator = sed.get("comparator").upper() if cls.is_string_attribute(key_type, key, comparator): lhs = getattr(mv, "source" if key == "source_path" else key) elif cls.is_numeric_attribute(key_type, key, comparator): if key == "version_number": key = "version" lhs = getattr(mv, key) value = int(value) elif cls.is_tag(key_type, comparator): lhs = mv.tags.get(key, None) else: raise MlflowException( f"Invalid search expression type '{key_type}'", error_code=INVALID_PARAMETER_VALUE ) # NB: Handling the special `mlflow.prompt.is_prompt` tag. This tag is used for # distinguishing between prompt models and normal models. For example, we want to # search for models only by the following filter string: # # tags.`mlflow.prompt.is_prompt` != 'true' # tags.`mlflow.prompt.is_prompt` = 'false' # # However, models do not have this tag, so lhs is None in this case. Instead of returning # False like normal tag filter, we need to return True here. if key == IS_PROMPT_TAG_KEY and lhs is None: return (comparator == "=" and value == "false") or ( comparator == "!=" and value == "true" ) if lhs is None: return False if comparator == "IN" and isinstance(value, (set, list)): return lhs in set(value) return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def filter(cls, model_versions, filter_string): """Filters a set of model versions based on a search filter string.""" model_versions = [mv for mv in model_versions if mv.current_stage != STAGE_DELETED_INTERNAL] if not filter_string: return model_versions parsed = cls.parse_search_filter(filter_string) def model_version_matches(mv): return all(cls._does_model_version_match_clauses(mv, s) for s in parsed) return [mv for mv in model_versions if model_version_matches(mv)] @classmethod def parse_order_by_for_search_model_versions(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) identifier = SearchExperimentsUtils._get_identifier( token_value.strip(), cls.VALID_ORDER_BY_ATTRIBUTE_KEYS ) return identifier["type"], identifier["key"], is_ascending @classmethod def _get_sort_key(cls, order_by_list): order_by = [] parsed_order_by = map(cls.parse_order_by_for_search_model_versions, order_by_list or []) for type_, key, ascending in parsed_order_by: if type_ == "attribute": # Need to add this mapping because version is a keyword in sql if key == "version_number": key = "version" order_by.append((key, ascending)) else: raise MlflowException.invalid_parameter_value(f"Invalid order_by entity: {type_}") # Add a tie-breaker if not any(key == "name" for key, _ in order_by): order_by.append(("name", True)) if not any(key == "version_number" for key, _ in order_by): order_by.append(("version", False)) return lambda model_version: tuple( _apply_reversor(model_version, k, asc) for (k, asc) in order_by ) @classmethod def sort(cls, model_versions, order_by_list): return sorted(model_versions, key=cls._get_sort_key(order_by_list)) @classmethod def _get_model_version_search_identifier(cls, identifier, valid_attributes): tokens = identifier.split(".", maxsplit=1) if len(tokens) == 1: key = tokens[0] identifier = cls._ATTRIBUTE_IDENTIFIER else: entity_type, key = tokens valid_entity_types = ("attribute", "tag", "tags") if entity_type not in valid_entity_types: raise MlflowException.invalid_parameter_value( f"Invalid entity type '{entity_type}'. " f"Valid entity types are {valid_entity_types}" ) identifier = ( cls._TAG_IDENTIFIER if entity_type in ("tag", "tags") else cls._ATTRIBUTE_IDENTIFIER ) if identifier == cls._ATTRIBUTE_IDENTIFIER and key not in valid_attributes: raise MlflowException.invalid_parameter_value( f"Invalid attribute key '{key}' specified. Valid keys are '{valid_attributes}'" ) key = cls._trim_backticks(cls._strip_quotes(key)) return {"type": identifier, "key": key} @classmethod def _get_comparison(cls, comparison): stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] cls._validate_comparison(stripped_comparison) left, comparator, right = stripped_comparison comp = cls._get_model_version_search_identifier(left.value, cls.VALID_SEARCH_ATTRIBUTE_KEYS) comp["comparator"] = comparator.value.upper() comp["value"] = cls._get_value(comp.get("type"), comp.get("key"), right) return comp @classmethod def _get_value(cls, identifier_type, key, token): if identifier_type == cls._TAG_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._ATTRIBUTE_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): if key != "run_id": raise MlflowException( "Only the 'run_id' attribute supports comparison with a list of quoted " "string values.", error_code=INVALID_PARAMETER_VALUE, ) return cls._parse_run_ids(token) elif token.ttype in cls.NUMERIC_VALUE_TYPES: if key not in cls.NUMERIC_ATTRIBUTES: raise MlflowException( f"Only the '{cls.NUMERIC_ATTRIBUTES}' attributes support comparison with " "numeric values.", error_code=INVALID_PARAMETER_VALUE, ) if token.ttype == TokenType.Literal.Number.Integer: return int(token.value) elif token.ttype == TokenType.Literal.Number.Float: return float(token.value) else: raise MlflowException( "Expected a quoted string value or a list of quoted string values for " f"attributes. Got value {token.value}", error_code=INVALID_PARAMETER_VALUE, ) else: # Expected to be either "param" or "metric". raise MlflowException( "Invalid identifier type. Expected one of " f"{[cls._ATTRIBUTE_IDENTIFIER, cls._TAG_IDENTIFIER]}.", error_code=INVALID_PARAMETER_VALUE, ) @classmethod def _process_statement(cls, statement): tokens = _join_in_comparison_tokens(statement.tokens) invalids = list(filter(cls._invalid_statement_token_search_model_version, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(map(str, invalids)) raise MlflowException.invalid_parameter_value( f"Invalid clause(s) in filter string: {invalid_clauses}" ) return [cls._get_comparison(t) for t in tokens if isinstance(t, Comparison)] @classmethod def _invalid_statement_token_search_model_version(cls, token): if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True @classmethod def parse_search_filter(cls, filter_string): if not filter_string: return [] try: parsed = sqlparse.parse(filter_string) except Exception: raise MlflowException( f"Error on parsing filter '{filter_string}'", error_code=INVALID_PARAMETER_VALUE ) if len(parsed) == 0 or not isinstance(parsed[0], Statement): raise MlflowException( f"Invalid filter '{filter_string}'. Could not be parsed.", error_code=INVALID_PARAMETER_VALUE, ) elif len(parsed) > 1: raise MlflowException( f"Search filter contained multiple expression {filter_string!r}. " "Provide AND-ed expression list.", error_code=INVALID_PARAMETER_VALUE, ) return cls._process_statement(parsed[0]) class SearchTraceUtils(SearchUtils): """ Utility class for searching traces. """ VALID_SEARCH_ATTRIBUTE_KEYS = { "request_id", "timestamp", "timestamp_ms", "execution_time", "execution_time_ms", "end_time", "end_time_ms", "status", "client_request_id", # The following keys are mapped to tags or metadata "name", "run_id", "prompt", # The following key is mapped to span attributes "text", } VALID_ORDER_BY_ATTRIBUTE_KEYS = { "experiment_id", "timestamp", "timestamp_ms", "execution_time", "execution_time_ms", "end_time", "end_time_ms", "status", "request_id", # The following keys are mapped to tags or metadata "name", "run_id", } NUMERIC_ATTRIBUTES = { "timestamp_ms", "timestamp", "execution_time_ms", "execution_time", "end_time_ms", "end_time", } VALID_TAG_COMPARATORS = {"!=", "=", "LIKE", "ILIKE", "RLIKE", "IS NULL", "IS NOT NULL"} VALID_STRING_ATTRIBUTE_COMPARATORS = {"!=", "=", "IN", "NOT IN", "LIKE", "ILIKE", "RLIKE"} VALID_SPAN_ATTRIBUTE_COMPARATORS = {"!=", "=", "IN", "NOT IN", "LIKE", "ILIKE", "RLIKE"} VALID_METADATA_COMPARATORS = {"!=", "=", "LIKE", "ILIKE", "RLIKE", "IS NULL", "IS NOT NULL"} NUMERIC_ASSESSMENT_COMPARATORS = {">", ">=", "<", "<="} VALID_ASSESSMENT_COMPARATORS = { "!=", "=", *NUMERIC_ASSESSMENT_COMPARATORS, "LIKE", "ILIKE", "RLIKE", "IS NULL", "IS NOT NULL", } _REQUEST_METADATA_IDENTIFIER = "request_metadata" _TAG_IDENTIFIER = "tag" _ATTRIBUTE_IDENTIFIER = "attribute" _SPAN_IDENTIFIER = "span" _FEEDBACK_IDENTIFIER = "feedback" _EXPECTATION_IDENTIFIER = "expectation" _ISSUE_IDENTIFIER = "issue" # These are aliases for the base identifiers # e.g. trace.status is equivalent to attribute.status _ALTERNATE_IDENTIFIERS = { "tags": _TAG_IDENTIFIER, "attributes": _ATTRIBUTE_IDENTIFIER, "trace": _ATTRIBUTE_IDENTIFIER, "metadata": _REQUEST_METADATA_IDENTIFIER, } _IDENTIFIERS = { _TAG_IDENTIFIER, _REQUEST_METADATA_IDENTIFIER, _ATTRIBUTE_IDENTIFIER, _SPAN_IDENTIFIER, _FEEDBACK_IDENTIFIER, _EXPECTATION_IDENTIFIER, _ISSUE_IDENTIFIER, } _VALID_IDENTIFIERS = _IDENTIFIERS | set(_ALTERNATE_IDENTIFIERS.keys()) # Supported span attributes _SUPPORTED_SPAN_ATTRIBUTES = {"name", "type", "status"} _SPAN_CONTENT_KEY = "content" VALID_SPAN_CONTENT_COMPARATORS = {"LIKE", "ILIKE"} # Supported issue attributes _SUPPORTED_ISSUE_ATTRIBUTES = {"id"} VALID_ISSUE_COMPARATORS = {"="} SUPPORT_IN_COMPARISON_ATTRIBUTE_KEYS = { "name", "status", "request_id", "run_id", "client_request_id", } # Some search keys are defined differently in the DB models. # E.g. "name" is mapped to TraceTagKey.TRACE_NAME SEARCH_KEY_TO_TAG = { "name": TraceTagKey.TRACE_NAME, "prompt": TraceTagKey.LINKED_PROMPTS, } SEARCH_KEY_TO_METADATA = { "run_id": TraceMetadataKey.SOURCE_RUN, } # Alias for attribute keys SEARCH_KEY_TO_ATTRIBUTE = { "timestamp": "timestamp_ms", "execution_time": "execution_time_ms", "end_time": "end_time_ms", } # Map trace search keys to span attributes for full text search SEARCH_KEY_TO_SPAN = { "text": _SPAN_CONTENT_KEY, } @classmethod def filter(cls, traces, filter_string): """Filters a set of traces based on a search filter string.""" if not filter_string: return traces parsed = cls.parse_search_filter_for_search_traces(filter_string) def trace_matches(trace): return all(cls._does_trace_match_clause(trace, s) for s in parsed) return list(filter(trace_matches, traces)) @classmethod def _does_trace_match_clause(cls, trace, sed): type_ = sed.get("type") key = sed.get("key") value = sed.get("value") comparator = sed.get("comparator").upper() if cls.is_tag(type_, comparator): if comparator == "IS NULL": return key not in trace.tags elif comparator == "IS NOT NULL": return key in trace.tags lhs = trace.tags.get(key) elif cls.is_request_metadata(type_, comparator): if comparator == "IS NULL": return key not in trace.request_metadata elif comparator == "IS NOT NULL": return key in trace.request_metadata lhs = trace.request_metadata.get(key) elif cls.is_attribute(type_, key, comparator): lhs = getattr(trace, key) elif cls.is_span(type_, key, comparator): raise MlflowException( "Span filtering requires database support and cannot be performed " "on in-memory trace data.", error_code=INVALID_PARAMETER_VALUE, ) elif cls.is_assessment(type_, key, comparator): raise MlflowException( "Assessment filtering requires database support and cannot be performed " "on in-memory trace data.", error_code=INVALID_PARAMETER_VALUE, ) elif sed.get("type") == cls._TAG_IDENTIFIER: lhs = trace.tags.get(key) else: raise MlflowException( f"Invalid search key '{key}', supported are {cls.VALID_SEARCH_ATTRIBUTE_KEYS}", error_code=INVALID_PARAMETER_VALUE, ) if lhs is None: return False return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def sort(cls, traces, order_by_list): return sorted(traces, key=cls._get_sort_key(order_by_list)) @classmethod def parse_order_by_for_search_traces(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) identifier = cls._get_identifier(token_value.strip(), cls.VALID_ORDER_BY_ATTRIBUTE_KEYS) identifier = cls._replace_key_to_tag_or_metadata(identifier) return identifier["type"], identifier["key"], is_ascending @classmethod def parse_search_filter_for_search_traces(cls, filter_string): parsed = cls.parse_search_filter(filter_string) return [cls._replace_key_to_tag_or_metadata(p) for p in parsed] @classmethod def _replace_key_to_tag_or_metadata(cls, parsed: dict[str, Any]): """ Replace search key to tag or metadata key if it is in the mapping. """ # Don't replace keys for span filters - they have their own namespace if parsed.get("type") == cls._SPAN_IDENTIFIER: return parsed key = parsed.get("key").lower() if key in cls.SEARCH_KEY_TO_TAG: parsed["type"] = cls._TAG_IDENTIFIER parsed["key"] = cls.SEARCH_KEY_TO_TAG[key] elif key in cls.SEARCH_KEY_TO_METADATA: parsed["type"] = cls._REQUEST_METADATA_IDENTIFIER parsed["key"] = cls.SEARCH_KEY_TO_METADATA[key] elif key in cls.SEARCH_KEY_TO_SPAN: parsed["type"] = cls._SPAN_IDENTIFIER parsed["key"] = cls.SEARCH_KEY_TO_SPAN[key] elif key in cls.SEARCH_KEY_TO_ATTRIBUTE: parsed["key"] = cls.SEARCH_KEY_TO_ATTRIBUTE[key] return parsed @classmethod def is_request_metadata(cls, key_type, comparator): if key_type == cls._REQUEST_METADATA_IDENTIFIER: if comparator not in cls.VALID_METADATA_COMPARATORS: raise MlflowException( f"Invalid comparator '{comparator}' not one of " f"'{cls.VALID_METADATA_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_span(cls, key_type, key_name, comparator): if key_type == cls._SPAN_IDENTIFIER: # Support span.attributes. format if key_name.startswith("attributes."): # Extract the actual attribute name after "attributes." attr_name = key_name[len("attributes.") :] if not attr_name: raise MlflowException( "Span attribute name cannot be empty after 'attributes.'", error_code=INVALID_PARAMETER_VALUE, ) if comparator not in cls.VALID_SPAN_ATTRIBUTE_COMPARATORS: raise MlflowException( f"span.{key_name} comparator '{comparator}' not one of " f"'{cls.VALID_SPAN_ATTRIBUTE_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) elif key_name in cls._SUPPORTED_SPAN_ATTRIBUTES: if comparator not in cls.VALID_SPAN_ATTRIBUTE_COMPARATORS: raise MlflowException( f"span.{key_name} comparator '{comparator}' not one of " f"'{cls.VALID_SPAN_ATTRIBUTE_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) elif key_name == cls._SPAN_CONTENT_KEY: if comparator not in cls.VALID_SPAN_CONTENT_COMPARATORS: raise MlflowException( f"span.{key_name} comparator '{comparator}' not one of " f"'{cls.VALID_SPAN_CONTENT_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) else: supported_attrs = ", ".join(sorted(cls._SUPPORTED_SPAN_ATTRIBUTES)) raise MlflowException( f"Invalid span attribute '{key_name}'. " f"Supported attributes: {supported_attrs}, attributes..", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_assessment(cls, key_type, key_name, comparator): if key_type in (cls._FEEDBACK_IDENTIFIER, cls._EXPECTATION_IDENTIFIER): if not key_name: raise MlflowException( "Assessment field name cannot be empty", error_code=INVALID_PARAMETER_VALUE, ) if comparator not in cls.VALID_ASSESSMENT_COMPARATORS: raise MlflowException( f"assessment.{key_name} comparator '{comparator}' not one of " f"'{cls.VALID_ASSESSMENT_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) return True return False @classmethod def is_issue(cls, key_type, key_name, comparator): if key_type == cls._ISSUE_IDENTIFIER: if key_name not in cls._SUPPORTED_ISSUE_ATTRIBUTES: supported_attrs = ", ".join(sorted(cls._SUPPORTED_ISSUE_ATTRIBUTES)) raise MlflowException( f"Invalid issue attribute '{key_name}'. " f"Supported attributes: {supported_attrs}", error_code=INVALID_PARAMETER_VALUE, ) if comparator not in cls.VALID_ISSUE_COMPARATORS: raise MlflowException( f"issue.{key_name} comparator '{comparator}' not one of " f"'{cls.VALID_ISSUE_COMPARATORS}'", error_code=INVALID_PARAMETER_VALUE, ) return True return False @staticmethod def _get_sql_json_comparison_func( comparator: str, dialect: str ) -> Callable[["ColumnElement", str], "ClauseElement"]: """ Returns a comparison function for JSON-serialized values. Assessment values are stored as JSON primitives in the database: - Boolean False -> false (no quotes in JSON) - Numeric value 5 -> 5 (no quotes in JSON) - String "yes" -> '"yes"' (WITH quotes in JSON) For equality comparisons, we match either the raw JSON primitive value (for booleans and numeric values) or the JSON-serialized value (for strings). """ import sqlalchemy as sa def mysql_json_equality_inequality_comparison( column: "ColumnElement", value: str ) -> "ClauseElement": # MySQL is case insensitive by default, so we need to use the BINARY operator # for case sensitive comparisons. We check both the raw value (for booleans/numbers) # and the JSON-serialized value (for strings). json_string_value = json.dumps(value) col_ref = f"{column.class_.__tablename__}.{column.key}" template = ( f"(({col_ref} = :value1 AND BINARY {col_ref} = :value1) OR " f"({col_ref} = :value2 AND BINARY {col_ref} = :value2))" ) if comparator == "!=": template = f"NOT {template}" return sa.text(template).bindparams( sa.bindparam("value1", value=value, unique=True), sa.bindparam("value2", value=json_string_value, unique=True), ) def json_equality_inequality_comparison( column: "ColumnElement", value: str ) -> "ClauseElement": # MSSQL uses collation for case-sensitive comparisons on String columns if dialect == MSSQL: column = column.collate(_MSSQL_CASE_SENSITIVE_COLLATION) json_string_value = json.dumps(value) clause = sa.or_(column == value, column == json_string_value) if comparator == "!=": clause = sa.not_(clause) return clause def json_numeric_comparison(column: "ColumnElement", value: str) -> "ClauseElement": # `CAST(... AS DOUBLE)` is only valid on MySQL 8.0.17+. Numeric coercion via addition # (`column + 0.0`) yields a floating comparison and renders identically across MySQL # versions, including 5.7. numeric_value = column + 0.0 if dialect == MYSQL else sa.cast(column, sa.Float) numeric_column = sa.case( ( sa.func.lower(column).in_([ "true", "false", "null", "nan", "infinity", "-infinity", ]), sa.null(), ), (sa.func.substring(column, 1, 1).in_(['"', "[", "{"]), sa.null()), else_=numeric_value, ) return SearchUtils.get_comparison_func(comparator)(numeric_column, float(value)) if comparator in SearchTraceUtils.NUMERIC_ASSESSMENT_COMPARATORS: return json_numeric_comparison elif comparator not in ("=", "!="): return SearchTraceUtils.get_sql_comparison_func(comparator, dialect) elif dialect == MYSQL: return mysql_json_equality_inequality_comparison else: return json_equality_inequality_comparison @classmethod def _valid_entity_type(cls, entity_type): entity_type = cls._trim_backticks(entity_type) if entity_type not in cls._VALID_IDENTIFIERS: raise MlflowException( f"Invalid entity type '{entity_type}'. Valid values are {cls._VALID_IDENTIFIERS}", error_code=INVALID_PARAMETER_VALUE, ) elif entity_type in cls._ALTERNATE_IDENTIFIERS: return cls._ALTERNATE_IDENTIFIERS[entity_type] else: return entity_type @classmethod def _get_sort_key(cls, order_by_list): order_by = [] parsed_order_by = map(cls.parse_order_by_for_search_traces, order_by_list or []) for type_, key, ascending in parsed_order_by: if type_ == "attribute": order_by.append((key, ascending)) else: raise MlflowException.invalid_parameter_value( f"Invalid order_by entity `{type_}` with key `{key}`" ) # Add a tie-breaker if not any(key == "timestamp_ms" for key, _ in order_by): order_by.append(("timestamp_ms", False)) if not any(key == "request_id" for key, _ in order_by): order_by.append(("request_id", True)) return lambda trace: tuple(_apply_reversor(trace, k, asc) for (k, asc) in order_by) @classmethod def _get_value(cls, identifier_type, key, token): if identifier_type == cls._TAG_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): return cls._parse_attribute_lists(token) raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._ATTRIBUTE_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): if key not in cls.SUPPORT_IN_COMPARISON_ATTRIBUTE_KEYS: raise MlflowException( f"Only attributes in {cls.SUPPORT_IN_COMPARISON_ATTRIBUTE_KEYS} " "supports comparison with a list of quoted string values.", error_code=INVALID_PARAMETER_VALUE, ) return cls._parse_attribute_lists(token) elif token.ttype in cls.NUMERIC_VALUE_TYPES: if key not in cls.NUMERIC_ATTRIBUTES: raise MlflowException( f"Only the '{cls.NUMERIC_ATTRIBUTES}' attributes support comparison with " "numeric values.", error_code=INVALID_PARAMETER_VALUE, ) if token.ttype == TokenType.Literal.Number.Integer: return int(token.value) elif token.ttype == TokenType.Literal.Number.Float: return float(token.value) else: raise MlflowException( "Expected a quoted string value or a list of quoted string values for " f"attributes. Got value {token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._REQUEST_METADATA_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) else: raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._SPAN_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif isinstance(token, Parenthesis): return cls._parse_attribute_lists(token) else: raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type in (cls._FEEDBACK_IDENTIFIER, cls._EXPECTATION_IDENTIFIER): if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) elif token.ttype in cls.NUMERIC_VALUE_TYPES: if token.ttype == TokenType.Literal.Number.Integer: return int(token.value) elif token.ttype == TokenType.Literal.Number.Float: return float(token.value) else: raise MlflowException( "Expected a quoted string value or numeric value for " f"{identifier_type} (e.g. 'my-value' or 0.8). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) elif identifier_type == cls._ISSUE_IDENTIFIER: if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) else: raise MlflowException( "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}", error_code=INVALID_PARAMETER_VALUE, ) else: # Expected to be either "param" or "metric". raise MlflowException( f"Invalid identifier type: {identifier_type}. " f"Expected one of {cls._VALID_IDENTIFIERS}.", error_code=INVALID_PARAMETER_VALUE, ) @classmethod def _parse_attribute_lists(cls, token): return cls._parse_list_from_sql_token(token) @classmethod def _process_statement(cls, statement): # check validity tokens = _join_in_comparison_tokens(statement.tokens, search_traces=True) invalids = list(filter(cls._invalid_statement_token_search_traces, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(f"'{token}'" for token in invalids) raise MlflowException( f"Invalid clause(s) in filter string: {invalid_clauses}", error_code=INVALID_PARAMETER_VALUE, ) return [cls._get_comparison(si) for si in tokens if isinstance(si, Comparison)] @classmethod def _invalid_statement_token_search_traces(cls, token): if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True @classmethod def _validate_assessment_comparison_value(cls, identifier_type, comparator, token): if identifier_type not in (cls._FEEDBACK_IDENTIFIER, cls._EXPECTATION_IDENTIFIER): return is_numeric_assessment_comparison = comparator in cls.NUMERIC_ASSESSMENT_COMPARATORS msg = ( f"Expected a numeric value for {identifier_type} when using comparator " f"'{comparator}'. Got value {token.value}" if is_numeric_assessment_comparison else "Expected a quoted string value for " f"{identifier_type} (e.g. 'my-value'). Got value " f"{token.value}" ) if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): if is_numeric_assessment_comparison: raise MlflowException(msg, error_code=INVALID_PARAMETER_VALUE) elif token.ttype in cls.NUMERIC_VALUE_TYPES: if not is_numeric_assessment_comparison: raise MlflowException(msg, error_code=INVALID_PARAMETER_VALUE) else: raise MlflowException(msg, error_code=INVALID_PARAMETER_VALUE) @classmethod def _validate_comparison(cls, tokens): # Allow 2-token IS NULL / IS NOT NULL comparisons if len(tokens) == 2: comparator = tokens[1].value.upper() if comparator in ("IS NULL", "IS NOT NULL"): if not isinstance(tokens[0], Identifier): raise MlflowException( f"Invalid comparison clause. Expected 'Identifier' found '{tokens[0]}'", error_code=INVALID_PARAMETER_VALUE, ) return # Allow timestamp/timestamp_ms as the first token for trace search if ( len(tokens) == 3 and not isinstance(tokens[0], Identifier) and tokens[0].match(ttype=TokenType.Name.Builtin, values=["timestamp", "timestamp_ms"]) ): return super()._validate_comparison(tokens) @classmethod def _get_comparison(cls, comparison): stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] cls._validate_comparison(stripped_comparison) # Handle IS NULL / IS NOT NULL (2 tokens: identifier + comparator, no value) if len(stripped_comparison) == 2: comparator = stripped_comparison[1].value.upper() comp = cls._get_identifier( stripped_comparison[0].value, cls.VALID_SEARCH_ATTRIBUTE_KEYS ) comp["comparator"] = comparator comp["value"] = None return comp comp = cls._get_identifier(stripped_comparison[0].value, cls.VALID_SEARCH_ATTRIBUTE_KEYS) comp["comparator"] = stripped_comparison[1].value cls._validate_assessment_comparison_value( comp.get("type"), comp.get("comparator"), stripped_comparison[2] ) comp["value"] = cls._get_value(comp.get("type"), comp.get("key"), stripped_comparison[2]) if comp.get("type") == cls._SPAN_IDENTIFIER: cls.is_span(comp["type"], comp["key"], comp["comparator"]) return comp @dataclass class TraceMetricsFilter: view_type: str entity: str key: str | None comparator: str value: Any class SearchTraceMetricsUtils(SearchTraceUtils): _VALID_VIEW_TYPES_TO_ENTITIES = { TraceMetricSearchKey.VIEW_TYPE: TraceMetricSearchKey.entity_to_key_requirement(), SpanMetricSearchKey.VIEW_TYPE: SpanMetricSearchKey.entity_to_key_requirement(), AssessmentMetricSearchKey.VIEW_TYPE: AssessmentMetricSearchKey.entity_to_key_requirement(), } @classmethod def parse_search_filter(cls, filter_string: str) -> TraceMetricsFilter: parsed = super().parse_search_filter(filter_string) if len(parsed) != 1: raise MlflowException.invalid_parameter_value( f"Invalid filter: '{filter_string}'. Expected one filter clause." ) return parsed[0] @classmethod def _process_statement(cls, statement: Statement) -> list[TraceMetricsFilter]: tokens = statement.tokens invalids = list(filter(cls._invalid_statement_token, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(map(str, invalids)) raise MlflowException.invalid_parameter_value( f"Invalid clause(s) in filter string: {invalid_clauses}" ) return [cls._get_comparison(t) for t in tokens if isinstance(t, Comparison)] @classmethod def _invalid_statement_token(cls, token: Token) -> bool: if isinstance(token, Comparison) or token.is_whitespace: return False return True @classmethod def _get_identifier(cls, identifier) -> dict[str, Any]: error_message = ( f"Invalid identifier {identifier!r}. Columns should be specified as " f"'trace.', 'span.', 'assessment.'." ) try: tokens = identifier.split(".", 2) match tokens: case [view_type, entity]: return cls._validate_metrics_fields(view_type, entity) case [view_type, entity, key]: return cls._validate_metrics_fields(view_type, entity, key) case _: raise MlflowException.invalid_parameter_value(error_message) except ValueError: raise MlflowException.invalid_parameter_value(error_message) @classmethod def _validate_metrics_fields(cls, view_type, entity, key=None) -> dict[str, Any]: view_type = cls._trim_backticks(view_type) if view_type not in cls._VALID_VIEW_TYPES_TO_ENTITIES: raise MlflowException.invalid_parameter_value( f"Invalid view type '{view_type}'. " f"Valid values are {cls._VALID_VIEW_TYPES_TO_ENTITIES.keys()}" ) valid_entities = cls._VALID_VIEW_TYPES_TO_ENTITIES[view_type] if entity not in valid_entities: raise MlflowException.invalid_parameter_value( f"Invalid entity '{entity}' specified for view type '{view_type}'. " f"Valid entities are {list(valid_entities.keys())}" ) key_is_required = valid_entities[entity] if key_is_required and key is None: raise MlflowException.invalid_parameter_value( f"Filtering by {entity} requires a key, e.g. '{view_type}.{entity}. = '" ) elif not key_is_required and key is not None: raise MlflowException.invalid_parameter_value( f"Filtering by {entity} does not require a key, use '{view_type}.{entity}' instead" ) key = cls._trim_backticks(cls._strip_quotes(key)) if key else None return {"view_type": view_type, "entity": entity, "key": key} @classmethod def _get_value(cls, entity, key, token): if token.ttype in cls.STRING_VALUE_TYPES or isinstance(token, Identifier): return cls._strip_quotes(token.value, expect_quoted_value=True) else: raise MlflowException.invalid_parameter_value( f"Expected a quoted string value for {entity} value (e.g. 'my-value'). " f"Got value {token.value}", ) @classmethod def _get_comparison(cls, comparison: Comparison) -> TraceMetricsFilter: stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] cls._validate_comparison(stripped_comparison) comp = cls._get_identifier(stripped_comparison[0].value) comparator = stripped_comparison[1].value if comparator != "=": raise MlflowException.invalid_parameter_value( f"Invalid comparator: '{comparator}', only '=' operator is supported" ) value = cls._get_value(comp["entity"], comp["key"], stripped_comparison[2]) return TraceMetricsFilter( view_type=comp["view_type"], entity=comp["entity"], key=comp["key"], comparator=comparator, value=value, ) class SearchEvaluationDatasetsUtils(SearchUtils): """ Utility class for searching evaluation datasets. """ VALID_SEARCH_ATTRIBUTE_KEYS = { "name", "created_time", "last_update_time", "created_by", "last_updated_by", } VALID_ORDER_BY_ATTRIBUTE_KEYS = {"name", "created_time", "last_update_time"} NUMERIC_ATTRIBUTES = {"created_time", "last_update_time"} VALID_TAG_COMPARATORS = {"!=", "=", "LIKE", "ILIKE"} @classmethod def _invalid_statement_token(cls, token): if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True @classmethod def _process_statement(cls, statement): tokens = _join_in_comparison_tokens(statement.tokens) invalids = list(filter(cls._invalid_statement_token, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(map(str, invalids)) raise MlflowException.invalid_parameter_value( f"Invalid clause(s) in filter string: {invalid_clauses}" ) return [cls._get_comparison(t) for t in tokens if isinstance(t, Comparison)] @classmethod def _get_identifier(cls, identifier, valid_attributes): tokens = identifier.split(".", maxsplit=1) if len(tokens) == 1: key = tokens[0] if key not in valid_attributes: raise MlflowException.invalid_parameter_value( f"Invalid attribute key '{key}' specified. Valid keys are: {valid_attributes}" ) return {"type": "attribute", "key": key} else: if tokens[0] == "tags": key = tokens[1] return {"type": "tag", "key": key} else: raise MlflowException.invalid_parameter_value( f"Invalid identifier token '{tokens[0]}' specified" ) @classmethod def parse_order_by_for_search_evaluation_datasets(cls, order_by): token_value, is_ascending = cls._parse_order_by_string(order_by) identifier = cls._get_identifier(token_value.strip(), cls.VALID_ORDER_BY_ATTRIBUTE_KEYS) return identifier["type"], identifier["key"], is_ascending @classmethod def is_string_attribute(cls, type_, key, comparator): return ( type_ == "attribute" and key not in cls.NUMERIC_ATTRIBUTES and comparator in cls.VALID_STRING_ATTRIBUTE_COMPARATORS ) @classmethod def is_numeric_attribute(cls, type_, key, comparator): return ( type_ == "attribute" and key in cls.NUMERIC_ATTRIBUTES and comparator in cls.VALID_NUMERIC_ATTRIBUTE_COMPARATORS ) class SearchLoggedModelsUtils(SearchUtils): NUMERIC_ATTRIBUTES = { "creation_timestamp", "creation_time", "last_updated_timestamp", "last_updated_time", } VALID_SEARCH_ATTRIBUTE_KEYS = { "name", "model_id", "model_type", "status", "source_run_id", } | NUMERIC_ATTRIBUTES VALID_TAG_COMPARATORS = {"!=", "=", "LIKE", "ILIKE"} VALID_PARAM_COMPARATORS = {"!=", "=", "LIKE", "ILIKE"} VALID_ORDER_BY_ATTRIBUTE_KEYS = VALID_SEARCH_ATTRIBUTE_KEYS @classmethod def _does_logged_model_match_clause( cls, model: LoggedModel, condition: dict[str, Any], datasets: list[dict[str, Any]] | None = None, ): key_type = condition.get("type") key = condition.get("key") value = condition.get("value") comparator = condition.get("comparator").upper() key = SearchUtils.translate_key_alias(key) if cls.is_metric(key_type, comparator): matching_metrics = [metric for metric in model.metrics if metric.key == key] if datasets: matching_metrics = [ metric for metric in matching_metrics if any(cls._is_metric_on_dataset(metric, dataset) for dataset in datasets) ] lhs = matching_metrics[0].value if matching_metrics else None value = float(value) elif cls.is_param(key_type, comparator): lhs = model.params.get(key, None) elif cls.is_tag(key_type, comparator): lhs = model.tags.get(key, None) elif cls.is_numeric_attribute(key_type, key, comparator): lhs = getattr(model, key) value = int(value) elif hasattr(model, key): lhs = getattr(model, key) else: raise MlflowException.invalid_parameter_value( f"Invalid logged model search key '{key}'", ) if lhs is None: return False return SearchUtils.get_comparison_func(comparator)(lhs, value) @classmethod def validate_list_supported(cls, key: str) -> None: """ Override to allow logged model attributes to be used with IN/NOT IN. """ @classmethod def filter_logged_models( cls, models: list[LoggedModel], filter_string: str | None = None, datasets: list[dict[str, Any]] | None = None, ): """Filters a set of runs based on a search filter string and list of dataset filters.""" if not filter_string and not datasets: return models parsed = cls.parse_search_filter(filter_string) # If there are dataset filters but no metric filters in the filter string, # filter for models that have any metrics on the datasets if datasets and not any( cls.is_metric(s.get("type"), s.get("comparator").upper()) for s in parsed ): def model_has_metrics_on_datasets(model): return any( any(cls._is_metric_on_dataset(metric, dataset) for dataset in datasets) for metric in model.metrics ) models = [model for model in models if model_has_metrics_on_datasets(model)] def model_matches(model): return all(cls._does_logged_model_match_clause(model, s, datasets) for s in parsed) return [model for model in models if model_matches(model)] @dataclass class OrderBy: field_name: str ascending: bool = True dataset_name: str | None = None dataset_digest: str | None = None @classmethod def parse_order_by_for_logged_models(cls, order_by: dict[str, Any]) -> OrderBy: if not isinstance(order_by, dict): raise MlflowException.invalid_parameter_value( "`order_by` must be a list of dictionaries." ) field_name = order_by.get("field_name") if field_name is None: raise MlflowException.invalid_parameter_value( "`field_name` in the `order_by` clause must be specified." ) if "." in field_name: entity = field_name.split(".", 1)[0] if entity != "metrics": raise MlflowException.invalid_parameter_value( f"Invalid order by field name: {entity}, only `metrics.` is allowed." ) else: field_name = field_name.strip() if field_name not in cls.VALID_ORDER_BY_ATTRIBUTE_KEYS: raise MlflowException.invalid_parameter_value( f"Invalid order by field name: {field_name}." ) ascending = order_by.get("ascending", True) if ascending not in [True, False]: raise MlflowException.invalid_parameter_value( "Value of `ascending` in the `order_by` clause must be a boolean, got " f"{type(ascending)} for field {field_name}." ) dataset_name = order_by.get("dataset_name") dataset_digest = order_by.get("dataset_digest") if dataset_digest and not dataset_name: raise MlflowException.invalid_parameter_value( "`dataset_digest` can only be specified if `dataset_name` is also specified." ) aliases = { "creation_time": "creation_timestamp", } return cls.OrderBy( aliases.get(field_name, field_name), ascending, dataset_name, dataset_digest ) @classmethod def _apply_reversor_for_logged_model( cls, model: LoggedModel, order_by: OrderBy, ): if "." in order_by.field_name: metric_key = order_by.field_name.split(".", 1)[1] filtered_metrics = sorted( [ m for m in model.metrics if m.key == metric_key and (not order_by.dataset_name or m.dataset_name == order_by.dataset_name) and (not order_by.dataset_digest or m.dataset_digest == order_by.dataset_digest) ], key=lambda metric: metric.timestamp, reverse=True, ) latest_metric_value = None if len(filtered_metrics) == 0 else filtered_metrics[0].value return ( _LoggedModelMetricComp(latest_metric_value) if order_by.ascending else _Reversor(latest_metric_value) ) else: value = getattr(model, order_by.field_name) return value if order_by.ascending else _Reversor(value) @classmethod def _get_sort_key(cls, order_by_list: list[dict[str, Any]] | None): parsed_order_by = list(map(cls.parse_order_by_for_logged_models, order_by_list or [])) # Add a tie-breaker if not any(order_by.field_name == "creation_timestamp" for order_by in parsed_order_by): parsed_order_by.append(cls.OrderBy("creation_timestamp", False)) if not any(order_by.field_name == "model_id" for order_by in parsed_order_by): parsed_order_by.append(cls.OrderBy("model_id")) return lambda logged_model: tuple( cls._apply_reversor_for_logged_model(logged_model, order_by) for order_by in parsed_order_by ) @classmethod def sort(cls, models, order_by_list): return sorted(models, key=cls._get_sort_key(order_by_list)) class _LoggedModelMetricComp: def __init__(self, obj): self.obj = obj def __eq__(self, other): return other.obj == self.obj def __lt__(self, other): if self.obj is None: return False if other.obj is None: return True return self.obj < other.obj @dataclass class SearchLoggedModelsPaginationToken: experiment_ids: list[str] filter_string: str | None = None order_by: list[dict[str, Any]] | None = None offset: int = 0 def to_json(self) -> str: return json.dumps(asdict(self)) def encode(self) -> str: return base64.b64encode(self.to_json().encode("utf-8")).decode("utf-8") @classmethod def decode(cls, token: str) -> "SearchLoggedModelsPaginationToken": try: token = json.loads(base64.b64decode(token.encode("utf-8")).decode("utf-8")) except json.JSONDecodeError as e: raise MlflowException.invalid_parameter_value(f"Invalid page token: {token}. {e}") return cls( experiment_ids=token.get("experiment_ids"), filter_string=token.get("filter_string") or None, order_by=token.get("order_by") or None, offset=token.get("offset") or 0, ) def validate( self, experiment_ids: list[str], filter_string: str | None, order_by: list[dict[str, Any]] | None, ) -> None: if self.experiment_ids != experiment_ids: raise MlflowException.invalid_parameter_value( f"Experiment IDs in the page token do not match the requested experiment IDs. " f"Expected: {experiment_ids}. Found: {self.experiment_ids}" ) if self.filter_string != filter_string: raise MlflowException.invalid_parameter_value( f"Filter string in the page token does not match the requested filter string. " f"Expected: {filter_string}. Found: {self.filter_string}" ) if self.order_by != order_by: raise MlflowException.invalid_parameter_value( f"Order by in the page token does not match the requested order by. " f"Expected: {order_by}. Found: {self.order_by}" ) class SearchIssuesUtils(SearchUtils): """Utility class for parsing issue search filters.""" VALID_SEARCH_ATTRIBUTE_KEYS = {"status", "source_run_id"} VALID_STRING_ATTRIBUTE_COMPARATORS = {"=", "!="} @classmethod def _invalid_statement_token(cls, token): """Check if a token is invalid for issue search filters.""" if ( isinstance(token, Comparison) or token.is_whitespace or token.match(ttype=TokenType.Keyword, values=["AND"]) ): return False return True @classmethod def _process_statement(cls, statement): """Process SQL statement and extract comparisons.""" tokens = _join_in_comparison_tokens(statement.tokens) invalids = list(filter(cls._invalid_statement_token, tokens)) if len(invalids) > 0: invalid_clauses = ", ".join(map(str, invalids)) raise MlflowException.invalid_parameter_value( f"Invalid clause(s) in filter string: {invalid_clauses}" ) return [cls._get_comparison(t) for t in tokens if isinstance(t, Comparison)] @classmethod def _get_comparison(cls, comparison): """Extract comparison details from a Comparison token.""" stripped_comparison = [token for token in comparison.tokens if not token.is_whitespace] if len(stripped_comparison) != 3: raise MlflowException.invalid_parameter_value( f"Invalid comparison: expected 3 tokens, got {len(stripped_comparison)}" ) left, comparator_token, right = stripped_comparison # Get field name if not isinstance(left, Identifier): raise MlflowException.invalid_parameter_value( f"Invalid comparison: left side must be an identifier, got {type(left)}" ) key = cls._strip_quotes(left.value).strip() if key not in cls.VALID_SEARCH_ATTRIBUTE_KEYS: raise MlflowException.invalid_parameter_value( f"Invalid filter field '{key}'. Supported fields: {cls.VALID_SEARCH_ATTRIBUTE_KEYS}" ) # Get comparator comparator = comparator_token.value.upper() if comparator not in cls.VALID_STRING_ATTRIBUTE_COMPARATORS: raise MlflowException.invalid_parameter_value( f"Invalid comparator '{comparator}'. " f"Supported comparators: {cls.VALID_STRING_ATTRIBUTE_COMPARATORS}" ) # Get value value = cls._strip_quotes(right.value).strip() return { "type": cls._ATTRIBUTE_IDENTIFIER, "key": key, "comparator": comparator, "value": value, }