chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Common Utilities Shared Across the Libraries
This directory contains logic shared across Ray Core and the native libraries.
- All dependencies by the libraries on non-public APIs in the repo should live here. Libraries should _not_ depend on `ray._private`.
- Interfaces exposed in this directory should be treated similarly to a "developer API."
- End users and external libraries not inside the Ray repo should not depend on any code in `ray._common` (the same as `ray._private`).
View File
+7
View File
@@ -0,0 +1,7 @@
# Prefix for the node id resource that is automatically added to each node.
# For example, a node may have id `node:172.23.42.1`.
NODE_ID_PREFIX = "node:"
# The system resource that head node has.
HEAD_NODE_RESOURCE_NAME = NODE_ID_PREFIX + "__internal_head__"
RAY_WARN_BLOCKING_GET_INSIDE_ASYNC_ENV_VAR = "RAY_WARN_BLOCKING_GET_INSIDE_ASYNC"
+181
View File
@@ -0,0 +1,181 @@
import inspect
import logging
from functools import wraps
from typing import Any, Callable, Optional, TypeVar, Union, cast, overload
from ray.util import log_once
from ray.util.annotations import _mark_annotated
# TypeVar for preserving function/class signatures through decorators.
# Note: These decorators also accept properties, but we use Callable for the
# common case. Properties work at runtime but won't get full type inference.
F = TypeVar("F", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
# A constant to use for any configuration that should be deprecated
# (to check, whether this config has actually been assigned a proper value or
# not).
DEPRECATED_VALUE = -1
def deprecation_warning(
old: str,
new: Optional[str] = None,
*,
help: Optional[str] = None,
error: Optional[Union[bool, type[Exception]]] = None,
stacklevel: int = 2,
) -> None:
"""Warns (via the `logger` object) or throws a deprecation warning/error.
Args:
old: A description of the "thing" that is to be deprecated.
new: A description of the new "thing" that replaces it.
help: An optional help text to tell the user, what to
do instead of using `old`.
error: Whether or which exception to raise. If True, raise ValueError.
If False, just warn. If `error` is-a subclass of Exception,
raise that Exception.
stacklevel: The stacklevel to use for the warning message.
Use 2 to point to where this function is called, 3+ to point
further up the stack.
Raises:
ValueError: If `error=True`.
Exception: Of type `error`, iff `error` is a sub-class of `Exception`.
"""
msg = "`{}` has been deprecated.{}".format(
old, (" Use `{}` instead.".format(new) if new else f" {help}" if help else "")
)
if error:
if not isinstance(error, bool) and issubclass(error, Exception):
# error is an Exception
raise error(msg)
else:
# error is a boolean, construct ValueError ourselves
raise ValueError(msg)
else:
logger.warning(
"DeprecationWarning: " + msg + " This will raise an error in the future!",
stacklevel=stacklevel,
)
@overload
def Deprecated(
old: None = None,
*,
new: Optional[str] = None,
help: Optional[str] = None,
error: Union[bool, type[Exception]],
) -> Callable[[F], F]:
...
@overload
def Deprecated(
old: str,
*,
new: Optional[str] = None,
help: Optional[str] = None,
error: Union[bool, type[Exception]],
) -> Callable[[F], F]:
...
def Deprecated(
old: Optional[str] = None,
*,
new: Optional[str] = None,
help: Optional[str] = None,
error: Union[bool, type[Exception]],
) -> Callable[[F], F]:
"""Decorator for documenting a deprecated class, method, or function.
Automatically adds a `deprecation.deprecation_warning(old=...,
error=False)` to not break existing code at this point to the decorated
class' constructor, method, or function.
In a next major release, this warning should then be made an error
(by setting error=True), which means at this point that the
class/method/function is no longer supported, but will still inform
the user about the deprecation event.
In a further major release, the class, method, function should be erased
entirely from the codebase.
.. testcode::
:skipif: True
from ray._common.deprecation import Deprecated
# Deprecated class: Patches the constructor to warn if the class is
# used.
@Deprecated(new="NewAndMuchCoolerClass", error=False)
class OldAndUncoolClass:
...
# Deprecated class method: Patches the method to warn if called.
class StillCoolClass:
...
@Deprecated(new="StillCoolClass.new_and_much_cooler_method()",
error=False)
def old_and_uncool_method(self, uncool_arg):
...
# Deprecated function: Patches the function to warn if called.
@Deprecated(new="new_and_much_cooler_function", error=False)
def old_and_uncool_function(*uncool_args):
...
"""
def _inner(obj: F) -> F:
# A deprecated class.
if inspect.isclass(obj):
# Patch the class' init method to raise the warning/error.
obj_init = obj.__init__
def patched_init(*args, **kwargs):
if log_once(old or obj.__name__):
deprecation_warning(
old=old or obj.__name__,
new=new,
help=help,
error=error,
stacklevel=3,
)
return obj_init(*args, **kwargs)
obj.__init__ = patched_init
_mark_annotated(obj)
# Return the patched class (with the warning/error when
# instantiated).
return obj
# A deprecated class method or function.
# Patch with the warning/error at the beginning.
def _ctor(*args, **kwargs):
if log_once(old or obj.__name__):
deprecation_warning(
old=old or obj.__name__,
new=new,
help=help,
error=error,
stacklevel=3,
)
# Call the deprecated method/function.
return obj(*args, **kwargs)
# Only apply @wraps for actual callables, not properties/descriptors.
# Setting __wrapped__ on a property causes inspect.unwrap() to return
# the property, which breaks inspect.signature() in the tracing helper.
if callable(obj):
_ctor = wraps(obj)(_ctor)
# Return the patched class method/function.
return cast(F, _ctor)
# Return the prepared decorator.
return _inner
+55
View File
@@ -0,0 +1,55 @@
import logging
from typing import Any, Dict
import ray
from ray._common.logging_constants import LogKey
class CoreContextFilter(logging.Filter):
TASK_LEVEL_LOG_KEYS = [
LogKey.TASK_ID.value,
LogKey.TASK_NAME.value,
LogKey.TASK_FUNCTION_NAME.value,
]
@classmethod
def get_ray_core_logging_context(cls) -> Dict[str, Any]:
"""
Get the ray core logging context as a dict.
Only use this function if you need include the attributes to the log record
yourself by bypassing the filter.
"""
if not ray.is_initialized():
# There is no additional context if ray is not initialized
return {}
runtime_context = ray.get_runtime_context()
ray_core_logging_context = {
LogKey.JOB_ID.value: runtime_context.get_job_id(),
LogKey.WORKER_ID.value: runtime_context.get_worker_id(),
LogKey.NODE_ID.value: runtime_context.get_node_id(),
}
if runtime_context.worker.mode == ray.WORKER_MODE:
ray_core_logging_context[
LogKey.ACTOR_ID.value
] = runtime_context.get_actor_id()
ray_core_logging_context[
LogKey.TASK_ID.value
] = runtime_context.get_task_id()
ray_core_logging_context[
LogKey.TASK_NAME.value
] = runtime_context.get_task_name()
ray_core_logging_context[
LogKey.TASK_FUNCTION_NAME.value
] = runtime_context.get_task_function_name()
ray_core_logging_context[
LogKey.ACTOR_NAME.value
] = runtime_context.get_actor_name()
return ray_core_logging_context
def filter(self, record):
context = self.get_ray_core_logging_context()
for key, value in context.items():
if value is not None and value != "":
setattr(record, key, value)
return True
+120
View File
@@ -0,0 +1,120 @@
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List
from ray._common.logging_constants import (
LOGGER_FLATTEN_KEYS,
LOGRECORD_STANDARD_ATTRS,
LogKey,
)
from ray._private.log import INTERNAL_TIMESTAMP_LOG_KEY
from ray._private.ray_constants import LOGGER_FORMAT
def _append_flatten_attributes(formatted_attrs: Dict[str, Any], key: str, value: Any):
"""Flatten the dictionary values for special keys and append the values in place.
If the key is in `LOGGER_FLATTEN_KEYS`, the value will be flattened and appended
to the `formatted_attrs` dictionary. Otherwise, the key-value pair will be appended
directly.
"""
if key in LOGGER_FLATTEN_KEYS:
if not isinstance(value, dict):
raise ValueError(
f"Expected a dictionary passing into {key}, but got {type(value)}"
)
for k, v in value.items():
if k in formatted_attrs:
raise KeyError(f"Found duplicated key in the log record: {k}")
formatted_attrs[k] = v
else:
formatted_attrs[key] = value
class AbstractFormatter(logging.Formatter, ABC):
def __init__(self, fmt=None, datefmt=None, style="%", validate=True) -> None:
super().__init__(fmt, datefmt, style, validate)
self._additional_log_standard_attrs = []
def set_additional_log_standard_attrs(
self, additional_log_standard_attrs: List[str]
) -> None:
self._additional_log_standard_attrs = additional_log_standard_attrs
@property
def additional_log_standard_attrs(self) -> List[str]:
return self._additional_log_standard_attrs
def generate_record_format_attrs(
self,
record: logging.LogRecord,
exclude_default_standard_attrs,
) -> dict:
record_format_attrs = {}
# If `exclude_default_standard_attrs` is False, include the standard attributes.
# Otherwise, include only Ray and user-provided context.
if not exclude_default_standard_attrs:
record_format_attrs.update(
{
LogKey.ASCTIME.value: self.formatTime(record),
LogKey.LEVELNAME.value: record.levelname,
LogKey.MESSAGE.value: record.getMessage(),
LogKey.FILENAME.value: record.filename,
LogKey.LINENO.value: record.lineno,
LogKey.PROCESS.value: record.process,
}
)
if record.exc_info:
if not record.exc_text:
record.exc_text = self.formatException(record.exc_info)
record_format_attrs[LogKey.EXC_TEXT.value] = record.exc_text
# Add the user specified additional standard attributes.
for key in self._additional_log_standard_attrs:
_append_flatten_attributes(
record_format_attrs, key, getattr(record, key, None)
)
for key, value in record.__dict__.items():
# Both Ray and user-provided context are stored in `record_format`.
if key not in LOGRECORD_STANDARD_ATTRS:
_append_flatten_attributes(record_format_attrs, key, value)
# Format the internal timestamp to the standardized `timestamp_ns` key.
if INTERNAL_TIMESTAMP_LOG_KEY in record_format_attrs:
record_format_attrs[LogKey.TIMESTAMP_NS.value] = record_format_attrs.pop(
INTERNAL_TIMESTAMP_LOG_KEY
)
return record_format_attrs
@abstractmethod
def format(self, record: logging.LogRecord) -> str:
pass
class JSONFormatter(AbstractFormatter):
def format(self, record: logging.LogRecord) -> str:
record_format_attrs = self.generate_record_format_attrs(
record, exclude_default_standard_attrs=False
)
return json.dumps(record_format_attrs)
class TextFormatter(AbstractFormatter):
def __init__(self, fmt=None, datefmt=None, style="%", validate=True) -> None:
super().__init__(fmt, datefmt, style, validate)
self._inner_formatter = logging.Formatter(LOGGER_FORMAT)
def format(self, record: logging.LogRecord) -> str:
s = self._inner_formatter.format(record)
record_format_attrs = self.generate_record_format_attrs(
record, exclude_default_standard_attrs=True
)
additional_attrs = " ".join(
[f"{key}={value}" for key, value in record_format_attrs.items()]
)
return f"{s} {additional_attrs}"
+63
View File
@@ -0,0 +1,63 @@
"""Logging-related constants shared across Ray libraries.
Used to distinguish standard Python logging attributes from Ray or user context.
See: https://docs.python.org/3/library/logging.html#logrecord-attributes
"""
from enum import Enum
LOGRECORD_STANDARD_ATTRS = frozenset(
{
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"message",
"module",
"msecs",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
"taskName",
}
)
LOGGER_FLATTEN_KEYS = {
"ray_serve_extra_fields",
}
class LogKey(str, Enum):
# Core context
JOB_ID = "job_id"
WORKER_ID = "worker_id"
NODE_ID = "node_id"
ACTOR_ID = "actor_id"
TASK_ID = "task_id"
ACTOR_NAME = "actor_name"
TASK_NAME = "task_name"
TASK_FUNCTION_NAME = "task_func_name"
# Logger built-in context
ASCTIME = "asctime"
LEVELNAME = "levelname"
MESSAGE = "message"
FILENAME = "filename"
LINENO = "lineno"
EXC_TEXT = "exc_text"
PROCESS = "process"
# Ray logging context
TIMESTAMP_NS = "timestamp_ns"
+27
View File
@@ -0,0 +1,27 @@
import socket
from contextlib import closing
from ray._raylet import ( # noqa: F401
build_address,
get_all_interfaces_ip,
get_localhost_ip,
is_ipv6,
is_localhost,
node_ip_address_from_perspective,
parse_address,
)
def find_free_port(family: socket.AddressFamily = socket.AF_INET) -> int:
"""Find a free port on the local machine.
Args:
family: The socket address family (AF_INET for IPv4, AF_INET6 for IPv6).
Defaults to AF_INET.
Returns:
An available port number.
"""
with closing(socket.socket(family, socket.SOCK_STREAM)) as s:
s.bind(("", 0))
return s.getsockname()[1]
@@ -0,0 +1,12 @@
"""This module provides the Python API for emitting internal Ray events
via the ONE-Event framework. Events are buffered and exported through
the C++ RayEventRecorder.
"""
from ray._common.observability.internal_event import InternalEventBuilder
from ray._common.observability.platform_events import PlatformEventBuilder
__all__ = [
"InternalEventBuilder",
"PlatformEventBuilder",
]
@@ -0,0 +1,111 @@
"""Base class for building internal Ray events.
This module provides the base class for creating event builders that can
emit events to dashboard-agents aggregator agent service.
"""
from abc import ABC, abstractmethod
from typing import Optional
from ray._raylet import RayEvent
from ray.core.generated.events_base_event_pb2 import RayEvent as RayEventProto
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class InternalEventBuilder(ABC):
"""Abstract base class for building internal Ray events.
Subclasses implement specific event types
and must implement get_entity_id() and serialize_event_data().
"""
def __init__(
self,
source_type: int,
event_type: int,
nested_event_field_number: int,
severity: int = RayEventProto.Severity.INFO,
message: str = "",
session_name: str = "",
):
"""Initialize the event builder.
Args:
source_type: RayEvent.SourceType enum value. Use
RayEventProto.SourceType.<NAME> constants (e.g.,
RayEventProto.SourceType.JOBS,
RayEventProto.SourceType.GCS).
event_type: RayEvent.EventType enum value. Use
RayEventProto.EventType.<NAME> constants (e.g.,
RayEventProto.EventType.SUBMISSION_JOB_DEFINITION_EVENT,
RayEventProto.EventType.DRIVER_JOB_LIFECYCLE_EVENT).
nested_event_field_number: The field number in RayEvent proto for the
nested event message. Use RayEventProto.<FIELD>_FIELD_NUMBER
constants (e.g.,
RayEventProto.SUBMISSION_JOB_DEFINITION_EVENT_FIELD_NUMBER).
severity: RayEvent.Severity enum value (default INFO).
message: Optional message associated with the event.
session_name: The Ray session name.
"""
self._source_type = source_type
self._event_type = event_type
self._nested_event_field_number = nested_event_field_number
self._severity = severity
self._message = message
self._session_name = session_name
@abstractmethod
def get_entity_id(self) -> str:
"""Return the unique entity ID for this event.
The entity ID is used to associate related events (e.g., definition
and lifecycle events for the same job).
Returns:
A string identifier unique to this entity (e.g., node_id/task_id/actor_id/job_id).
"""
pass
@abstractmethod
def serialize_event_data(self) -> bytes:
"""Serialize the event-specific protobuf data to bytes.
Returns:
Serialized protobuf bytes of the nested event message
(e.g., SubmissionJobDefinitionEvent.SerializeToString()).
"""
pass
def build(
self,
event_id: Optional[bytes] = None,
timestamp_ns: Optional[int] = None,
) -> RayEvent:
"""Build the Cython RayEvent object for submission.
Args:
event_id: Optional explicit event id bytes. When omitted, the C++ layer
generates a random id (matching the convention used by the other
RayEventInterface subclasses). Provide an explicit value to reuse
an id from an upstream source (e.g., a Kubernetes event uid).
timestamp_ns: Optional explicit event timestamp in nanoseconds since the
unix epoch. When omitted, the C++ layer captures the current time
at construction. Provide an explicit value for platform events that
carry their own source timestamp.
Returns:
A RayEvent object.
"""
return RayEvent(
source_type=self._source_type,
event_type=self._event_type,
severity=self._severity,
entity_id=self.get_entity_id(),
message=self._message,
session_name=self._session_name,
serialized_data=self.serialize_event_data(),
nested_event_field_number=self._nested_event_field_number,
event_id=event_id if event_id is not None else b"",
timestamp_ns=int(timestamp_ns) if timestamp_ns is not None else 0,
)
@@ -0,0 +1,67 @@
from typing import Dict, Optional
from ray._common.observability.internal_event import InternalEventBuilder
from ray.core.generated.events_base_event_pb2 import RayEvent as RayEventProto
from ray.core.generated.platform_event_pb2 import PlatformEvent, Source
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class PlatformEventBuilder(InternalEventBuilder):
"""Builder for creating infrastructure PlatformEvents (e.g., from Kubernetes)."""
def __init__(
self,
event_uid: str,
platform: int = Source.Platform.PLATFORM_UNSPECIFIED, # Source.Platform enum
object_kind: str = "",
object_name: str = "",
reason: str = "",
message: str = "",
severity: int = RayEventProto.Severity.INFO,
component: str = "",
source_metadata: Optional[Dict[str, str]] = None,
custom_fields: Optional[Dict[str, str]] = None,
session_name: str = "",
):
super().__init__(
source_type=RayEventProto.SourceType.CLUSTER_LIFECYCLE,
event_type=RayEventProto.EventType.PLATFORM_EVENT,
nested_event_field_number=RayEventProto.PLATFORM_EVENT_FIELD_NUMBER,
severity=severity,
message=message,
session_name=session_name,
)
self._event_uid = event_uid
self._platform = platform
self._object_kind = object_kind
self._object_name = object_name
self._reason = reason
self._component = component
self._source_metadata = source_metadata
self._custom_fields = custom_fields
def get_entity_id(self) -> str:
return self._event_uid
def serialize_event_data(self) -> bytes:
source_proto = Source(
platform=self._platform,
component=self._component,
)
if self._source_metadata:
for k, v in self._source_metadata.items():
source_proto.metadata[k] = v
event = PlatformEvent(
source=source_proto,
object_kind=self._object_kind,
object_name=self._object_name,
message=self._message,
reason=self._reason,
)
if self._custom_fields:
for k, v in self._custom_fields.items():
event.custom_fields[k] = v
return event.SerializeToString()
+113
View File
@@ -0,0 +1,113 @@
# ruff: noqa
import packaging.version
# Pydantic is a dependency of `ray["default"]` but not the minimal installation,
# so handle the case where it isn't installed.
try:
import pydantic
PYDANTIC_INSTALLED = True
except ImportError:
pydantic = None
PYDANTIC_INSTALLED = False
if not PYDANTIC_INSTALLED:
IS_PYDANTIC_2 = False
BaseModel = None
Extra = None
Field = None
NonNegativeFloat = None
NonNegativeInt = None
PositiveFloat = None
PositiveInt = None
PrivateAttr = None
StrictInt = None
ValidationError = None
root_validator = None
validator = None
def is_subclass_of_base_model(obj):
return False
elif not hasattr(pydantic, "__version__") or packaging.version.parse(
pydantic.__version__
) < packaging.version.parse("2.0"):
raise ImportError(
"Pydantic v1 is no longer supported in Ray. " "Please upgrade to `pydantic>=2`."
)
else:
IS_PYDANTIC_2 = True
from pydantic import (
BaseModel,
Extra,
Field,
NonNegativeFloat,
NonNegativeInt,
PositiveFloat,
PositiveInt,
PrivateAttr,
StrictInt,
ValidationError,
root_validator,
validator,
)
def is_subclass_of_base_model(obj):
return issubclass(obj, BaseModel)
def _iter_model_field_types():
model_field_types = []
try:
from pydantic.fields import ModelField as model_field_type
except ImportError:
pass
else:
model_field_types.append(model_field_type)
try:
from pydantic.v1.fields import ModelField as compat_model_field_type
except ImportError:
pass
else:
if compat_model_field_type not in model_field_types:
model_field_types.append(compat_model_field_type)
return model_field_types
def register_pydantic_serializers(serialization_context):
if not PYDANTIC_INSTALLED:
return
# Pydantic's Cython validators are not serializable.
# https://github.com/cloudpipe/cloudpickle/issues/408
#
# FastAPI can still surface Pydantic's v1 compatibility ModelField under
# Pydantic v2, so we need to register serializers for both types until that
# compatibility path is no longer used upstream.
for model_field_type in _iter_model_field_types():
serialization_context._register_cloudpickle_serializer(
model_field_type,
custom_serializer=lambda o: {
"name": o.name,
# outer_type_ is the original type for ModelFields,
# while type_ can be updated later with the nested type
# like int for List[int].
"type_": o.outer_type_,
"class_validators": o.class_validators,
"model_config": o.model_config,
"default": o.default,
"default_factory": o.default_factory,
"required": o.required,
"alias": o.alias,
"field_info": o.field_info,
},
custom_deserializer=(
lambda kwargs, model_field_type=model_field_type: model_field_type(
**kwargs
)
),
)
+5
View File
@@ -0,0 +1,5 @@
# Default max_concurrency option in @ray.remote for async actors.
DEFAULT_MAX_CONCURRENCY_ASYNC = 1000
LOGGING_ROTATE_BYTES = 512 * 1024 * 1024 # 512MB.
LOGGING_ROTATE_BACKUP_COUNT = 5 # 5 Backup files at max.
+454
View File
@@ -0,0 +1,454 @@
"""Manage, parse and validate options for Ray tasks, actors and actor methods."""
import warnings
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional, Tuple, Union
import ray
from ray._private import ray_constants
from ray._private.label_utils import (
validate_fallback_strategy,
validate_label_selector,
)
from ray._private.utils import get_ray_doc_version
from ray.util.placement_group import PlacementGroup
from ray.util.scheduling_strategies import (
NodeAffinitySchedulingStrategy,
NodeLabelSchedulingStrategy,
PlacementGroupSchedulingStrategy,
)
@dataclass
class Option:
# Type constraint of an option.
type_constraint: Optional[Union[type, Tuple[type]]] = None
# Value constraint of an option.
# The callable should return None if there is no error.
# Otherwise, return the error message.
value_constraint: Optional[Callable[[Any], Optional[str]]] = None
# Default value.
default_value: Any = None
def validate(self, keyword: str, value: Any):
"""Validate the option."""
if self.type_constraint is not None:
if not isinstance(value, self.type_constraint):
raise TypeError(
f"The type of keyword '{keyword}' must be {self.type_constraint}, "
f"but received type {type(value)}"
)
if self.value_constraint is not None:
possible_error_message = self.value_constraint(value)
if possible_error_message:
raise ValueError(possible_error_message)
def _counting_option(name: str, infinite: bool = True, default_value: Any = None):
"""This is used for positive and discrete options.
Args:
name: The name of the option keyword.
infinite: If True, user could use -1 to represent infinity.
default_value: The default value for this option.
Returns:
An Option object.
"""
if infinite:
return Option(
(int, type(None)),
lambda x: None
if (x is None or x >= -1)
else f"The keyword '{name}' only accepts None, 0, -1"
" or a positive integer, where -1 represents infinity.",
default_value=default_value,
)
return Option(
(int, type(None)),
lambda x: None
if (x is None or x >= 0)
else f"The keyword '{name}' only accepts None, 0 or a positive integer.",
default_value=default_value,
)
def _validate_resource_quantity(name, quantity):
if quantity < 0:
return f"The quantity of resource {name} cannot be negative"
if (
isinstance(quantity, float)
and quantity != 0.0
and int(quantity * ray._raylet.RESOURCE_UNIT_SCALING) == 0
):
return (
f"The precision of the fractional quantity of resource {name}"
" cannot go beyond 0.0001"
)
resource_name = "GPU" if name == "num_gpus" else name
if resource_name in ray._private.accelerators.get_all_accelerator_resource_names():
(
valid,
error_message,
) = ray._private.accelerators.get_accelerator_manager_for_resource(
resource_name
).validate_resource_request_quantity(
quantity
)
if not valid:
return error_message
return None
def _resource_option(name: str, default_value: Any = None):
"""This is used for resource related options."""
return Option(
(float, int, type(None)),
lambda x: None if (x is None) else _validate_resource_quantity(name, x),
default_value=default_value,
)
def _validate_resources(resources: Optional[Dict[str, float]]) -> Optional[str]:
if resources is None:
return None
if "CPU" in resources or "GPU" in resources:
return (
"Use the 'num_cpus' and 'num_gpus' keyword instead of 'CPU' and 'GPU' "
"in 'resources' keyword"
)
for name, quantity in resources.items():
possible_error_message = _validate_resource_quantity(name, quantity)
if possible_error_message:
return possible_error_message
return None
_common_options = {
"label_selector": Option((dict, type(None)), lambda x: validate_label_selector(x)),
"fallback_strategy": Option(
(list, type(None)), lambda x: validate_fallback_strategy(x)
),
"accelerator_type": Option((str, type(None))),
"memory": _resource_option("memory"),
"name": Option((str, type(None))),
"num_cpus": _resource_option("num_cpus"),
"num_gpus": _resource_option("num_gpus"),
"object_store_memory": _counting_option("object_store_memory", False),
# TODO(suquark): "placement_group", "placement_group_bundle_index"
# and "placement_group_capture_child_tasks" are deprecated,
# use "scheduling_strategy" instead.
"placement_group": Option(
(type(None), str, PlacementGroup), default_value="default"
),
"placement_group_bundle_index": Option(int, default_value=-1),
"placement_group_capture_child_tasks": Option((bool, type(None))),
"resources": Option((dict, type(None)), lambda x: _validate_resources(x)),
"runtime_env": Option((dict, type(None))),
"scheduling_strategy": Option(
(
type(None),
str,
PlacementGroupSchedulingStrategy,
NodeAffinitySchedulingStrategy,
NodeLabelSchedulingStrategy,
)
),
"enable_task_events": Option(bool, default_value=True),
"_labels": Option((dict, type(None))),
}
def issubclass_safe(obj: Any, cls_: type) -> bool:
try:
return issubclass(obj, cls_)
except TypeError:
return False
_task_only_options = {
"max_calls": _counting_option("max_calls", False, default_value=0),
# Normal tasks may be retried on failure this many times.
# TODO(swang): Allow this to be set globally for an application.
"max_retries": _counting_option(
"max_retries", default_value=ray_constants.DEFAULT_TASK_MAX_RETRIES
),
# override "_common_options"
"num_cpus": _resource_option("num_cpus", default_value=1),
"num_returns": Option(
(int, str, type(None)),
lambda x: None
if (x is None or x == "dynamic" or x == "streaming" or x >= 0)
else "Default None. When None is passed, "
"The default value is 1 for a task and actor task, and "
"'streaming' for generator tasks and generator actor tasks. "
"The keyword 'num_returns' only accepts None, "
"a non-negative integer, "
"'streaming' (for generators), or 'dynamic'. 'dynamic' flag "
"will be deprecated in the future, and it is recommended to use "
"'streaming' instead.",
default_value=None,
),
"object_store_memory": Option( # override "_common_options"
(int, type(None)),
lambda x: None
if (x is None)
else "Setting 'object_store_memory' is not implemented for tasks",
),
"retry_exceptions": Option(
(bool, list, tuple),
lambda x: None
if (
isinstance(x, bool)
or (
isinstance(x, (list, tuple))
and all(issubclass_safe(x_, Exception) for x_ in x)
)
)
else "retry_exceptions must be either a boolean or a list of exceptions",
default_value=False,
),
"_generator_backpressure_num_objects": Option(
(int, type(None)),
lambda x: None
if x != 0
else (
"_generator_backpressure_num_objects=0 is not allowed. "
"Use a value > 0. If the value is equal to 1, the behavior "
"is identical to Python generator (generator 1 object "
"whenever `next` is called). Use -1 to disable this feature. "
),
),
"_num_objects_per_yield": Option(
(int, type(None)),
lambda x: None
if (x is None or x > 0)
else (
"_num_objects_per_yield is a private streaming generator option "
"that must be set to a positive integer."
),
default_value=1,
),
}
_actor_only_options = {
"concurrency_groups": Option((list, dict, type(None))),
"enable_tensor_transport": Option((bool, type(None)), default_value=None),
"lifetime": Option(
(str, type(None)),
lambda x: None
if x in (None, "detached", "non_detached")
else "actor `lifetime` argument must be one of 'detached', "
"'non_detached' and 'None'.",
),
"max_concurrency": _counting_option("max_concurrency", False),
"max_restarts": _counting_option("max_restarts", default_value=0),
"max_task_retries": _counting_option("max_task_retries", default_value=0),
"max_pending_calls": _counting_option("max_pending_calls", default_value=-1),
"namespace": Option((str, type(None))),
"get_if_exists": Option(bool, default_value=False),
"allow_out_of_order_execution": Option((bool, type(None))),
# Actor-wide cap on the number of unconsumed streaming-generator
# objects across all generator tasks running on the actor. Coexists
# with the per-method `_generator_backpressure_num_objects`: both
# apply, and the producer blocks on whichever is tighter. -1 (or
# None / unset) disables the actor-wide cap.
"_actor_generator_backpressure_num_objects": Option(
(int, type(None)),
lambda x: None
if (x is None or x > 0 or x == -1)
else (
"_actor_generator_backpressure_num_objects must be > 0 to cap the "
"actor's total unconsumed generator objects, or -1 to disable. "
f"Got {x}."
),
),
}
# Priority is important here because during dictionary update, same key with higher
# priority overrides the same key with lower priority. We make use of priority
# to set the correct default value for tasks / actors.
# priority: _common_options > _actor_only_options > _task_only_options
valid_options: Dict[str, Option] = {
**_task_only_options,
**_actor_only_options,
**_common_options,
}
# priority: _task_only_options > _common_options
task_options: Dict[str, Option] = {**_common_options, **_task_only_options}
# priority: _actor_only_options > _common_options
actor_options: Dict[str, Option] = {**_common_options, **_actor_only_options}
remote_args_error_string = (
"The @ray.remote decorator must be applied either with no arguments and no "
"parentheses, for example '@ray.remote', or it must be applied using some of "
f"the arguments in the list {list(valid_options.keys())}, for example "
"'@ray.remote(num_returns=2, resources={\"CustomResource\": 1})'."
)
def _check_deprecate_placement_group(options: Dict[str, Any]):
"""Check if deprecated placement group option exists."""
placement_group = options.get("placement_group", "default")
scheduling_strategy = options.get("scheduling_strategy")
# TODO(suquark): @ray.remote(placement_group=None) is used in
# "python/ray.data._internal/remote_fn.py" and many other places,
# while "ray.data.read_api.read_datasource" set "scheduling_strategy=SPREAD".
# This might be a bug, but it is also ok to allow them co-exist.
if (placement_group not in ("default", None)) and (scheduling_strategy is not None):
raise ValueError(
"Placement groups should be specified via the "
"scheduling_strategy option. "
"The placement_group option is deprecated."
)
def _warn_if_using_deprecated_placement_group(
options: Dict[str, Any], caller_stacklevel: int
):
placement_group = options["placement_group"]
placement_group_bundle_index = options["placement_group_bundle_index"]
placement_group_capture_child_tasks = options["placement_group_capture_child_tasks"]
if placement_group != "default":
warnings.warn(
"placement_group parameter is deprecated. Use "
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
"instead, see the usage at "
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
DeprecationWarning,
stacklevel=caller_stacklevel + 1,
)
if placement_group_bundle_index != -1:
warnings.warn(
"placement_group_bundle_index parameter is deprecated. Use "
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
"instead, see the usage at "
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
DeprecationWarning,
stacklevel=caller_stacklevel + 1,
)
if placement_group_capture_child_tasks:
warnings.warn(
"placement_group_capture_child_tasks parameter is deprecated. Use "
"scheduling_strategy=PlacementGroupSchedulingStrategy(...) "
"instead, see the usage at "
f"https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/package-ref.html#ray-remote.", # noqa: E501
DeprecationWarning,
stacklevel=caller_stacklevel + 1,
)
def validate_task_options(
options: Dict[str, Any],
in_options: bool,
is_generator_callable: Optional[bool] = None,
):
"""Options check for Ray tasks.
Args:
options: Options for Ray tasks.
in_options: If True, we are checking the options under the context of
".options()".
is_generator_callable: Optional bool indicating whether the callable is a
generator function. If provided and num_returns is 'streaming' or
'dynamic', validates that the callable is a generator.
"""
for k, v in options.items():
if k not in task_options:
raise ValueError(
f"Invalid option keyword {k} for remote functions. "
f"Valid ones are {list(task_options)}."
)
task_options[k].validate(k, v)
if in_options and "max_calls" in options:
raise ValueError("Setting 'max_calls' is not supported in '.options()'.")
_check_deprecate_placement_group(options)
if is_generator_callable is not None:
num_returns = options.get("num_returns")
if num_returns is not None:
validate_num_returns(is_generator_callable, num_returns)
def validate_actor_options(options: Dict[str, Any], in_options: bool):
"""Options check for Ray actors.
Args:
options: Options for Ray actors.
in_options: If True, we are checking the options under the context of
".options()".
"""
for k, v in options.items():
if k not in actor_options:
raise ValueError(
f"Invalid option keyword {k} for actors. "
f"Valid ones are {list(actor_options)}."
)
actor_options[k].validate(k, v)
if in_options and "concurrency_groups" in options:
raise ValueError(
"Setting 'concurrency_groups' is not supported in '.options()'."
)
if options.get("get_if_exists") and not options.get("name"):
raise ValueError("The actor name must be specified to use `get_if_exists`.")
if "object_store_memory" in options:
warnings.warn(
"Setting 'object_store_memory'"
" for actors is deprecated since it doesn't actually"
" reserve the required object store memory."
f" Use object spilling that's enabled by default (https://docs.ray.io/en/{get_ray_doc_version()}/ray-core/objects/object-spilling.html) " # noqa: E501
"instead to bypass the object store memory size limitation.",
DeprecationWarning,
stacklevel=1,
)
_check_deprecate_placement_group(options)
def validate_num_returns(is_generator_callable: bool, num_returns: Any) -> None:
"""Validate num_returns for @ray.remote and @ray.method decorators.
This function validates:
1. If num_returns is an integer < 0, it should fail fast.
2. If num_returns='streaming' or 'dynamic' is used with a non-generator
function, it should fail fast.
Args:
is_generator_callable: Whether the callable is a generator function or
async generator function.
num_returns: The num_returns value to validate.
Raises:
ValueError: If num_returns < 0, or if num_returns is 'streaming' or 'dynamic'
but the callable is not a generator function or async generator function.
"""
if num_returns is None:
return
# Validate num_returns < 0
if isinstance(num_returns, int) and num_returns < 0:
raise ValueError(f"num_returns must be >= 0, but got {num_returns}.")
# Validate num_returns='streaming' or 'dynamic' for generator functions
if num_returns in ("streaming", "dynamic") and not is_generator_callable:
raise ValueError(
f"num_returns='{num_returns}' can only be used with generator functions "
f"(functions that use 'yield'). "
f"The decorated function is not a generator function."
)
def update_options(
original_options: Dict[str, Any], new_options: Dict[str, Any]
) -> Dict[str, Any]:
"""Update original options with new options and return.
The returned updated options contain shallow copy of original options.
"""
return {**original_options, **new_options}
+162
View File
@@ -0,0 +1,162 @@
import functools
import logging
import random
import re
import time
import traceback
from collections.abc import Sequence
from typing import Callable, Optional, TypeVar
try:
from typing import ParamSpec
except ImportError:
from typing_extensions import ParamSpec
logger = logging.getLogger(__name__)
R = TypeVar("R")
P = ParamSpec("P")
def format_exception(exc: BaseException, include_cause: bool = False) -> str:
"""Format ``exc`` as ``"ClassName: message"`` for substring/regex matching.
Uses `traceback.format_exception_only` so the class name is preserved
and callers can match on either the class name or the message.
Args:
exc: The exception to format.
include_cause: If True and ``exc.__cause__`` is set (``raise X from Y``),
append the cause exception after the base exception. This is useful when
we want to match on an exception encountered in the UDF (e.g. ``RateLimitError``)
which is wrapped in a ``UserCodeException`` by Ray Data.
Returns:
A single-string representation of ``exc`` in the form
``"ClassName: message"``. When ``include_cause`` is True and
``exc.__cause__`` is set, the cause's formatted form is appended
after a single space. See the example below.
Example:
For a ``UserCodeException`` wrapping a ``RateLimitError``, calling ``format_exception(e, include_cause=True)``
returns::
ray.exceptions.UserCodeException: UDF failed to process a data block. RateLimitError: Error code: 429 - rate limited
"""
s = "".join(traceback.format_exception_only(type(exc), exc)).rstrip("\n")
if include_cause and exc.__cause__:
cause = exc.__cause__
s += " " + "".join(traceback.format_exception_only(type(cause), cause)).rstrip(
"\n"
)
return s
def matches_error(pattern: str, error_str: str) -> bool:
"""True if ``pattern`` matches ``error_str`` as a substring or as a regex.
Substring is tried first so literal patterns are not interpreted as regex.
Invalid regex patterns return False instead of raising.
Args:
pattern: Pattern to match, tried first as a substring then as a regex.
error_str: Formatted exception string.
Returns:
True if ``pattern`` matches ``error_str`` as a substring or as a regex.
"""
if pattern in error_str:
return True
try:
return bool(re.search(pattern, error_str))
except re.error:
return False
def call_with_retry(
f: Callable[P, R],
description: str,
match: Optional[Sequence[str]] = None,
max_attempts: int = 10,
max_backoff_s: int = 32,
*args: P.args,
**kwargs: P.kwargs,
) -> R:
"""Retry a function with exponential backoff.
Args:
f: The function to retry.
description: An imperative description of the function being retried. For
example, "open the file".
match: A sequence of patterns to match in the exception message. Each
pattern is first checked as a substring, then as a regex. If
``None``, any error is retried.
max_attempts: The maximum number of attempts to retry.
max_backoff_s: The maximum number of seconds to backoff.
*args: Arguments to pass to the function.
**kwargs: Keyword arguments to pass to the function.
Returns:
The result of the function.
"""
# TODO: consider inverse match and matching exception type
assert max_attempts >= 1, f"`max_attempts` must be positive. Got {max_attempts}."
for i in range(max_attempts):
try:
return f(*args, **kwargs)
except Exception as e:
exception_str = format_exception(e)
is_retryable = match is None or any(
matches_error(pattern, exception_str) for pattern in match
)
if is_retryable and i + 1 < max_attempts:
# Retry with binary exponential backoff with 20% random jitter.
backoff = min(2**i, max_backoff_s) * (random.uniform(0.8, 1.2))
logger.debug(
f"Retrying {i+1} attempts to {description} after {backoff} seconds."
)
time.sleep(backoff)
else:
if is_retryable:
logger.debug(
f"Failed to {description} after {max_attempts} attempts. Raising."
)
else:
logger.debug(
f"Did not find a match for {exception_str}. Raising after {i+1} attempts."
)
raise e from None
def retry(
description: str,
match: Optional[Sequence[str]] = None,
max_attempts: int = 10,
max_backoff_s: int = 32,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Decorator-based version of call_with_retry.
Args:
description: An imperative description of the function being retried. For
example, "open the file".
match: A sequence of patterns to match in the exception message. Each
pattern is first checked as a substring, then as a regex. If
``None``, any error is retried.
max_attempts: The maximum number of attempts to retry.
max_backoff_s: The maximum number of seconds to backoff.
Returns:
A Callable that can be applied in a normal decorator fashion.
"""
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return call_with_retry(
func, description, match, max_attempts, max_backoff_s, *args, **kwargs
)
return inner
return decorator
+34
View File
@@ -0,0 +1,34 @@
import io
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
pass
import ray._private.utils
import ray.cloudpickle as pickle
import ray.exceptions
from ray._private import ray_constants
from ray.util import inspect_serializability
logger = logging.getLogger(__name__)
ALLOW_OUT_OF_BAND_OBJECT_REF_SERIALIZATION = ray_constants.env_bool(
"RAY_allow_out_of_band_object_ref_serialization", True
)
def pickle_dumps(obj: Any, error_msg: str):
"""Wrap cloudpickle.dumps to provide better error message
when the object is not serializable.
"""
try:
return pickle.dumps(obj)
except (TypeError, ray.exceptions.OufOfBandObjectRefSerializationException) as e:
sio = io.StringIO()
inspect_serializability(obj, print_file=sio)
msg = f"{error_msg}:\n{sio.getvalue()}"
if isinstance(e, TypeError):
raise TypeError(msg) from e
else:
raise ray.exceptions.OufOfBandObjectRefSerializationException(msg)
+163
View File
@@ -0,0 +1,163 @@
import inspect
import logging
from inspect import Parameter
from typing import Any, Dict, List, Tuple
from ray._private.inspect_util import is_cython
# Logger for this module. It should be configured at the entry point
# into the program using Ray. Ray provides a default configuration at
# entry/init points.
logger = logging.getLogger(__name__)
# This dummy type is also defined in ArgumentsBuilder.java. Please keep it
# synced.
DUMMY_TYPE = b"__RAY_DUMMY__"
def get_signature(func: Any) -> inspect.Signature:
"""Get signature parameters.
Support Cython functions by grabbing relevant attributes from the Cython
function and attaching to a no-op function. This is somewhat brittle, since
inspect may change, but given that inspect is written to a PEP, we hope
it is relatively stable. Future versions of Python may allow overloading
the inspect 'isfunction' and 'ismethod' functions / create ABC for Python
functions. Until then, it appears that Cython won't do anything about
compatability with the inspect module.
Args:
func: The function whose signature should be checked.
Returns:
A function signature object, which includes the names of the keyword
arguments as well as their default values.
Raises:
TypeError: A type error if the signature is not supported
"""
# The first condition for Cython functions, the latter for Cython instance
# methods
if is_cython(func):
attrs = ["__code__", "__annotations__", "__defaults__", "__kwdefaults__"]
if all(hasattr(func, attr) for attr in attrs):
original_func = func
def func():
return
for attr in attrs:
setattr(func, attr, getattr(original_func, attr))
else:
raise TypeError(f"{func!r} is not a Python function we can process")
return inspect.signature(func)
def extract_signature(func: Any, ignore_first: bool = False) -> List[Parameter]:
"""Extract the function signature from the function.
Args:
func: The function whose signature should be extracted.
ignore_first: True if the first argument should be ignored. This should
be used when func is a method of a class.
Returns:
List of Parameter objects representing the function signature.
"""
signature_parameters = list(get_signature(func).parameters.values())
if ignore_first:
if len(signature_parameters) == 0:
raise ValueError(
"Methods must take a 'self' argument, but the "
f"method '{func.__name__}' does not have one."
)
signature_parameters = signature_parameters[1:]
return signature_parameters
def validate_args(
signature_parameters: List[Parameter], args: Tuple[Any, ...], kwargs: Dict[str, Any]
) -> None:
"""Validates the arguments against the signature.
Args:
signature_parameters: The list of Parameter objects
representing the function signature, obtained from
`extract_signature`.
args: The positional arguments passed into the function.
kwargs: The keyword arguments passed into the function.
Raises:
TypeError: Raised if arguments do not fit in the function signature.
"""
reconstructed_signature = inspect.Signature(parameters=signature_parameters)
try:
reconstructed_signature.bind(*args, **kwargs)
except TypeError as exc: # capture a friendlier stacktrace
raise TypeError(str(exc)) from None
def flatten_args(
signature_parameters: List[Parameter], args: Tuple[Any, ...], kwargs: Dict[str, Any]
) -> List[Any]:
"""Validates the arguments against the signature and flattens them.
The flat list representation is a serializable format for arguments.
Since the flatbuffer representation of function arguments is a list, we
combine both keyword arguments and positional arguments. We represent
this with two entries per argument value - [DUMMY_TYPE, x] for positional
arguments and [KEY, VALUE] for keyword arguments. See the below example.
See `recover_args` for logic restoring the flat list back to args/kwargs.
Args:
signature_parameters: The list of Parameter objects
representing the function signature, obtained from
`extract_signature`.
args: The positional arguments passed into the function.
kwargs: The keyword arguments passed into the function.
Returns:
List of args and kwargs. Non-keyword arguments are prefixed
by internal enum DUMMY_TYPE.
Raises:
TypeError: Raised if arguments do not fit in the function signature.
"""
validate_args(signature_parameters, args, kwargs)
list_args = []
for arg in args:
list_args += [DUMMY_TYPE, arg]
for keyword, arg in kwargs.items():
list_args += [keyword, arg]
return list_args
def recover_args(flattened_args: List[Any]) -> Tuple[List[Any], Dict[str, Any]]:
"""Recreates `args` and `kwargs` from the flattened arg list.
Args:
flattened_args: List of args and kwargs. This should be the output of
`flatten_args`.
Returns:
args: The non-keyword arguments passed into the function.
kwargs: The keyword arguments passed into the function.
"""
assert (
len(flattened_args) % 2 == 0
), "Flattened arguments need to be even-numbered. See `flatten_args`."
args = []
kwargs = {}
for name_index in range(0, len(flattened_args), 2):
name, arg = flattened_args[name_index], flattened_args[name_index + 1]
if name == DUMMY_TYPE:
args.append(arg)
else:
kwargs[name] = arg
return args, kwargs
+530
View File
@@ -0,0 +1,530 @@
"""Test utilities for Ray.
This module contains test utility classes that are distributed with the Ray package
and can be used by external libraries and tests. These utilities must remain in
_common/ (not in tests/) to be accessible in the Ray package distribution.
"""
import asyncio
import inspect
import logging
import os
import subprocess
import sys
import threading
import time
import traceback
import uuid
from collections import defaultdict
from collections.abc import Awaitable
from contextlib import contextmanager
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, Iterator, List, Optional, Set
import ray
import ray._common.usage.usage_lib as ray_usage_lib
import ray._private.utils
from ray._common.network_utils import build_address
from ray._common.utils import decode
logger = logging.getLogger(__name__)
try:
from prometheus_client.core import Metric
from prometheus_client.parser import Sample, text_string_to_metric_families
except (ImportError, ModuleNotFoundError):
Metric = None
Sample = None
def text_string_to_metric_families(*args, **kwargs):
raise ModuleNotFoundError("`prometheus_client` not found")
@ray.remote(num_cpus=0)
class SignalActor:
"""A Ray actor for coordinating test execution through signals.
Useful for testing async coordination, waiting for specific states,
and synchronizing multiple actors or tasks in tests.
"""
def __init__(self):
self.ready_event = asyncio.Event()
self.num_waiters = 0
def send(self, clear: bool = False):
self.ready_event.set()
if clear:
self.ready_event.clear()
async def wait(self, should_wait: bool = True):
if should_wait:
self.num_waiters += 1
await self.ready_event.wait()
self.num_waiters -= 1
async def cur_num_waiters(self) -> int:
return self.num_waiters
@ray.remote(num_cpus=0)
class Semaphore:
"""A Ray actor implementing a semaphore for test coordination.
Useful for testing resource limiting, concurrency control,
and coordination between multiple actors or tasks.
"""
def __init__(self, value: int = 1):
self._sema = asyncio.Semaphore(value=value)
async def acquire(self):
await self._sema.acquire()
async def release(self):
self._sema.release()
async def locked(self) -> bool:
return self._sema.locked()
__all__ = ["SignalActor", "Semaphore"]
def wait_for_condition(
condition_predictor: Callable[..., bool],
timeout: float = 10,
retry_interval_ms: float = 100,
raise_exceptions: bool = False,
**kwargs: Any,
):
"""Wait until a condition is met or time out with an exception.
Args:
condition_predictor: A function that predicts the condition.
timeout: Maximum timeout in seconds.
retry_interval_ms: Retry interval in milliseconds.
raise_exceptions: If true, exceptions that occur while executing
condition_predictor won't be caught and instead will be raised.
**kwargs: Arguments to pass to the condition_predictor.
Returns:
None: Returns when the condition is met.
Raises:
RuntimeError: If the condition is not met before the timeout expires.
"""
start = time.monotonic()
last_ex = None
while time.monotonic() - start <= timeout:
try:
if condition_predictor(**kwargs):
return
except Exception:
if raise_exceptions:
raise
last_ex = ray._private.utils.format_error_message(traceback.format_exc())
time.sleep(retry_interval_ms / 1000.0)
message = "The condition wasn't met before the timeout expired."
if last_ex is not None:
message += f" Last exception: {last_ex}"
raise RuntimeError(message)
async def async_wait_for_condition(
condition_predictor: Callable[..., Awaitable[bool]],
timeout: float = 10,
retry_interval_ms: float = 100,
**kwargs: Any,
):
"""Wait until a condition is met or time out with an exception.
Args:
condition_predictor: A function that predicts the condition.
timeout: Maximum timeout in seconds.
retry_interval_ms: Retry interval in milliseconds.
**kwargs: Arguments to pass to the condition_predictor.
Returns:
None: Returns when the condition is met.
Raises:
RuntimeError: If the condition is not met before the timeout expires.
"""
start = time.monotonic()
last_ex = None
while time.monotonic() - start <= timeout:
try:
if inspect.iscoroutinefunction(condition_predictor):
if await condition_predictor(**kwargs):
return
else:
if condition_predictor(**kwargs):
return
except Exception as ex:
last_ex = ex
await asyncio.sleep(retry_interval_ms / 1000.0)
message = "The condition wasn't met before the timeout expired."
if last_ex is not None:
message += f" Last exception: {last_ex}"
raise RuntimeError(message)
@contextmanager
def simulate_s3_bucket(
port: int = 5002,
region: str = "us-west-2",
) -> Iterator[str]:
"""Context manager that simulates an S3 bucket and yields the URI.
Args:
port: The port of the localhost endpoint where S3 is being served.
region: The S3 region.
Yields:
str: URI for the simulated S3 bucket.
"""
from moto.server import ThreadedMotoServer
old_env = os.environ
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
s3_server = f"http://{build_address('localhost', port)}"
server = ThreadedMotoServer(port=port)
server.start()
url = f"s3://{uuid.uuid4().hex}?region={region}&endpoint_override={s3_server}"
yield url
server.stop()
os.environ = old_env
class TelemetryCallsite(Enum):
DRIVER = "driver"
ACTOR = "actor"
TASK = "task"
def _get_library_usages() -> Set[str]:
return set(
ray_usage_lib.get_library_usages_to_report(
ray.experimental.internal_kv.internal_kv_get_gcs_client()
)
)
def _get_extra_usage_tags() -> Dict[str, str]:
return ray_usage_lib.get_extra_usage_tags_to_report(
ray.experimental.internal_kv.internal_kv_get_gcs_client()
)
def check_library_usage_telemetry(
use_lib_fn: Callable[[], None],
*,
callsite: TelemetryCallsite,
expected_library_usages: List[Set[str]],
expected_extra_usage_tags: Optional[Dict[str, str]] = None,
):
"""Helper for writing tests to validate library usage telemetry.
`use_lib_fn` is a callable that will be called from the provided callsite.
After calling it, the telemetry data to export will be validated against
expected_library_usages and expected_extra_usage_tags.
"""
assert len(_get_library_usages()) == 0, _get_library_usages()
if callsite == TelemetryCallsite.DRIVER:
use_lib_fn()
elif callsite == TelemetryCallsite.ACTOR:
@ray.remote
class A:
def __init__(self):
use_lib_fn()
a = A.remote()
ray.get(a.__ray_ready__.remote())
elif callsite == TelemetryCallsite.TASK:
@ray.remote
def f():
use_lib_fn()
ray.get(f.remote())
else:
assert False, f"Unrecognized callsite: {callsite}"
library_usages = _get_library_usages()
extra_usage_tags = _get_extra_usage_tags()
assert library_usages in expected_library_usages, library_usages
if expected_extra_usage_tags:
assert all(
[extra_usage_tags[k] == v for k, v in expected_extra_usage_tags.items()]
), extra_usage_tags
class FakeTimer:
def __init__(self, start_time: Optional[float] = None):
self._lock = threading.Lock()
self.reset(start_time=start_time)
def reset(self, start_time: Optional[float] = None):
with self._lock:
if start_time is None:
start_time = time.time()
self._curr = start_time
def time(self) -> float:
return self._curr
def advance(self, by: float):
with self._lock:
self._curr += by
def realistic_sleep(self, amt: float):
with self._lock:
self._curr += amt + 0.001
def is_named_tuple(cls):
"""Return True if cls is a namedtuple and False otherwise."""
b = cls.__bases__
if len(b) != 1 or b[0] is not tuple:
return False
f = getattr(cls, "_fields", None)
if not isinstance(f, tuple):
return False
return all(type(n) is str for n in f)
def assert_tensors_equivalent(obj1, obj2):
"""
Recursively compare objects with special handling for torch.Tensor.
Tensors are considered equivalent if:
- Same dtype and shape
- Same device type (e.g., both 'cpu' or both 'cuda'), index ignored
- Values are equal (or close for floats)
"""
import torch
if isinstance(obj1, torch.Tensor) and isinstance(obj2, torch.Tensor):
# 1. dtype
assert obj1.dtype == obj2.dtype, f"dtype mismatch: {obj1.dtype} vs {obj2.dtype}"
# 2. shape
assert obj1.shape == obj2.shape, f"shape mismatch: {obj1.shape} vs {obj2.shape}"
# 3. device type must match (cpu/cpu or cuda/cuda), ignore index
assert (
obj1.device.type == obj2.device.type
), f"Device type mismatch: {obj1.device} vs {obj2.device}"
# 4. Compare values safely on CPU
t1_cpu = obj1.cpu()
t2_cpu = obj2.cpu()
if obj1.dtype.is_floating_point or obj1.dtype.is_complex:
assert torch.allclose(
t1_cpu, t2_cpu, atol=1e-6, rtol=1e-5
), "Floating-point tensors not close"
else:
assert torch.equal(t1_cpu, t2_cpu), "Integer/bool tensors not equal"
return
# Type must match
if type(obj1) is not type(obj2):
raise AssertionError(f"Type mismatch: {type(obj1)} vs {type(obj2)}")
# Handle namedtuples
if is_named_tuple(type(obj1)):
assert len(obj1) == len(obj2)
for a, b in zip(obj1, obj2):
assert_tensors_equivalent(a, b)
elif isinstance(obj1, dict):
assert obj1.keys() == obj2.keys()
for k in obj1:
assert_tensors_equivalent(obj1[k], obj2[k])
elif isinstance(obj1, (list, tuple)):
assert len(obj1) == len(obj2)
for a, b in zip(obj1, obj2):
assert_tensors_equivalent(a, b)
elif hasattr(obj1, "__dict__") and hasattr(obj2, "__dict__"):
# Compare user-defined objects by their public attributes
keys1 = {
k
for k in obj1.__dict__.keys()
if not k.startswith("_ray_") and k != "_pytype_"
}
keys2 = {
k
for k in obj2.__dict__.keys()
if not k.startswith("_ray_") and k != "_pytype_"
}
assert keys1 == keys2, f"Object attribute keys differ: {keys1} vs {keys2}"
for k in keys1:
assert_tensors_equivalent(obj1.__dict__[k], obj2.__dict__[k])
else:
# Fallback for primitives: int, float, str, bool, etc.
assert obj1 == obj2, f"Non-tensor values differ: {obj1} vs {obj2}"
def run_string_as_driver(
driver_script: str, env: Dict = None, encode: str = "utf-8"
) -> str:
"""Run a driver as a separate process.
Args:
driver_script: A string to run as a Python script.
env: The environment variables for the driver.
encode: The encoding to use for the driver script.
Returns:
The script's output.
"""
proc = subprocess.Popen(
[sys.executable, "-"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
)
with proc:
output = proc.communicate(driver_script.encode(encoding=encode))[0]
if proc.returncode:
print(decode(output, encode_type=encode))
logger.error(proc.stderr)
raise subprocess.CalledProcessError(
proc.returncode, proc.args, output, proc.stderr
)
out = decode(output, encode_type=encode)
return out
@dataclass
class MetricSamplePattern:
name: Optional[str] = None
value: Optional[str] = None
partial_label_match: Optional[Dict[str, str]] = None
def matches(self, sample: "Sample"):
if self.name is not None:
if self.name != sample.name:
return False
if self.value is not None:
if self.value != sample.value:
return False
if self.partial_label_match is not None:
for label, value in self.partial_label_match.items():
if sample.labels.get(label) != value:
return False
return True
@dataclass
class PrometheusTimeseries:
"""A collection of timeseries from multiple addresses. Each timeseries is a
collection of samples with the same metric name and labels. Concretely:
- components_dict: a dictionary of addresses to the Component labels
- metric_descriptors: a dictionary of metric names to the Metric object
- metric_samples: the latest value of each label
"""
components_dict: Dict[str, Set[str]] = field(default_factory=dict)
metric_descriptors: Dict[str, "Metric"] = field(default_factory=dict)
metric_samples: Dict[frozenset, "Sample"] = field(default_factory=dict)
def flush(self):
self.components_dict.clear()
self.metric_descriptors.clear()
self.metric_samples.clear()
def fetch_raw_prometheus(prom_addresses, timeout=None):
# Local import so minimal dependency tests can run without requests
import requests
for address in prom_addresses:
try:
kwargs = {} if timeout is None else {"timeout": timeout}
response = requests.get(f"http://{address}/metrics", **kwargs)
yield address, response.text
except requests.exceptions.ConnectionError:
continue
except requests.exceptions.Timeout:
continue
def fetch_prometheus(prom_addresses, timeout=None):
components_dict = {}
metric_descriptors = {}
metric_samples = []
for address in prom_addresses:
if address not in components_dict:
components_dict[address] = set()
for address, response in fetch_raw_prometheus(prom_addresses, timeout=timeout):
for metric in text_string_to_metric_families(response):
for sample in metric.samples:
metric_descriptors[sample.name] = metric
metric_samples.append(sample)
if "Component" in sample.labels:
components_dict[address].add(sample.labels["Component"])
return components_dict, metric_descriptors, metric_samples
def fetch_prometheus_timeseries(
prom_addreses: List[str],
result: PrometheusTimeseries,
timeout=None,
) -> PrometheusTimeseries:
components_dict, metric_descriptors, metric_samples = fetch_prometheus(
prom_addreses, timeout=timeout
)
for address, components in components_dict.items():
if address not in result.components_dict:
result.components_dict[address] = set()
result.components_dict[address].update(components)
result.metric_descriptors.update(metric_descriptors)
for sample in metric_samples:
# udpate sample to the latest value
result.metric_samples[
frozenset(list(sample.labels.items()) + [("_metric_name_", sample.name)])
] = sample
return result
def fetch_prometheus_metrics(prom_addresses: List[str]) -> Dict[str, List[Any]]:
"""Return prometheus metrics from the given addresses.
Args:
prom_addresses: List of metrics_agent addresses to collect metrics from.
Returns:
Dict mapping from metric name to list of samples for the metric.
"""
_, _, samples = fetch_prometheus(prom_addresses)
samples_by_name = defaultdict(list)
for sample in samples:
samples_by_name[sample.name].append(sample)
return samples_by_name
def fetch_prometheus_metric_timeseries(
prom_addresses: List[str],
result: PrometheusTimeseries,
timeout=None,
) -> Dict[str, List[Any]]:
samples = fetch_prometheus_timeseries(
prom_addresses, result, timeout=timeout
).metric_samples.values()
samples_by_name = defaultdict(list)
for sample in samples:
samples_by_name[sample.name].append(sample)
return samples_by_name
+53
View File
@@ -0,0 +1,53 @@
load("@rules_python//python:defs.bzl", "py_library")
load("//bazel:python.bzl", "py_test_module_list")
py_library(
name = "conftest",
srcs = glob(["**/conftest.py"]),
visibility = [
"//python/ray/_common/tests:__subpackages__",
],
deps = ["//python/ray/tests:conftest"],
)
# Small tests.
py_test_module_list(
size = "small",
files = [
"test_deprecation.py",
"test_filters.py",
"test_formatters.py",
"test_logging_constants.py",
"test_network_utils.py",
"test_ray_option_utils.py",
"test_retry.py",
"test_signal_semaphore_utils.py",
"test_signature.py",
"test_tls_utils.py",
"test_utils.py",
"test_wait_for_condition.py",
],
tags = [
"exclusive",
"team:core",
],
deps = [
":conftest",
"//:ray_lib",
],
)
py_test_module_list(
size = "large",
files = [
"test_usage_stats.py",
],
tags = [
"exclusive",
"team:core",
],
deps = [
":conftest",
"//:ray_lib",
],
)
+2
View File
@@ -0,0 +1,2 @@
# Imports for filters and formatters tests
pytest_plugins = ["ray.tests.conftest"]
@@ -0,0 +1,97 @@
import sys
from unittest.mock import patch
import pytest
from ray._common.deprecation import (
DEPRECATED_VALUE,
Deprecated,
deprecation_warning,
)
def test_deprecation_warning_warn():
with patch("ray._common.deprecation.logger.warning") as mock_warning:
deprecation_warning("old_feature", "new_feature")
mock_warning.assert_called_once()
args, _ = mock_warning.call_args
assert (
"DeprecationWarning: `old_feature` has been deprecated. Use `new_feature` instead."
in args[0]
)
def test_deprecation_warning_error():
with pytest.raises(ValueError) as excinfo:
deprecation_warning("old_feature", error=True)
assert "`old_feature` has been deprecated." in str(excinfo.value)
def test_deprecated_decorator_function():
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
"ray._common.deprecation.log_once"
) as mock_log_once:
mock_log_once.return_value = True
@Deprecated(old="old_func", new="new_func", error=False)
def old_func():
return "result"
result = old_func()
assert result == "result"
mock_warning.assert_called_once()
def test_deprecated_decorator_class():
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
"ray._common.deprecation.log_once"
) as mock_log_once:
mock_log_once.return_value = True
@Deprecated(old="OldClass", new="NewClass", error=False)
class OldClass:
pass
instance = OldClass()
assert isinstance(instance, OldClass)
mock_warning.assert_called_once()
def test_deprecated_decorator_method():
with patch("ray._common.deprecation.logger.warning") as mock_warning, patch(
"ray._common.deprecation.log_once"
) as mock_log_once:
mock_log_once.return_value = True
class MyClass:
@Deprecated(old="old_method", new="new_method", error=False)
def old_method(self):
return "method_result"
instance = MyClass()
result = instance.old_method()
assert result == "method_result"
mock_warning.assert_called_once()
def test_deprecated_decorator_error():
with patch("ray._common.deprecation.log_once") as mock_log_once:
mock_log_once.return_value = True
@Deprecated(old="old_func", error=True)
def old_func():
pass
with pytest.raises(ValueError):
old_func()
def test_deprecated_value_constant():
assert (
DEPRECATED_VALUE == -1
), f"DEPRECATED_VALUE should be -1, but got {DEPRECATED_VALUE}"
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+160
View File
@@ -0,0 +1,160 @@
import logging
import logging.config
import sys
from concurrent.futures import ThreadPoolExecutor
import pytest
import ray
from ray._common.filters import CoreContextFilter
class TestCoreContextFilter:
def test_driver_process(self, shutdown_only):
log_context = ["job_id", "worker_id", "node_id"]
filter = CoreContextFilter()
record = logging.makeLogRecord({})
assert filter.filter(record)
# Ray is not initialized so no context except PID which should be available
for attr in log_context:
assert not hasattr(record, attr)
# PID should be available even when Ray is not initialized
assert hasattr(record, "process")
assert hasattr(record, "_ray_timestamp_ns")
ray.init()
record = logging.makeLogRecord({})
assert filter.filter(record)
runtime_context = ray.get_runtime_context()
expected_values = {
"job_id": runtime_context.get_job_id(),
"worker_id": runtime_context.get_worker_id(),
"node_id": runtime_context.get_node_id(),
"process": record.process,
}
for attr in log_context:
assert hasattr(record, attr)
assert getattr(record, attr) == expected_values[attr]
# This is not a worker process, so actor_id and task_id should not exist.
for attr in ["actor_id", "task_id"]:
assert not hasattr(record, attr)
assert hasattr(record, "_ray_timestamp_ns")
def test_task_process(self, shutdown_only):
@ray.remote
def f():
filter = CoreContextFilter()
record = logging.makeLogRecord({})
assert filter.filter(record)
should_exist = ["job_id", "worker_id", "node_id", "task_id", "process"]
runtime_context = ray.get_runtime_context()
expected_values = {
"job_id": runtime_context.get_job_id(),
"worker_id": runtime_context.get_worker_id(),
"node_id": runtime_context.get_node_id(),
"task_id": runtime_context.get_task_id(),
"task_name": runtime_context.get_task_name(),
"task_func_name": runtime_context.get_task_function_name(),
"process": record.process,
}
for attr in should_exist:
assert hasattr(record, attr)
assert getattr(record, attr) == expected_values[attr]
assert not hasattr(record, "actor_id")
assert not hasattr(record, "actor_name")
assert hasattr(record, "_ray_timestamp_ns")
obj_ref = f.remote()
ray.get(obj_ref)
def test_actor_process(self, shutdown_only):
@ray.remote
class A:
def f(self):
filter = CoreContextFilter()
record = logging.makeLogRecord({})
assert filter.filter(record)
should_exist = [
"job_id",
"worker_id",
"node_id",
"actor_id",
"task_id",
"process",
]
runtime_context = ray.get_runtime_context()
expected_values = {
"job_id": runtime_context.get_job_id(),
"worker_id": runtime_context.get_worker_id(),
"node_id": runtime_context.get_node_id(),
"actor_id": runtime_context.get_actor_id(),
"actor_name": runtime_context.get_actor_name(),
"task_id": runtime_context.get_task_id(),
"task_name": runtime_context.get_task_name(),
"task_func_name": runtime_context.get_task_function_name(),
"process": record.process,
}
for attr in should_exist:
assert hasattr(record, attr)
assert getattr(record, attr) == expected_values[attr]
assert hasattr(record, "_ray_timestamp_ns")
# Record should not have the attribute with a value of an empty string.
assert runtime_context.get_actor_name() == ""
assert not hasattr(record, "actor_name")
actor = A.remote()
ray.get(actor.f.remote())
def test_actor_process_with_thread(self, shutdown_only):
@ray.remote
class MockedRayDataWorker:
def _check_log_record_in_thread(self):
filter = CoreContextFilter()
record = logging.makeLogRecord({})
assert filter.filter(record)
should_exist = [
"job_id",
"worker_id",
"node_id",
"actor_id",
"task_id",
"process",
]
runtime_context = ray.get_runtime_context()
expected_values = {
"job_id": runtime_context.get_job_id(),
"worker_id": runtime_context.get_worker_id(),
"node_id": runtime_context.get_node_id(),
"actor_id": runtime_context.get_actor_id(),
"task_id": runtime_context.get_task_id(),
"process": record.process,
}
for attr in should_exist:
assert hasattr(record, attr)
assert getattr(record, attr) == expected_values[attr]
assert hasattr(record, "_ray_timestamp_ns")
# Record should not have the attribute with a value of an empty string.
assert runtime_context.get_actor_name() == ""
assert not hasattr(record, "actor_name")
assert runtime_context.get_task_name() == ""
assert not hasattr(record, "task_name")
assert runtime_context.get_task_function_name() == ""
assert not hasattr(record, "task_function_name")
return record
def map(self):
with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(self._check_log_record_in_thread).result()
actor = MockedRayDataWorker.remote()
ray.get(actor.map.remote())
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+162
View File
@@ -0,0 +1,162 @@
import json
import logging
import logging.config
import sys
import pytest
from ray._common.formatters import JSONFormatter, TextFormatter
class TestJSONFormatter:
def test_empty_record(self, shutdown_only):
formatter = JSONFormatter()
record = logging.makeLogRecord({})
formatted = formatter.format(record)
record_dict = json.loads(formatted)
should_exist = [
"process",
"asctime",
"levelname",
"message",
"filename",
"lineno",
"timestamp_ns",
]
for key in should_exist:
assert key in record_dict
assert len(record_dict) == len(should_exist)
assert "exc_text" not in record_dict
def test_record_with_exception(self, shutdown_only):
formatter = JSONFormatter()
record = logging.makeLogRecord({})
try:
raise ValueError("test")
except ValueError:
record.exc_info = sys.exc_info()
formatted = formatter.format(record)
record_dict = json.loads(formatted)
should_exist = [
"process",
"asctime",
"levelname",
"message",
"filename",
"lineno",
"exc_text",
"timestamp_ns",
]
for key in should_exist:
assert key in record_dict
assert "Traceback (most recent call last):" in record_dict["exc_text"]
assert len(record_dict) == len(should_exist)
def test_record_with_user_provided_context(self, shutdown_only):
formatter = JSONFormatter()
record = logging.makeLogRecord({"user": "ray"})
formatted = formatter.format(record)
record_dict = json.loads(formatted)
should_exist = [
"process",
"asctime",
"levelname",
"message",
"filename",
"lineno",
"user",
"timestamp_ns",
]
for key in should_exist:
assert key in record_dict
assert record_dict["user"] == "ray"
assert len(record_dict) == len(should_exist)
assert "exc_text" not in record_dict
def test_record_with_flatten_keys_invalid_value(self, shutdown_only):
formatter = JSONFormatter()
record = logging.makeLogRecord({"ray_serve_extra_fields": "not_a_dict"})
with pytest.raises(ValueError):
formatter.format(record)
def test_record_with_flatten_keys_valid_dict(self, shutdown_only):
formatter = JSONFormatter()
record = logging.makeLogRecord(
{"ray_serve_extra_fields": {"key1": "value1", "key2": 2}}
)
formatted = formatter.format(record)
record_dict = json.loads(formatted)
should_exist = [
"process",
"asctime",
"levelname",
"message",
"filename",
"lineno",
"key1",
"key2",
"timestamp_ns",
]
for key in should_exist:
assert key in record_dict
assert record_dict["key1"] == "value1", record_dict
assert record_dict["key2"] == 2
assert "ray_serve_extra_fields" not in record_dict
assert len(record_dict) == len(should_exist)
assert "exc_text" not in record_dict
def test_record_with_valid_additional_log_standard_attrs(self, shutdown_only):
formatter = JSONFormatter()
formatter.set_additional_log_standard_attrs(["name"])
record = logging.makeLogRecord({})
formatted = formatter.format(record)
record_dict = json.loads(formatted)
should_exist = [
"process",
"asctime",
"levelname",
"message",
"filename",
"lineno",
"timestamp_ns",
"name",
]
for key in should_exist:
assert key in record_dict
assert len(record_dict) == len(should_exist)
class TestTextFormatter:
def test_record_with_user_provided_context(self):
formatter = TextFormatter()
record = logging.makeLogRecord({"user": "ray"})
formatted = formatter.format(record)
assert "user=ray" in formatted
def test_record_with_exception(self):
formatter = TextFormatter()
record = logging.LogRecord(
name="test_logger",
level=logging.INFO,
pathname="test.py",
lineno=1000,
msg="Test message",
args=None,
exc_info=None,
)
formatted = formatter.format(record)
for s in ["INFO", "Test message", "test.py:1000", "--"]:
assert s in formatted
def test_record_with_valid_additional_log_standard_attrs(self, shutdown_only):
formatter = TextFormatter()
formatter.set_additional_log_standard_attrs(["name"])
record = logging.makeLogRecord({})
formatted = formatter.format(record)
assert "name=" in formatted
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,83 @@
import sys
import pytest
from ray._common.logging_constants import (
LOGGER_FLATTEN_KEYS,
LOGRECORD_STANDARD_ATTRS,
LogKey,
)
def test_logrecord_standard_attrs_is_frozenset():
assert isinstance(LOGRECORD_STANDARD_ATTRS, frozenset)
def test_logrecord_standard_attrs_contains_standard_names():
expected = frozenset(
{
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"message",
"module",
"msecs",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"thread",
"threadName",
"taskName",
}
)
assert LOGRECORD_STANDARD_ATTRS == expected
def test_logrecord_standard_attrs_has_expected_size():
assert len(LOGRECORD_STANDARD_ATTRS) == 23
def test_logger_flatten_keys_is_set():
assert isinstance(LOGGER_FLATTEN_KEYS, set)
assert "ray_serve_extra_fields" in LOGGER_FLATTEN_KEYS
def test_logkey_is_enum():
from enum import Enum
assert issubclass(LogKey, Enum)
expected = {
"JOB_ID": "job_id",
"WORKER_ID": "worker_id",
"NODE_ID": "node_id",
"ACTOR_ID": "actor_id",
"TASK_ID": "task_id",
"ACTOR_NAME": "actor_name",
"TASK_NAME": "task_name",
"TASK_FUNCTION_NAME": "task_func_name",
"ASCTIME": "asctime",
"LEVELNAME": "levelname",
"MESSAGE": "message",
"FILENAME": "filename",
"LINENO": "lineno",
"EXC_TEXT": "exc_text",
"PROCESS": "process",
"TIMESTAMP_NS": "timestamp_ns",
}
actual = {member.name: member.value for member in LogKey}
assert actual == expected
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,17 @@
import sys
import pytest
from ray._common.network_utils import is_localhost
def test_is_localhost():
assert is_localhost("localhost")
assert is_localhost("127.0.0.1")
assert is_localhost("::1")
assert not is_localhost("8.8.8.8")
assert not is_localhost("2001:db8::1")
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,197 @@
import re
import sys
from unittest.mock import patch
import pytest
from ray._common.ray_option_utils import (
Option,
_check_deprecate_placement_group,
_counting_option,
_resource_option,
_validate_resource_quantity,
_validate_resources,
update_options,
validate_actor_options,
validate_task_options,
)
from ray.util.placement_group import PlacementGroup
class TestOptionValidation:
def test_option_validate(self):
opt = Option(
type_constraint=int, value_constraint=lambda v: "error" if v < 0 else None
)
opt.validate("test", 1)
with pytest.raises(TypeError):
opt.validate("test", "a")
with pytest.raises(ValueError, match="error"):
opt.validate("test", -1)
def test_counting_option(self):
# Test infinite counting option
opt_inf = _counting_option("test_inf", infinite=True)
opt_inf.validate("test_inf", 5)
opt_inf.validate("test_inf", 0)
opt_inf.validate("test_inf", -1) # Represents infinity
opt_inf.validate("test_inf", None)
with pytest.raises(ValueError):
opt_inf.validate("test_inf", -2)
with pytest.raises(TypeError):
opt_inf.validate("test_inf", 1.5)
# Test non-infinite counting option
opt_non_inf = _counting_option("test_non_inf", infinite=False)
opt_non_inf.validate("test_non_inf", 5)
opt_non_inf.validate("test_non_inf", 0)
opt_non_inf.validate("test_non_inf", None)
with pytest.raises(ValueError):
opt_non_inf.validate("test_non_inf", -1)
@patch("ray._raylet.RESOURCE_UNIT_SCALING", 10000)
@patch(
"ray._private.accelerators.get_all_accelerator_resource_names",
return_value={"GPU", "TPU"},
)
@patch("ray._private.accelerators.get_accelerator_manager_for_resource")
def test_validate_resource_quantity(self, mock_get_manager, mock_get_all_names):
# Valid cases
assert _validate_resource_quantity("CPU", 1) is None
assert _validate_resource_quantity("memory", 0) is None
assert _validate_resource_quantity("custom", 0.5) is None
# Invalid cases
err = _validate_resource_quantity("CPU", -1)
assert isinstance(err, str)
assert "cannot be negative" in err
err = _validate_resource_quantity("CPU", 0.00001)
assert isinstance(err, str)
assert "cannot go beyond 0.0001" in err
# Accelerator validation
mock_manager_instance = mock_get_manager.return_value
mock_manager_instance.validate_resource_request_quantity.return_value = (
False,
"mock error",
)
err = _validate_resource_quantity("GPU", 1.5)
assert isinstance(err, str)
assert "mock error" in err
mock_get_manager.assert_called_with("GPU")
mock_manager_instance.validate_resource_request_quantity.assert_called_with(1.5)
mock_manager_instance.validate_resource_request_quantity.return_value = (
True,
"",
)
assert _validate_resource_quantity("TPU", 1) is None
def test_resource_option(self):
opt = _resource_option("CPU")
opt.validate("CPU", 1)
opt.validate("CPU", 0.5)
opt.validate("CPU", None)
with pytest.raises(TypeError):
opt.validate("CPU", "1")
with pytest.raises(ValueError):
opt.validate("CPU", -1.0)
def test_validate_resources(self):
assert _validate_resources(None) is None
assert _validate_resources({"custom": 1}) is None
err = _validate_resources({"CPU": 1, "GPU": 1})
assert isinstance(err, str)
assert "Use the 'num_cpus' and 'num_gpus' keyword" in err
err = _validate_resources({"custom": -1})
assert isinstance(err, str)
assert "cannot be negative" in err
class TestTaskActorOptionValidation:
def test_validate_task_options_valid(self):
validate_task_options({"num_cpus": 2, "max_retries": 3}, in_options=False)
def test_validate_task_options_invalid_keyword(self):
with pytest.raises(ValueError, match="Invalid option keyword"):
validate_task_options({"invalid_option": 1}, in_options=False)
def test_validate_task_options_in_options_invalid(self):
with pytest.raises(
ValueError,
match=re.escape("Setting 'max_calls' is not supported in '.options()'."),
):
validate_task_options({"max_calls": 5}, in_options=True)
def test_validate_actor_options_valid(self):
validate_actor_options({"max_concurrency": 2, "name": "abc"}, in_options=False)
def test_validate_actor_options_invalid_keyword(self):
with pytest.raises(ValueError, match="Invalid option keyword"):
validate_actor_options({"invalid_option": 1}, in_options=False)
def test_validate_actor_options_in_options_invalid(self):
with pytest.raises(
ValueError,
match=re.escape(
"Setting 'concurrency_groups' is not supported in '.options()'."
),
):
validate_actor_options({"concurrency_groups": {}}, in_options=True)
def test_validate_actor_get_if_exists_no_name(self):
with pytest.raises(
ValueError, match="must be specified to use `get_if_exists`"
):
validate_actor_options({"get_if_exists": True}, in_options=False)
def test_validate_actor_object_store_memory_warning(self):
with pytest.warns(
DeprecationWarning,
match="Setting 'object_store_memory' for actors is deprecated",
):
validate_actor_options({"object_store_memory": 100}, in_options=False)
def test_check_deprecate_placement_group(self):
pg = PlacementGroup.empty()
# No error if only one is specified
_check_deprecate_placement_group({"placement_group": pg})
_check_deprecate_placement_group({"scheduling_strategy": "SPREAD"})
# Error if both are specified
with pytest.raises(
ValueError, match="Placement groups should be specified via"
):
_check_deprecate_placement_group(
{"placement_group": pg, "scheduling_strategy": "SPREAD"}
)
# Check no error with default or None placement_group
_check_deprecate_placement_group(
{"placement_group": "default", "scheduling_strategy": "SPREAD"}
)
_check_deprecate_placement_group(
{"placement_group": None, "scheduling_strategy": "SPREAD"}
)
class TestUpdateOptions:
def test_simple_update(self):
original = {"num_cpus": 1, "name": "a"}
new = {"num_cpus": 2, "num_gpus": 1}
updated = update_options(original, new)
assert updated == {"num_cpus": 2, "name": "a", "num_gpus": 1}
def test_update_with_empty_new(self):
original = {"num_cpus": 1}
updated = update_options(original, {})
assert updated == original
def test_update_empty_original(self):
new = {"num_cpus": 1}
updated = update_options({}, new)
assert updated == new
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+136
View File
@@ -0,0 +1,136 @@
import sys
import pytest
from ray._common.retry import (
call_with_retry,
retry,
)
def test_call_with_retry_immediate_success_with_args():
def func(a, b):
return [a, b]
assert call_with_retry(func, "func", [], 1, 0, "a", "b") == ["a", "b"]
def test_retry_immediate_success_with_object_args():
class MyClass:
@retry("func", [], 1, 0)
def func(self, a, b):
return [a, b]
assert MyClass().func("a", "b") == ["a", "b"]
@pytest.mark.parametrize("use_decorator", [True, False])
def test_retry_last_attempt_successful_with_appropriate_wait_time(
monkeypatch, use_decorator
):
sleep_total = 0
def sleep(x):
nonlocal sleep_total
sleep_total += x
monkeypatch.setattr("time.sleep", sleep)
monkeypatch.setattr("random.uniform", lambda a, b: 1)
pattern = "have not reached 4th attempt"
call_count = 0
def func():
nonlocal call_count
call_count += 1
if call_count == 4:
return "success"
raise ValueError(pattern)
args = ["func", [pattern], 4, 3]
if use_decorator:
assert retry(*args)(func)() == "success"
else:
assert call_with_retry(func, *args) == "success"
assert sleep_total == 6 # 1 + 2 + 3
@pytest.mark.parametrize("use_decorator", [True, False])
def test_retry_unretryable_error(use_decorator):
call_count = 0
def func():
nonlocal call_count
call_count += 1
raise ValueError("unretryable error")
args = ["func", ["only retryable error"], 10, 0]
with pytest.raises(ValueError, match="unretryable error"):
if use_decorator:
retry(*args)(func)()
else:
call_with_retry(func, *args)
assert call_count == 1
@pytest.mark.parametrize("use_decorator", [True, False])
def test_retry_fail_all_attempts_retry_all_errors(use_decorator):
call_count = 0
def func():
nonlocal call_count
call_count += 1
raise ValueError(str(call_count))
args = ["func", None, 3, 0]
with pytest.raises(ValueError):
if use_decorator:
retry(*args)(func)()
else:
call_with_retry(func, *args)
assert call_count == 3
def test_call_with_retry_matches_class_name():
"""Patterns can match the exception class name (e.g., 'RateLimit')."""
class RateLimitError(Exception):
pass
call_count = 0
def func():
nonlocal call_count
call_count += 1
raise RateLimitError("Error code: 429")
with pytest.raises(RateLimitError):
call_with_retry(func, "func", ["RateLimit"], 3, 0)
assert call_count == 3
@pytest.mark.parametrize(
"pattern,should_retry",
[
# Valid regex that is not a literal substring, that matches via regex search
(r"\d{3}", True),
# Invalid regex, re.error is handled by returning False and the error is not retried.
(r"[unclosed", False),
],
)
def test_call_with_retry_regex_matching(pattern, should_retry):
call_count = 0
def func():
nonlocal call_count
call_count += 1
raise ValueError("Error code: 429")
with pytest.raises(ValueError):
call_with_retry(func, "func", [pattern], 3, 0)
assert call_count == (3 if should_retry else 1)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,103 @@
"""Tests for Ray test utility classes.
This module contains pytest-based tests for SignalActor and Semaphore classes
from ray._common.test_utils. These test utility classes are used for coordination
and synchronization in Ray tests.
"""
import sys
import time
import pytest
import ray
from ray._common.test_utils import Semaphore, SignalActor, wait_for_condition
@pytest.fixture(scope="module")
def ray_init():
"""Initialize Ray for the test module."""
ray.init(num_cpus=4)
yield
ray.shutdown()
def test_signal_actor_basic(ray_init):
"""Test basic SignalActor functionality - send and wait operations."""
signal = SignalActor.remote()
# Test initial state
assert ray.get(signal.cur_num_waiters.remote()) == 0
# Test send and wait
ray.get(signal.send.remote())
signal.wait.remote()
assert ray.get(signal.cur_num_waiters.remote()) == 0
def test_signal_actor_multiple_waiters(ray_init):
"""Test SignalActor with multiple waiters and signal clearing."""
signal = SignalActor.remote()
# Create multiple waiters
for _ in range(3):
signal.wait.remote()
# Check number of waiters
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 3)
# Send signal and wait for all waiters
ray.get(signal.send.remote())
# Verify all waiters are done
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 0)
# check that .wait() doesn't block if the signal is already sent
ray.get(signal.wait.remote())
assert ray.get(signal.cur_num_waiters.remote()) == 0
# clear the signal
ray.get(signal.send.remote(clear=True))
signal.wait.remote()
# Verify all waiters are done
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
ray.get(signal.send.remote())
def test_semaphore_basic(ray_init):
"""Test basic Semaphore functionality - acquire, release, and lock status."""
sema = Semaphore.remote(value=2)
# Test initial state
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
# Test acquire and release
ray.get(sema.acquire.remote())
ray.get(sema.acquire.remote())
wait_for_condition(lambda: ray.get(sema.locked.remote()) is True)
ray.get(sema.release.remote())
ray.get(sema.release.remote())
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
def test_semaphore_concurrent(ray_init):
"""Test Semaphore with concurrent workers to verify resource limiting."""
sema = Semaphore.remote(value=2)
def worker():
ray.get(sema.acquire.remote())
time.sleep(0.1)
ray.get(sema.release.remote())
# Create multiple workers
_ = [worker() for _ in range(4)]
# Verify semaphore is not locked
wait_for_condition(lambda: ray.get(sema.locked.remote()) is False)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+515
View File
@@ -0,0 +1,515 @@
"""Tests for Ray signature utility functions.
This module contains pytest-based tests for signature-related functions in
ray._common.signature. These functions are used for extracting, validating,
and flattening function signatures for serialization.
"""
import inspect
import sys
from typing import Any, Optional
from unittest.mock import Mock, patch
import pytest
from ray._common.signature import (
DUMMY_TYPE,
extract_signature,
flatten_args,
get_signature,
recover_args,
validate_args,
)
class TestGetSignature:
"""Tests for the get_signature utility function."""
def test_regular_function(self):
"""Test getting signature from a regular Python function."""
def test_func(a, b=10, *args, **kwargs):
return a + b
sig = get_signature(test_func)
assert sig is not None
assert len(sig.parameters) == 4
assert "a" in sig.parameters
assert "b" in sig.parameters
assert sig.parameters["b"].default == 10
def test_function_with_annotations(self):
"""Test getting signature from a function with type annotations."""
def test_func(a: int, b: str = "default") -> str:
return f"{a}{b}"
sig = get_signature(test_func)
assert sig is not None
assert len(sig.parameters) == 2
assert sig.parameters["a"].annotation is int
assert sig.parameters["b"].annotation is str
assert sig.parameters["b"].default == "default"
def test_function_no_parameters(self):
"""Test getting signature from a function with no parameters."""
def test_func():
return "hello"
sig = get_signature(test_func)
assert sig is not None
assert len(sig.parameters) == 0
def test_lambda_function(self):
"""Test getting signature from a lambda function."""
sig = get_signature(lambda x, y=5: x + y)
assert sig is not None
assert len(sig.parameters) == 2 # x, y
assert sig.parameters["y"].default == 5
@patch("ray._common.signature.is_cython")
def test_cython_function_with_attributes(self, mock_is_cython):
"""Test getting signature from a Cython function with required attributes."""
mock_is_cython.return_value = True
def original_func(x=10):
return x
mock_func = Mock()
mock_func.__code__ = original_func.__code__
mock_func.__annotations__ = original_func.__annotations__
mock_func.__defaults__ = original_func.__defaults__
mock_func.__kwdefaults__ = original_func.__kwdefaults__
sig = get_signature(mock_func)
assert sig is not None
assert len(sig.parameters) == 1
assert "x" in sig.parameters
@patch("ray._common.signature.is_cython")
def test_cython_function_missing_attributes(self, mock_is_cython):
"""Test error handling for Cython function missing required attributes."""
mock_is_cython.return_value = True
# Create a mock Cython function missing required attributes
mock_func = Mock()
del mock_func.__code__ # Remove required attribute
with pytest.raises(TypeError, match="is not a Python function we can process"):
get_signature(mock_func)
def test_method_signature(self):
"""Test getting signature from a class method."""
class TestClass:
def test_method(self, a, b=20):
return a + b
sig = get_signature(TestClass.test_method)
assert sig is not None
assert len(sig.parameters) == 3 # self, a, b
assert "self" in sig.parameters
assert "a" in sig.parameters
assert "b" in sig.parameters
assert sig.parameters["b"].default == 20
class TestExtractSignature:
"""Tests for the extract_signature utility function."""
def test_function_without_ignore_first(self):
"""Test extracting signature from function without ignoring first parameter."""
def test_func(a, b=10, c=None):
return a + b
params = extract_signature(test_func, ignore_first=False)
assert len(params) == 3
assert params[0].name == "a"
assert params[1].name == "b"
assert params[1].default == 10
assert params[2].name == "c"
assert params[2].default is None
def test_method_with_ignore_first(self):
"""Test extracting signature from method ignoring 'self' parameter."""
class TestClass:
def test_method(self, a, b=20):
return a + b
params = extract_signature(TestClass.test_method, ignore_first=True)
assert len(params) == 2
assert params[0].name == "a"
assert params[1].name == "b"
assert params[1].default == 20
def test_function_with_ignore_first(self):
"""Test extracting signature from regular function with ignore_first=True."""
def test_func(x, y, z=30):
return x + y + z
params = extract_signature(test_func, ignore_first=True)
assert len(params) == 2
assert params[0].name == "y"
assert params[1].name == "z"
assert params[1].default == 30
def test_empty_parameters_with_ignore_first(self):
"""Test error handling when method has no parameters but ignore_first=True."""
def test_func():
return "hello"
with pytest.raises(ValueError, match="Methods must take a 'self' argument"):
extract_signature(test_func, ignore_first=True)
def test_single_parameter_with_ignore_first(self):
"""Test extracting signature from method with only 'self' parameter."""
class TestClass:
def test_method(self):
return "hello"
params = extract_signature(TestClass.test_method, ignore_first=True)
assert len(params) == 0
def test_varargs_and_kwargs(self):
"""Test extracting signature with *args and **kwargs."""
def test_func(a, b=10, *args, **kwargs):
return a + b
params = extract_signature(test_func, ignore_first=False)
assert len(params) == 4
assert params[0].name == "a"
assert params[1].name == "b"
assert params[2].name == "args"
assert params[2].kind == inspect.Parameter.VAR_POSITIONAL
assert params[3].name == "kwargs"
assert params[3].kind == inspect.Parameter.VAR_KEYWORD
class TestValidateArgs:
"""Tests for the validate_args utility function."""
def test_valid_positional_args(self):
"""Test validation with valid positional arguments."""
def test_func(a, b, c=30):
return a + b + c
params = extract_signature(test_func)
# Should not raise an exception
validate_args(params, (1, 2), {})
validate_args(params, (1, 2, 3), {})
def test_valid_keyword_args(self):
"""Test validation with valid keyword arguments."""
def test_func(a, b=20, c=30):
return a + b + c
params = extract_signature(test_func)
# Should not raise an exception
validate_args(params, (1,), {"b": 2})
validate_args(params, (1,), {"b": 2, "c": 3})
validate_args(params, (), {"a": 1, "b": 2, "c": 3})
def test_valid_mixed_args(self):
"""Test validation with mixed positional and keyword arguments."""
def test_func(a, b, c=30):
return a + b + c
params = extract_signature(test_func)
# Should not raise an exception
validate_args(params, (1,), {"b": 2})
validate_args(params, (1, 2), {"c": 3})
def test_too_many_positional_args(self):
"""Test error handling for too many positional arguments."""
def test_func(a, b):
return a + b
params = extract_signature(test_func)
with pytest.raises(TypeError):
validate_args(params, (1, 2, 3), {})
def test_missing_required_args(self):
"""Test error handling for missing required arguments."""
def test_func(a, b, c=30):
return a + b + c
params = extract_signature(test_func)
with pytest.raises(TypeError):
validate_args(params, (1,), {}) # Missing 'b'
def test_unexpected_keyword_args(self):
"""Test error handling for unexpected keyword arguments."""
def test_func(a, b):
return a + b
params = extract_signature(test_func)
with pytest.raises(TypeError):
validate_args(params, (1, 2), {"c": 3})
def test_duplicate_args(self):
"""Test error handling for duplicate arguments (positional and keyword)."""
def test_func(a, b, c=30):
return a + b + c
params = extract_signature(test_func)
with pytest.raises(TypeError):
validate_args(params, (1, 2), {"b": 3}) # 'b' specified twice
def test_varargs_validation(self):
"""Test validation with *args and **kwargs."""
def test_func(a, b=20, *args, **kwargs):
return a + b
params = extract_signature(test_func)
# Should not raise an exception
validate_args(params, (1, 2, 3, 4), {"extra": 5})
validate_args(params, (1,), {"b": 2, "extra": 3})
class TestFlattenArgs:
"""Tests for the flatten_args utility function."""
def test_only_positional_args(self):
"""Test flattening with only positional arguments."""
def test_func(a, b, c):
return a + b + c
params = extract_signature(test_func)
flattened = flatten_args(params, (1, 2, 3), {})
expected = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, DUMMY_TYPE, 3]
assert flattened == expected
def test_only_keyword_args(self):
"""Test flattening with only keyword arguments."""
def test_func(a=1, b=2, c=3):
return a + b + c
params = extract_signature(test_func)
flattened = flatten_args(params, (), {"a": 10, "b": 20, "c": 30})
expected = ["a", 10, "b", 20, "c", 30]
assert flattened == expected
def test_mixed_args(self):
"""Test flattening with mixed positional and keyword arguments."""
def test_func(a, b, c=30):
return a + b + c
params = extract_signature(test_func)
flattened = flatten_args(params, (1, 2), {"c": 3})
expected = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, "c", 3]
assert flattened == expected
def test_empty_args(self):
"""Test flattening with no arguments."""
def test_func():
return "hello"
params = extract_signature(test_func)
flattened = flatten_args(params, (), {})
assert flattened == []
def test_complex_types(self):
"""Test flattening with complex argument types."""
def test_func(a, b, c=None):
return a + b
params = extract_signature(test_func)
complex_args = ([1, 2, 3], {"key": "value"})
complex_kwargs = {"c": {"nested": "dict"}}
flattened = flatten_args(params, complex_args, complex_kwargs)
expected = [
DUMMY_TYPE,
[1, 2, 3],
DUMMY_TYPE,
{"key": "value"},
"c",
{"nested": "dict"},
]
assert flattened == expected
def test_invalid_args_raises_error(self):
"""Test that invalid arguments raise TypeError during flattening."""
def test_func(a, b):
return a + b
params = extract_signature(test_func)
with pytest.raises(TypeError):
flatten_args(params, (1, 2, 3), {}) # Too many args
class TestRecoverArgs:
"""Tests for the recover_args utility function."""
def test_only_positional_args(self):
"""Test recovering only positional arguments."""
flattened = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, DUMMY_TYPE, 3]
args, kwargs = recover_args(flattened)
assert args == [1, 2, 3]
assert kwargs == {}
def test_only_keyword_args(self):
"""Test recovering only keyword arguments."""
flattened = ["a", 10, "b", 20, "c", 30]
args, kwargs = recover_args(flattened)
assert args == []
assert kwargs == {"a": 10, "b": 20, "c": 30}
def test_mixed_args(self):
"""Test recovering mixed positional and keyword arguments."""
flattened = [DUMMY_TYPE, 1, DUMMY_TYPE, 2, "c", 3]
args, kwargs = recover_args(flattened)
assert args == [1, 2]
assert kwargs == {"c": 3}
def test_empty_flattened(self):
"""Test recovering from empty flattened list."""
flattened = []
args, kwargs = recover_args(flattened)
assert args == []
assert kwargs == {}
def test_complex_types(self):
"""Test recovering complex argument types."""
flattened = [
DUMMY_TYPE,
[1, 2, 3],
DUMMY_TYPE,
{"key": "value"},
"c",
{"nested": "dict"},
]
args, kwargs = recover_args(flattened)
assert args == [[1, 2, 3], {"key": "value"}]
assert kwargs == {"c": {"nested": "dict"}}
def test_invalid_odd_length(self):
"""Test error handling for odd-length flattened list."""
flattened = [DUMMY_TYPE, 1, "key"] # Odd length
with pytest.raises(
AssertionError, match="Flattened arguments need to be even-numbered"
):
recover_args(flattened)
def test_preserve_order(self):
"""Test that argument order is preserved during flatten/recover."""
def test_func(a, b, c, d, e):
return a + b + c + d + e
params = extract_signature(test_func)
original_args = (1, 2, 3)
original_kwargs = {"d": 4, "e": 5}
flattened = flatten_args(params, original_args, original_kwargs)
recovered_args, recovered_kwargs = recover_args(flattened)
assert recovered_args == [1, 2, 3]
assert recovered_kwargs == {"d": 4, "e": 5}
class TestIntegration:
"""Integration tests for signature utilities working together."""
def test_complete_workflow(self):
"""Test complete workflow from function to flatten/recover."""
def test_func(x: int, y: str = "default", z: Optional[Any] = None):
return f"{x}_{y}_{z}"
# Extract signature
params = extract_signature(test_func)
assert len(params) == 3
# Validate arguments
args = (42, "hello")
kwargs = {"z": [1, 2, 3]}
validate_args(params, args, kwargs)
# Flatten arguments
flattened = flatten_args(params, args, kwargs)
expected = [DUMMY_TYPE, 42, DUMMY_TYPE, "hello", "z", [1, 2, 3]]
assert flattened == expected
# Recover arguments
recovered_args, recovered_kwargs = recover_args(flattened)
assert recovered_args == list(args)
assert recovered_kwargs == kwargs
def test_method_workflow_with_ignore_first(self):
"""Test complete workflow for class methods with ignore_first=True."""
class TestClass:
def test_method(self, a: int, b: str = "test"):
return f"{a}_{b}"
# Extract signature ignoring 'self'
params = extract_signature(TestClass.test_method, ignore_first=True)
assert len(params) == 2
assert params[0].name == "a"
assert params[1].name == "b"
# Validate and flatten
args = (100,)
kwargs = {"b": "custom"}
validate_args(params, args, kwargs)
flattened = flatten_args(params, args, kwargs)
# Recover and verify
recovered_args, recovered_kwargs = recover_args(flattened)
assert recovered_args == list(args)
assert recovered_kwargs == kwargs
def test_varargs_kwargs_workflow(self):
"""Test workflow with functions that have *args and **kwargs."""
def test_func(a, b=10, *args, **kwargs):
return a + b + sum(args) + sum(kwargs.values())
params = extract_signature(test_func)
# Test with extra positional and keyword arguments
args = (1, 2, 3, 4, 5)
kwargs = {"extra1": 10, "extra2": 20}
validate_args(params, args, kwargs)
flattened = flatten_args(params, args, kwargs)
recovered_args, recovered_kwargs = recover_args(flattened)
assert recovered_args == list(args)
assert recovered_kwargs == kwargs
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,41 @@
import sys
import pytest
from ray._common.tls_utils import generate_self_signed_tls_certs
def test_generate_self_signed_tls_certs_returns_tuple():
cert_contents, key_contents = generate_self_signed_tls_certs()
assert isinstance(cert_contents, str)
assert isinstance(key_contents, str)
def test_generate_self_signed_tls_certs_pem_format():
cert_contents, key_contents = generate_self_signed_tls_certs()
assert cert_contents.strip().startswith("-----BEGIN CERTIFICATE-----")
assert cert_contents.strip().endswith("-----END CERTIFICATE-----")
assert key_contents.strip().startswith("-----BEGIN")
assert "PRIVATE KEY" in key_contents
def test_generate_self_signed_tls_certs_usable_for_ssl():
import ssl
import tempfile
cert_contents, key_contents = generate_self_signed_tls_certs()
with (
tempfile.NamedTemporaryFile(mode="w", suffix=".crt") as cf,
tempfile.NamedTemporaryFile(mode="w", suffix=".key") as kf,
):
cf.write(cert_contents)
cf.flush()
kf.write(key_contents)
kf.flush()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cf.name, kf.name)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
"""Tests for Ray utility functions.
This module contains pytest-based tests for utility functions in ray._common.utils.
Test utility classes (SignalActor, Semaphore) are in ray._common.test_utils to
ensure they're included in the Ray package distribution.
"""
import asyncio
import os
import sys
import tempfile
import warnings
import pytest
from ray._common.utils import (
_BACKGROUND_TASKS,
env_bool,
get_or_create_event_loop,
get_system_memory,
load_class,
run_background_task,
try_to_create_directory,
)
# Optional imports for testing
try:
import psutil
PSUTIL_AVAILABLE = True
except ImportError:
PSUTIL_AVAILABLE = False
class TestGetOrCreateEventLoop:
"""Tests for the get_or_create_event_loop utility function."""
def test_existing_event_loop(self):
# With running event loop
expect_loop = asyncio.new_event_loop()
expect_loop.set_debug(True)
asyncio.set_event_loop(expect_loop)
with warnings.catch_warnings():
# Assert no deprecating warnings raised for python>=3.10.
warnings.simplefilter("error")
actual_loop = get_or_create_event_loop()
assert actual_loop == expect_loop, "Loop should not be recreated."
def test_new_event_loop(self):
with warnings.catch_warnings():
# Assert no deprecating warnings raised for python>=3.10.
warnings.simplefilter("error")
loop = get_or_create_event_loop()
assert loop is not None, "new event loop should be created."
class TestEnvBool:
"""Tests for the env_bool utility function."""
_KEY = "_RAY_TEST_ENV_BOOL"
@pytest.mark.parametrize(
"env_value, expected",
[
("1", True),
("true", True),
("True", True),
("TRUE", True),
("0", False),
("false", False),
("False", False),
("FALSE", False),
("yes", False),
("no", False),
("", False),
],
)
def test_env_bool_values(self, env_value, expected, monkeypatch):
monkeypatch.setenv(self._KEY, env_value)
assert env_bool(self._KEY, False) is expected
def test_env_bool_default_when_unset(self, monkeypatch):
monkeypatch.delenv(self._KEY, raising=False)
assert env_bool(self._KEY, False) is False
assert env_bool(self._KEY, True) is True
@pytest.mark.asyncio
async def test_run_background_task():
"""Test the run_background_task utility function."""
result = {}
async def co():
result["start"] = 1
await asyncio.sleep(0)
result["end"] = 1
run_background_task(co())
# Background task is running.
assert len(_BACKGROUND_TASKS) == 1
# co executed.
await asyncio.sleep(0)
# await asyncio.sleep(0) from co is reached.
await asyncio.sleep(0)
# co finished and callback called.
await asyncio.sleep(0)
# The task should be removed from the set once it finishes.
assert len(_BACKGROUND_TASKS) == 0
assert result.get("start") == 1
assert result.get("end") == 1
class TestTryToCreateDirectory:
"""Tests for the try_to_create_directory utility function."""
def test_create_new_directory(self):
"""Test creating a new directory."""
with tempfile.TemporaryDirectory() as temp_dir:
test_dir = os.path.join(temp_dir, "test_dir")
try_to_create_directory(test_dir)
assert os.path.exists(test_dir), "Directory should be created"
assert os.path.isdir(test_dir), "Path should be a directory"
def test_existing_directory(self):
"""Test creating a directory that already exists."""
with tempfile.TemporaryDirectory() as temp_dir:
test_dir = os.path.join(temp_dir, "existing_dir")
# Create directory first
os.makedirs(test_dir)
# Should work without error
try_to_create_directory(test_dir)
assert os.path.exists(test_dir), "Directory should still exist"
def test_nested_directory_creation(self):
"""Test creating nested directory structure."""
with tempfile.TemporaryDirectory() as temp_dir:
nested_dir = os.path.join(temp_dir, "nested", "deep", "structure")
try_to_create_directory(nested_dir)
assert os.path.exists(nested_dir), "Nested directory should be created"
def test_tilde_expansion(self):
"""Test directory creation with tilde expansion."""
with tempfile.TemporaryDirectory() as temp_dir:
fake_home = os.path.join(temp_dir, "fake_home")
os.makedirs(fake_home, exist_ok=True)
# Mock the expanduser for this test
original_expanduser = os.path.expanduser
os.path.expanduser = (
lambda path: path.replace("~", fake_home)
if path.startswith("~")
else path
)
try:
tilde_dir = "~/test_tilde_dir"
try_to_create_directory(tilde_dir)
expected_path = os.path.join(fake_home, "test_tilde_dir")
assert os.path.exists(
expected_path
), "Tilde-expanded directory should be created"
finally:
# Restore original expanduser
os.path.expanduser = original_expanduser
class TestLoadClass:
"""Tests for the load_class utility function."""
def test_load_builtin_class(self):
"""Test loading a builtin class."""
list_class = load_class("builtins.list")
assert list_class is list, "Should load the builtin list class"
def test_load_module(self):
"""Test loading a module."""
path_module = load_class("os.path")
import os.path
assert path_module is os.path, "Should load os.path module"
def test_load_function(self):
"""Test loading a function from a module."""
makedirs_func = load_class("os.makedirs")
assert makedirs_func is os.makedirs, "Should load os.makedirs function"
def test_load_standard_library_class(self):
"""Test loading a standard library class."""
temp_dir_class = load_class("tempfile.TemporaryDirectory")
assert (
temp_dir_class is tempfile.TemporaryDirectory
), "Should load TemporaryDirectory class"
def test_load_nested_module_class(self):
"""Test loading a class from a nested module."""
datetime_class = load_class("datetime.datetime")
import datetime
assert (
datetime_class is datetime.datetime
), "Should load datetime.datetime class"
def test_invalid_path_error(self):
"""Test error handling for invalid paths."""
with pytest.raises(ValueError, match="valid path like mymodule.provider_class"):
load_class("invalid")
def test_nonexistent_module_error(self):
"""Test error handling for nonexistent modules."""
with pytest.raises((ImportError, ModuleNotFoundError)):
load_class("nonexistent_module.SomeClass")
def test_nonexistent_attribute_error(self):
"""Test error handling for nonexistent attributes."""
with pytest.raises(AttributeError):
load_class("os.NonexistentClass")
class TestGetSystemMemory:
"""Tests for the get_system_memory utility function."""
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
def test_cgroups_v1_with_low_limit(self):
"""Test cgroups v1 with low memory limit."""
with tempfile.NamedTemporaryFile("w") as memory_limit_file:
memory_limit_file.write("1073741824") # 1GB
memory_limit_file.flush()
memory = get_system_memory(
memory_limit_filename=memory_limit_file.name,
memory_limit_filename_v2="__does_not_exist__",
)
assert memory == 1073741824, "Should return cgroup limit when low"
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
def test_cgroups_v1_with_high_limit(self):
"""Test cgroups v1 with high memory limit (should fallback to psutil)."""
with tempfile.NamedTemporaryFile("w") as memory_limit_file:
memory_limit_file.write(str(2**63)) # Very high limit
memory_limit_file.flush()
psutil_memory = psutil.virtual_memory().total
memory = get_system_memory(
memory_limit_filename=memory_limit_file.name,
memory_limit_filename_v2="__does_not_exist__",
)
assert (
memory == psutil_memory
), "Should fallback to psutil when cgroup limit is very high"
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
def test_cgroups_v2_with_limit(self):
"""Test cgroups v2 with memory limit set."""
with tempfile.NamedTemporaryFile("w") as memory_max_file:
memory_max_file.write("2147483648\n") # 2GB with newline
memory_max_file.flush()
memory = get_system_memory(
memory_limit_filename="__does_not_exist__",
memory_limit_filename_v2=memory_max_file.name,
)
assert memory == 2147483648, "Should return cgroups v2 limit"
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
def test_cgroups_v2_unlimited(self):
"""Test cgroups v2 with unlimited memory (max)."""
with tempfile.NamedTemporaryFile("w") as memory_max_file:
memory_max_file.write("max")
memory_max_file.flush()
psutil_memory = psutil.virtual_memory().total
memory = get_system_memory(
memory_limit_filename="__does_not_exist__",
memory_limit_filename_v2=memory_max_file.name,
)
assert (
memory == psutil_memory
), "Should fallback to psutil when cgroups v2 is unlimited"
@pytest.mark.skipif(not PSUTIL_AVAILABLE, reason="psutil not available")
def test_no_cgroup_files(self):
"""Test fallback to psutil when no cgroup files exist."""
psutil_memory = psutil.virtual_memory().total
memory = get_system_memory(
memory_limit_filename="__does_not_exist__",
memory_limit_filename_v2="__also_does_not_exist__",
)
assert memory == psutil_memory, "Should use psutil when no cgroup files exist"
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,320 @@
import asyncio
import sys
import time
import pytest
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
class TestWaitForCondition:
"""Tests for the synchronous wait_for_condition function."""
def test_immediate_true_condition(self):
"""Test that function returns immediately when condition is already true."""
def always_true():
return True
wait_for_condition(always_true, timeout=5)
def test_condition_becomes_true(self):
"""Test waiting for a condition that becomes true after some time."""
counter = {"value": 0}
def condition():
counter["value"] += 1
return counter["value"] >= 3
wait_for_condition(condition, timeout=5, retry_interval_ms=50)
assert counter["value"] >= 3
def test_timeout_raises_runtime_error(self):
"""Test that timeout raises RuntimeError with appropriate message."""
def always_false():
return False
with pytest.raises(RuntimeError) as exc_info:
wait_for_condition(always_false, timeout=0.2, retry_interval_ms=50)
assert "condition wasn't met before the timeout expired" in str(exc_info.value)
def test_condition_with_kwargs(self):
"""Test passing kwargs to the condition predictor."""
def condition_with_args(target, current=0):
return current >= target
wait_for_condition(condition_with_args, timeout=1, target=5, current=10)
# Should not raise an exception since current >= target
def test_exception_handling_default(self):
"""Test that exceptions are caught by default and timeout occurs."""
def failing_condition():
raise ValueError("Test exception")
with pytest.raises(RuntimeError) as exc_info:
wait_for_condition(failing_condition, timeout=0.2, retry_interval_ms=50)
error_msg = str(exc_info.value)
assert "condition wasn't met before the timeout expired" in error_msg
assert "Last exception:" in error_msg
assert "ValueError: Test exception" in error_msg
def test_exception_handling_raise_true(self):
"""Test that exceptions are raised when raise_exceptions=True."""
def failing_condition():
raise ValueError("Test exception")
with pytest.raises(ValueError) as exc_info:
wait_for_condition(failing_condition, timeout=1, raise_exceptions=True)
assert "Test exception" in str(exc_info.value)
def test_custom_retry_interval(self):
"""Test that custom retry intervals are respected."""
call_times = []
def condition():
call_times.append(time.time())
return len(call_times) >= 3
wait_for_condition(condition, timeout=5, retry_interval_ms=200)
# Verify that calls were spaced approximately 200ms apart
if len(call_times) >= 2:
interval = call_times[1] - call_times[0]
assert 0.15 <= interval <= 0.25 # Allow some tolerance
def test_condition_with_mixed_results(self):
"""Test condition that fails initially then succeeds."""
attempts = {"count": 0}
def intermittent_condition():
attempts["count"] += 1
# Succeed on the 4th attempt
return attempts["count"] >= 4
wait_for_condition(intermittent_condition, timeout=2, retry_interval_ms=100)
assert attempts["count"] >= 4
class TestAsyncWaitForCondition:
"""Tests for the asynchronous async_wait_for_condition function."""
@pytest.mark.asyncio
async def test_immediate_true_condition(self):
"""Test that function returns immediately when condition is already true."""
def always_true():
return True
await async_wait_for_condition(always_true, timeout=5)
@pytest.mark.asyncio
async def test_async_condition_becomes_true(self):
"""Test waiting for an async condition that becomes true after some time."""
counter = {"value": 0}
async def async_condition():
counter["value"] += 1
await asyncio.sleep(0.01) # Small async operation
return counter["value"] >= 3
await async_wait_for_condition(async_condition, timeout=5, retry_interval_ms=50)
assert counter["value"] >= 3
@pytest.mark.asyncio
async def test_sync_condition_becomes_true(self):
"""Test waiting for a sync condition in async context."""
counter = {"value": 0}
def sync_condition():
counter["value"] += 1
return counter["value"] >= 3
await async_wait_for_condition(sync_condition, timeout=5, retry_interval_ms=50)
assert counter["value"] >= 3
@pytest.mark.asyncio
async def test_timeout_raises_runtime_error(self):
"""Test that timeout raises RuntimeError with appropriate message."""
def always_false():
return False
with pytest.raises(RuntimeError) as exc_info:
await async_wait_for_condition(
always_false, timeout=0.2, retry_interval_ms=50
)
assert "condition wasn't met before the timeout expired" in str(exc_info.value)
@pytest.mark.asyncio
async def test_condition_with_kwargs(self):
"""Test passing kwargs to the condition predictor."""
def condition_with_args(target, current=0):
return current >= target
await async_wait_for_condition(
condition_with_args, timeout=1, target=5, current=10
)
# Should not raise an exception since current >= target
@pytest.mark.asyncio
async def test_async_condition_with_kwargs(self):
"""Test passing kwargs to an async condition predictor."""
async def async_condition_with_args(target, current=0):
await asyncio.sleep(0.01)
return current >= target
await async_wait_for_condition(
async_condition_with_args, timeout=1, target=5, current=10
)
# Should not raise an exception since current >= target
@pytest.mark.asyncio
async def test_exception_handling(self):
"""Test that exceptions are caught and timeout occurs."""
def failing_condition():
raise ValueError("Test exception")
with pytest.raises(RuntimeError) as exc_info:
await async_wait_for_condition(
failing_condition, timeout=0.2, retry_interval_ms=50
)
error_msg = str(exc_info.value)
assert "condition wasn't met before the timeout expired" in error_msg
assert "Last exception:" in error_msg
@pytest.mark.asyncio
async def test_async_exception_handling(self):
"""Test that exceptions from async conditions are caught."""
async def async_failing_condition():
await asyncio.sleep(0.01)
raise ValueError("Async test exception")
with pytest.raises(RuntimeError) as exc_info:
await async_wait_for_condition(
async_failing_condition, timeout=0.2, retry_interval_ms=50
)
error_msg = str(exc_info.value)
assert "condition wasn't met before the timeout expired" in error_msg
assert "Last exception:" in error_msg
@pytest.mark.asyncio
async def test_custom_retry_interval(self):
"""Test that custom retry intervals are respected."""
call_times = []
def condition():
call_times.append(time.time())
return len(call_times) >= 3
await async_wait_for_condition(condition, timeout=5, retry_interval_ms=200)
# Verify that calls were spaced approximately 200ms apart
if len(call_times) >= 2:
interval = call_times[1] - call_times[0]
assert 0.15 <= interval <= 0.25 # Allow some tolerance
@pytest.mark.asyncio
async def test_mixed_sync_async_conditions(self):
"""Test that both sync and async conditions work in the same test."""
sync_counter = {"value": 0}
async_counter = {"value": 0}
def sync_condition():
sync_counter["value"] += 1
return sync_counter["value"] >= 2
async def async_condition():
async_counter["value"] += 1
await asyncio.sleep(0.01)
return async_counter["value"] >= 2
# Test sync condition
await async_wait_for_condition(sync_condition, timeout=2, retry_interval_ms=50)
assert sync_counter["value"] >= 2
# Test async condition
await async_wait_for_condition(async_condition, timeout=2, retry_interval_ms=50)
assert async_counter["value"] >= 2
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_zero_timeout(self):
"""Test behavior with zero timeout."""
def slow_condition():
time.sleep(0.1)
return True
with pytest.raises(RuntimeError):
wait_for_condition(slow_condition, timeout=0, retry_interval_ms=50)
@pytest.mark.asyncio
async def test_async_zero_timeout(self):
"""Test async behavior with zero timeout."""
async def slow_condition():
await asyncio.sleep(0.1)
return True
with pytest.raises(RuntimeError):
await async_wait_for_condition(
slow_condition, timeout=0, retry_interval_ms=50
)
def test_very_small_retry_interval(self):
"""Test with very small retry interval."""
counter = {"value": 0}
def condition():
counter["value"] += 1
return counter["value"] >= 5
start_time = time.time()
wait_for_condition(condition, timeout=1, retry_interval_ms=1)
elapsed = time.time() - start_time
# Should complete quickly due to small retry interval
assert elapsed < 0.5
assert counter["value"] >= 5
@pytest.mark.asyncio
async def test_async_very_small_retry_interval(self):
"""Test async version with very small retry interval."""
counter = {"value": 0}
def condition():
counter["value"] += 1
return counter["value"] >= 5
start_time = time.time()
await async_wait_for_condition(condition, timeout=1, retry_interval_ms=1)
elapsed = time.time() - start_time
# Should complete quickly due to small retry interval
assert elapsed < 0.5
assert counter["value"] >= 5
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+101
View File
@@ -0,0 +1,101 @@
"""TLS utilities shared across Ray libraries (e.g. Serve)."""
import datetime
import os
import socket
from typing import Tuple
from ray._common.network_utils import (
get_localhost_ip,
node_ip_address_from_perspective,
)
def generate_self_signed_tls_certs() -> Tuple[str, str]:
"""Create self-signed key/cert pair for testing.
Returns:
Tuple of (cert_pem_contents, key_pem_contents).
Raises:
ImportError: If the ``cryptography`` library is not installed.
"""
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
except ImportError as e:
raise ImportError(
"Using self-signed TLS certs requires `cryptography`. "
"Install it with: pip install cryptography"
) from e
key = rsa.generate_private_key(
public_exponent=65537, key_size=2048, backend=default_backend()
)
key_contents = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "ray-internal")])
altnames = x509.SubjectAlternativeName(
[
x509.DNSName(socket.gethostbyname(socket.gethostname())),
x509.DNSName(get_localhost_ip()),
x509.DNSName(node_ip_address_from_perspective()),
x509.DNSName("localhost"),
]
)
now = datetime.datetime.utcnow()
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.add_extension(altnames, critical=False)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=365))
.sign(key, hashes.SHA256(), default_backend())
)
cert_contents = cert.public_bytes(serialization.Encoding.PEM).decode()
return cert_contents, key_contents
def add_port_to_grpc_server(server, address):
import grpc
if os.environ.get("RAY_USE_TLS", "0").lower() in ("1", "true"):
server_cert_chain, private_key, ca_cert = load_certs_from_env()
credentials = grpc.ssl_server_credentials(
[(private_key, server_cert_chain)],
root_certificates=ca_cert,
require_client_auth=ca_cert is not None,
)
return server.add_secure_port(address, credentials)
else:
return server.add_insecure_port(address)
def load_certs_from_env():
tls_env_vars = ["RAY_TLS_SERVER_CERT", "RAY_TLS_SERVER_KEY", "RAY_TLS_CA_CERT"]
if any(v not in os.environ for v in tls_env_vars):
raise RuntimeError(
"If the environment variable RAY_USE_TLS is set to true "
"then RAY_TLS_SERVER_CERT, RAY_TLS_SERVER_KEY and "
"RAY_TLS_CA_CERT must also be set."
)
with open(os.environ["RAY_TLS_SERVER_CERT"], "rb") as f:
server_cert_chain = f.read()
with open(os.environ["RAY_TLS_SERVER_KEY"], "rb") as f:
private_key = f.read()
with open(os.environ["RAY_TLS_CA_CERT"], "rb") as f:
ca_cert = f.read()
return server_cert_chain, private_key, ca_cert
@@ -0,0 +1,63 @@
SCHEMA_VERSION = "0.1"
# The key to store / obtain cluster metadata.
CLUSTER_METADATA_KEY = b"CLUSTER_METADATA"
# The name of a json file where usage stats will be written.
USAGE_STATS_FILE = "usage_stats.json"
USAGE_STATS_ENABLED_ENV_VAR = "RAY_USAGE_STATS_ENABLED"
USAGE_STATS_SOURCE_ENV_VAR = "RAY_USAGE_STATS_SOURCE"
USAGE_STATS_SOURCE_OSS = "OSS"
USAGE_STATS_ENABLED_FOR_CLI_MESSAGE = (
"Usage stats collection is enabled. To disable this, add `--disable-usage-stats` "
"to the command that starts the cluster, or run the following command:"
" `ray disable-usage-stats` before starting the cluster. "
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
)
USAGE_STATS_ENABLED_FOR_RAY_INIT_MESSAGE = (
"Usage stats collection is enabled. To disable this, run the following command:"
" `ray disable-usage-stats` before starting Ray. "
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
)
USAGE_STATS_DISABLED_MESSAGE = "Usage stats collection is disabled."
USAGE_STATS_ENABLED_BY_DEFAULT_FOR_CLI_MESSAGE = (
"Usage stats collection is enabled by default without user confirmation "
"because this terminal is detected to be non-interactive. "
"To disable this, add `--disable-usage-stats` to the command that starts "
"the cluster, or run the following command:"
" `ray disable-usage-stats` before starting the cluster. "
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
)
USAGE_STATS_ENABLED_BY_DEFAULT_FOR_RAY_INIT_MESSAGE = (
"Usage stats collection is enabled by default for nightly wheels. "
"To disable this, run the following command:"
" `ray disable-usage-stats` before starting Ray. "
"See https://docs.ray.io/en/master/cluster/usage-stats.html for more details."
)
USAGE_STATS_CONFIRMATION_MESSAGE = (
"Enable usage stats collection? "
"This prompt will auto-proceed in 10 seconds to avoid blocking cluster startup."
)
LIBRARY_USAGE_SET_NAME = "library_usage_"
HARDWARE_USAGE_SET_NAME = "hardware_usage_"
# Keep in-sync with the same constants defined in usage_stats_client.h
EXTRA_USAGE_TAG_PREFIX = "extra_usage_tag_"
USAGE_STATS_NAMESPACE = "usage_stats"
KUBERNETES_SERVICE_HOST_ENV = "KUBERNETES_SERVICE_HOST"
KUBERAY_ENV = "RAY_USAGE_STATS_KUBERAY_IN_USE"
PROVIDER_KUBERNETES_GENERIC = "kubernetes"
PROVIDER_KUBERAY = "kuberay"
File diff suppressed because it is too large Load Diff
+491
View File
@@ -0,0 +1,491 @@
import asyncio
import binascii
import errno
import importlib
import inspect
import logging
import os
import random
import string
import sys
import tempfile
import time
from abc import ABC, abstractmethod
from inspect import signature
from types import ModuleType
from typing import Any, Coroutine, Dict, Optional, Tuple
import ray
from ray._raylet import GcsClient, NodeID
from ray.core.generated.gcs_pb2 import GcsNodeInfo
from ray.core.generated.gcs_service_pb2 import GetAllNodeInfoRequest
import psutil
logger = logging.getLogger(__name__)
def env_integer(key, default):
if key in os.environ:
value = os.environ[key]
try:
return int(value)
except ValueError:
logger.debug(
f"Found {key} in environment, but value must "
f"be an integer. Got: {value}. Returning "
f"provided default {default}."
)
return default
return default
def env_float(key, default):
if key in os.environ:
value = os.environ[key]
try:
return float(value)
except ValueError:
logger.debug(
f"Found {key} in environment, but value must "
f"be a float. Got: {value}. Returning "
f"provided default {default}."
)
return default
return default
def env_bool(key, default):
if key in os.environ:
val = os.environ[key].lower()
return val == "true" or val == "1"
return default
def import_module_and_attr(
full_path: str, *, reload_module: bool = False
) -> Tuple[ModuleType, Any]:
"""Given a full import path to a module attr, return the imported module and attr.
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
Args:
full_path: The full import path to the module and attr.
reload_module: Whether to reload the module.
Returns:
A tuple of the imported module and attr.
"""
if ":" in full_path:
if full_path.count(":") > 1:
raise ValueError(
f'Got invalid import path "{full_path}". An '
"import path may have at most one colon."
)
module_name, attr_name = full_path.split(":")
else:
last_period_idx = full_path.rfind(".")
module_name = full_path[:last_period_idx]
attr_name = full_path[last_period_idx + 1 :]
module = importlib.import_module(module_name)
if reload_module:
importlib.reload(module)
return module, getattr(module, attr_name)
def import_attr(full_path: str, *, reload_module: bool = False) -> Any:
"""Given a full import path to a module attr, return the imported attr.
If `reload_module` is set, the module will be reloaded using `importlib.reload`.
For example, the following are equivalent:
MyClass = import_attr("module.submodule:MyClass")
MyClass = import_attr("module.submodule.MyClass")
from module.submodule import MyClass
Args:
full_path: The full import path to the module and attr.
reload_module: Whether to reload the module.
Returns:
Imported attr
"""
return import_module_and_attr(full_path, reload_module=reload_module)[1]
def get_or_create_event_loop() -> asyncio.AbstractEventLoop:
"""Get a running async event loop if one exists, otherwise create one.
This function serves as a proxy for the deprecating get_event_loop().
It tries to get the running loop first, and if no running loop
could be retrieved:
- For python version <3.10: it falls back to the get_event_loop
call.
- For python version >= 3.10: it uses the same python implementation
of _get_event_loop() at asyncio/events.py.
Ideally, one should use high level APIs like asyncio.run() with python
version >= 3.7, if not possible, one should create and manage the event
loops explicitly.
"""
vers_info = sys.version_info
if vers_info.major >= 3 and vers_info.minor >= 10:
# This follows the implementation of the deprecating `get_event_loop`
# in python3.10's asyncio. See python3.10/asyncio/events.py
# _get_event_loop()
try:
loop = asyncio.get_running_loop()
assert loop is not None
return loop
except RuntimeError as e:
# No running loop, relying on the error message as for now to
# differentiate runtime errors.
assert "no running event loop" in str(e)
try:
loop = asyncio.get_event_loop_policy().get_event_loop()
return loop
except RuntimeError:
# Python 3.14+: get_event_loop() no longer creates a loop automatically
# See: https://docs.python.org/3.14/library/asyncio-eventloop.html
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
return asyncio.get_event_loop()
_BACKGROUND_TASKS = set()
def run_background_task(coroutine: Coroutine) -> asyncio.Task:
"""Schedule a task reliably to the event loop.
This API is used when you don't want to cache the reference of `asyncio.Task`.
For example,
```
get_event_loop().create_task(coroutine(*args))
```
The above code doesn't guarantee to schedule the coroutine to the event loops
When using create_task in a "fire and forget" way, we should keep the references
alive for the reliable execution. This API is used to fire and forget
asynchronous execution.
https://docs.python.org/3/library/asyncio-task.html#creating-tasks
"""
task = get_or_create_event_loop().create_task(coroutine)
# Add task to the set. This creates a strong reference.
_BACKGROUND_TASKS.add(task)
# To prevent keeping references to finished tasks forever,
# make each task remove its own reference from the set after
# completion:
task.add_done_callback(_BACKGROUND_TASKS.discard)
return task
# Used in gpu detection
RESOURCE_CONSTRAINT_PREFIX = "accelerator_type:"
PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME = "bundle"
def resources_from_ray_options(options_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Determine a task's resource requirements.
Args:
options_dict: The dictionary that contains resources requirements.
Returns:
A dictionary of the resource requirements for the task.
"""
resources = (options_dict.get("resources") or {}).copy()
if "CPU" in resources or "GPU" in resources:
raise ValueError(
"The resources dictionary must not contain the key 'CPU' or 'GPU'"
)
elif "memory" in resources or "object_store_memory" in resources:
raise ValueError(
"The resources dictionary must not "
"contain the key 'memory' or 'object_store_memory'"
)
elif PLACEMENT_GROUP_BUNDLE_RESOURCE_NAME in resources:
raise ValueError(
"The resource should not include `bundle` which "
f"is reserved for Ray. resources: {resources}"
)
num_cpus = options_dict.get("num_cpus")
num_gpus = options_dict.get("num_gpus")
memory = options_dict.get("memory")
object_store_memory = options_dict.get("object_store_memory")
accelerator_type = options_dict.get("accelerator_type")
if num_cpus is not None:
resources["CPU"] = num_cpus
if num_gpus is not None:
resources["GPU"] = num_gpus
if memory is not None:
resources["memory"] = int(memory)
if object_store_memory is not None:
resources["object_store_memory"] = object_store_memory
if accelerator_type is not None:
resources[f"{RESOURCE_CONSTRAINT_PREFIX}{accelerator_type}"] = 0.001
return resources
# Match the standard alphabet used for UUIDs.
RANDOM_STRING_ALPHABET = string.ascii_lowercase + string.digits
def get_random_alphanumeric_string(length: int):
"""Generates random string of length consisting exclusively of
- Lower-case ASCII chars
- Digits
"""
return "".join(random.choices(RANDOM_STRING_ALPHABET, k=length))
_PRINTED_WARNING = set()
def get_call_location(back: int = 1):
"""
Get the location (filename and line number) of a function caller, `back`
frames up the stack.
Args:
back: The number of frames to go up the stack, not including this
function.
Returns:
A string with the filename and line number of the caller.
For example, "myfile.py:123".
"""
stack = inspect.stack()
try:
frame = stack[back + 1]
return f"{frame.filename}:{frame.lineno}"
except IndexError:
return "UNKNOWN"
def resolve_user_ray_temp_dir(gcs_client: GcsClient, node_id: str):
"""
Get the ray temp directory.
If a temp dir was specified for this node, this function will
retrieve the information from GCS. Otherwise, it will fallback to the
default ray temp directory.
Args:
gcs_client: The GCS client.
node_id: The ID of the node to fetch the temp dir for.
E.g.: "1a9904d8aa3de65367830e2aef6313a5b2e9d4b0e3725e0dceeacb1b"
(hex string representation of the node ID)
Returns:
The path to the ray temp directory.
"""
# check if temp dir is available from runtime context
if ray.is_initialized() and ray.get_runtime_context().get_node_id() == node_id:
return ray.get_runtime_context().get_temp_dir()
# Fetch temp dir as specified by --temp-dir at creation time.
try:
# Create node selector for node_id filter
node_selector = GetAllNodeInfoRequest.NodeSelector()
node_selector.node_id = NodeID.from_hex(node_id).binary()
node_infos = gcs_client.get_all_node_info(
node_selectors=[node_selector],
state_filter=GcsNodeInfo.GcsNodeState.ALIVE,
).values()
except Exception as e:
raise Exception(
f"Failed to get node info from GCS when fetching tempdir for node {node_id}: {e}"
)
if not node_infos:
raise Exception(
f"No node info associated with ALIVE state found for node {node_id} in GCS"
)
node_info = next(iter(node_infos))
if node_info is not None:
temp_dir = getattr(node_info, "temp_dir", None)
if temp_dir is not None:
return temp_dir
else:
raise Exception(
"Node temp_dir was not found in NodeInfo. did the node's raylet start successfully?"
)
def get_default_system_temp_dir():
if "RAY_TMPDIR" in os.environ:
return os.environ["RAY_TMPDIR"]
elif sys.platform.startswith("linux") and "TMPDIR" in os.environ:
return os.environ["TMPDIR"]
elif sys.platform.startswith("darwin") or sys.platform.startswith("linux"):
# Ideally we wouldn't need this fallback, but keep it for now for
# for compatibility
tempdir = os.path.join(os.sep, "tmp")
else:
tempdir = tempfile.gettempdir()
return tempdir
def get_default_ray_temp_dir():
return os.path.join(get_default_system_temp_dir(), "ray")
def get_ray_address_file(temp_dir: Optional[str]):
if temp_dir is None:
temp_dir = get_default_ray_temp_dir()
return os.path.join(temp_dir, "ray_current_cluster")
def reset_ray_address(temp_dir: Optional[str] = None):
address_file = get_ray_address_file(temp_dir)
if os.path.exists(address_file):
try:
os.remove(address_file)
except OSError:
pass
def load_class(path):
"""Load a class at runtime given a full path.
Example of the path: mypkg.mysubpkg.myclass
"""
class_data = path.split(".")
if len(class_data) < 2:
raise ValueError("You need to pass a valid path like mymodule.provider_class")
module_path = ".".join(class_data[:-1])
class_str = class_data[-1]
module = importlib.import_module(module_path)
return getattr(module, class_str)
def get_system_memory(
# For cgroups v1:
memory_limit_filename: str = "/sys/fs/cgroup/memory/memory.limit_in_bytes",
# For cgroups v2:
memory_limit_filename_v2: str = "/sys/fs/cgroup/memory.max",
):
"""Return the total amount of system memory in bytes.
Args:
memory_limit_filename: The path to the file that contains the memory
limit for the Docker container. Defaults to
/sys/fs/cgroup/memory/memory.limit_in_bytes.
memory_limit_filename_v2: The path to the file that contains the memory
limit for the Docker container in cgroups v2. Defaults to
/sys/fs/cgroup/memory.max.
Returns:
The total amount of system memory in bytes.
"""
# Try to accurately figure out the memory limit if we are in a docker
# container. Note that this file is not specific to Docker and its value is
# often much larger than the actual amount of memory.
docker_limit = None
if os.path.exists(memory_limit_filename):
with open(memory_limit_filename, "r") as f:
docker_limit = int(f.read().strip())
elif os.path.exists(memory_limit_filename_v2):
with open(memory_limit_filename_v2, "r") as f:
# Don't forget to strip() the newline:
max_file = f.read().strip()
if max_file.isnumeric():
docker_limit = int(max_file)
else:
# max_file is "max", i.e. is unset.
docker_limit = None
# Use psutil if it is available.
psutil_memory_in_bytes = psutil.virtual_memory().total
if docker_limit is not None:
# We take the min because the cgroup limit is very large if we aren't
# in Docker.
return min(docker_limit, psutil_memory_in_bytes)
return psutil_memory_in_bytes
def binary_to_hex(identifier):
hex_identifier = binascii.hexlify(identifier)
hex_identifier = hex_identifier.decode()
return hex_identifier
def hex_to_binary(hex_identifier):
return binascii.unhexlify(hex_identifier)
def try_make_directory_shared(directory_path):
try:
os.chmod(directory_path, 0o0777)
except OSError as e:
# Silently suppress the PermissionError that is thrown by the chmod.
# This is done because the user attempting to change the permissions
# on a directory may not own it. The chmod is attempted whether the
# directory is new or not to avoid race conditions.
# ray-project/ray/#3591
if e.errno in [errno.EACCES, errno.EPERM]:
pass
else:
raise
def try_to_create_directory(directory_path):
# Attempt to create a directory that is globally readable/writable.
directory_path = os.path.expanduser(directory_path)
os.makedirs(directory_path, exist_ok=True)
# Change the log directory permissions so others can use it. This is
# important when multiple people are using the same machine.
try_make_directory_shared(directory_path)
def get_function_args(callable):
all_parameters = frozenset(signature(callable).parameters)
return list(all_parameters)
def decode(byte_str: str, allow_none: bool = False, encode_type: str = "utf-8"):
"""Make this unicode in Python 3, otherwise leave it as bytes.
Args:
byte_str: The byte string to decode.
allow_none: If true, then we will allow byte_str to be None in which
case we will return an empty string. TODO(rkn): Remove this flag.
This is only here to simplify upgrading to flatbuffers 1.10.0.
encode_type: The encoding type to use for decoding. Defaults to "utf-8".
Returns:
A byte string in Python 2 and a unicode string in Python 3.
"""
if byte_str is None and allow_none:
return ""
if not isinstance(byte_str, bytes):
raise ValueError(f"The argument {byte_str} must be a bytes object.")
return byte_str.decode(encode_type)
class TimerBase(ABC):
@abstractmethod
def time(self) -> float:
"""Return the current time."""
raise NotImplementedError
class Timer(TimerBase):
def time(self) -> float:
return time.time()