Files
2026-07-13 13:17:40 +08:00

267 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dataclasses
from dataclasses import asdict, fields
from typing import Awaitable, Callable, List, Tuple
import aiohttp.web
from ray.dashboard.optional_utils import rest_response
from ray.dashboard.utils import HTTPStatusCode
from ray.util.state.common import (
DEFAULT_LIMIT,
DEFAULT_RPC_TIMEOUT,
RAY_MAX_LIMIT_FROM_API_SERVER,
ListApiOptions,
ListApiResponse,
PredicateType,
StateSchema,
SummaryApiOptions,
SummaryApiResponse,
SupportedFilterType,
filter_fields,
)
from ray.util.state.exception import DataSourceUnavailable
from ray.util.state.util import convert_string_to_type
def do_reply(
status_code: HTTPStatusCode, error_message: str, result: ListApiResponse, **kwargs
):
return rest_response(
status_code=status_code,
message=error_message,
result=result,
convert_google_style=False,
**kwargs,
)
async def handle_list_api(
list_api_fn: Callable[[ListApiOptions], Awaitable[ListApiResponse]],
req: aiohttp.web.Request,
):
try:
result = await list_api_fn(option=options_from_req(req))
return do_reply(
status_code=HTTPStatusCode.OK,
error_message="",
result=asdict(result),
)
except ValueError as e:
return do_reply(
status_code=HTTPStatusCode.BAD_REQUEST,
error_message=str(e),
result=None,
)
except DataSourceUnavailable as e:
return do_reply(
status_code=HTTPStatusCode.INTERNAL_ERROR,
error_message=str(e),
result=None,
)
def _get_filters_from_req(
req: aiohttp.web.Request,
) -> List[Tuple[str, PredicateType, SupportedFilterType]]:
filter_keys = req.query.getall("filter_keys", [])
filter_predicates = req.query.getall("filter_predicates", [])
filter_values = req.query.getall("filter_values", [])
assert len(filter_keys) == len(filter_values)
filters = []
for key, predicate, val in zip(filter_keys, filter_predicates, filter_values):
filters.append((key, predicate, val))
return filters
def options_from_req(req: aiohttp.web.Request) -> ListApiOptions:
"""Obtain `ListApiOptions` from the aiohttp request."""
limit = int(
req.query.get("limit") if req.query.get("limit") is not None else DEFAULT_LIMIT
)
if limit > RAY_MAX_LIMIT_FROM_API_SERVER:
raise ValueError(
f"Given limit {limit} exceeds the supported "
f"limit {RAY_MAX_LIMIT_FROM_API_SERVER}. Use a lower limit, or set the "
f"`RAY_MAX_LIMIT_FROM_API_SERVER` environment variable to a larger value."
)
timeout = int(req.query.get("timeout", 30))
filters = _get_filters_from_req(req)
detail = convert_string_to_type(req.query.get("detail", False), bool)
exclude_driver = convert_string_to_type(req.query.get("exclude_driver", True), bool)
return ListApiOptions(
limit=limit,
timeout=timeout,
filters=filters,
detail=detail,
exclude_driver=exclude_driver,
)
def summary_options_from_req(req: aiohttp.web.Request) -> SummaryApiOptions:
timeout = int(req.query.get("timeout", DEFAULT_RPC_TIMEOUT))
filters = _get_filters_from_req(req)
summary_by = req.query.get("summary_by", None)
return SummaryApiOptions(timeout=timeout, filters=filters, summary_by=summary_by)
async def handle_summary_api(
summary_fn: Callable[[SummaryApiOptions], SummaryApiResponse],
req: aiohttp.web.Request,
):
result = await summary_fn(option=summary_options_from_req(req))
return do_reply(
status_code=HTTPStatusCode.OK,
error_message="",
result=asdict(result),
)
def convert_filters_type(
filter: List[Tuple[str, PredicateType, SupportedFilterType]],
schema: StateSchema,
) -> List[Tuple[str, PredicateType, SupportedFilterType]]:
"""Convert the given filter's type to SupportedFilterType.
This method is necessary because click can only accept a single type
for its tuple (which is string in this case).
Args:
filter: A list of filter which is a tuple of (key, val).
schema: The state schema. It is used to infer the type of the column for filter.
Returns:
A new list of filters with correct types that match the schema.
"""
new_filter = []
if dataclasses.is_dataclass(schema):
schema = {field.name: field.type for field in fields(schema)}
else:
schema = schema.schema_dict()
for col, predicate, val in filter:
if col in schema:
column_type = schema[col]
try:
isinstance(val, column_type)
except TypeError:
# Calling `isinstance` to the Literal type raises a TypeError.
# Ignore this case.
pass
else:
if isinstance(val, column_type):
# Do nothing.
pass
elif column_type is int or column_type == "integer":
try:
val = convert_string_to_type(val, int)
except ValueError:
raise ValueError(
f"Invalid filter `--filter {col} {val}` for a int type "
"column. Please provide an integer filter "
f"`--filter {col} [int]`"
)
elif column_type is float or column_type == "number":
try:
val = convert_string_to_type(
val,
float,
)
except ValueError:
raise ValueError(
f"Invalid filter `--filter {col} {val}` for a float "
"type column. Please provide an integer filter "
f"`--filter {col} [float]`"
)
elif column_type is bool or column_type == "boolean":
try:
val = convert_string_to_type(val, bool)
except ValueError:
raise ValueError(
f"Invalid filter `--filter {col} {val}` for a boolean "
"type column. Please provide "
f"`--filter {col} [True|true|1]` for True or "
f"`--filter {col} [False|false|0]` for False."
)
new_filter.append((col, predicate, val))
return new_filter
def do_filter(
data: List[dict],
filters: List[Tuple[str, PredicateType, SupportedFilterType]],
state_dataclass: StateSchema,
detail: bool,
) -> List[dict]:
"""Return the filtered data given filters.
Args:
data: A list of state data.
filters: A list of KV tuple to filter data (key, val). The data is filtered
if data[key] != val.
state_dataclass: The state schema.
detail: If True, include detail-only columns; otherwise drop them.
Returns:
A list of filtered state data in dictionary. Each state data's
unnecessary columns are filtered by the given state_dataclass schema.
"""
filters = convert_filters_type(filters, state_dataclass)
result = []
for datum in data:
match = True
for filter_column, filter_predicate, filter_value in filters:
filterable_columns = state_dataclass.filterable_columns()
filter_column = filter_column.lower()
if filter_column not in filterable_columns:
raise ValueError(
f"The given filter column {filter_column} is not supported. "
"Enter filters with -filter key=value "
"or -filter key!=value "
f"Supported filter columns: {filterable_columns}"
)
if filter_column not in datum:
match = False
elif filter_predicate == "=":
if isinstance(filter_value, str) and isinstance(
datum[filter_column], str
):
# Case insensitive match for string filter values.
match = datum[filter_column].lower() == filter_value.lower()
elif isinstance(filter_value, str) and isinstance(
datum[filter_column], bool
):
match = datum[filter_column] == convert_string_to_type(
filter_value, bool
)
elif isinstance(filter_value, str) and isinstance(
datum[filter_column], int
):
match = datum[filter_column] == convert_string_to_type(
filter_value, int
)
else:
match = datum[filter_column] == filter_value
elif filter_predicate == "!=":
if isinstance(filter_value, str) and isinstance(
datum[filter_column], str
):
match = datum[filter_column].lower() != filter_value.lower()
else:
match = datum[filter_column] != filter_value
else:
raise ValueError(
f"Unsupported filter predicate {filter_predicate} is given. "
"Available predicates: =, !=."
)
if not match:
break
if match:
result.append(filter_fields(datum, state_dataclass, detail))
return result