[runtime_env] Make pip installs incremental (#20341)

Uses a direct `pip install` instead of creating a conda env to make pip installs incremental to the cluster environment.

Separates the handling of `pip` and `conda` dependencies.

The new `pip` approach still works if only the base Ray is installed on the cluster and the user specifies libraries like "ray[serve]" in the `pip` field.  The mechanism is as follows:
- We don't actually want to reinstall ray via pip, since this could lead to version mismatch issues.  Instead, we want to use the Ray that's already installed in the cluster.
- So if "ray" was included by the user in the pip list, remove it
- If a library "ray[serve]" or "ray[tune, rllib]" was included in the pip list, remove it and replace it by its dependencies (e.g. "uvicorn", "requests", ..)

Co-authored-by: architkulkarni <arkulkar@gmail.com>
Co-authored-by: architkulkarni <architkulkarni@users.noreply.github.com>
This commit is contained in:
Edward Oakes 2021-12-14 15:55:18 -08:00 committed by GitHub
parent 57cc76cf5e
commit 10947c83b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 374 additions and 118 deletions

View file

@ -183,7 +183,6 @@ test_python() {
-python/ray/tests:test_runtime_env_plugin # runtime_env not supported on Windows -python/ray/tests:test_runtime_env_plugin # runtime_env not supported on Windows
-python/ray/tests:test_runtime_env_env_vars # runtime_env not supported on Windows -python/ray/tests:test_runtime_env_env_vars # runtime_env not supported on Windows
-python/ray/tests:test_runtime_env_complicated # conda install slow leading to timeout -python/ray/tests:test_runtime_env_complicated # conda install slow leading to timeout
-python/ray/tests:test_runtime_env_conda # conda not supported on Windows
-python/ray/tests:test_stress # timeout -python/ray/tests:test_stress # timeout
-python/ray/tests:test_stress_sharded # timeout -python/ray/tests:test_stress_sharded # timeout
-python/ray/tests:test_k8s_operator_unit_tests -python/ray/tests:test_k8s_operator_unit_tests

View file

@ -17,6 +17,7 @@ import ray.dashboard.modules.runtime_env.runtime_env_consts \
from ray.experimental.internal_kv import _internal_kv_initialized, \ from ray.experimental.internal_kv import _internal_kv_initialized, \
_initialize_internal_kv _initialize_internal_kv
from ray._private.ray_logging import setup_component_logger from ray._private.ray_logging import setup_component_logger
from ray._private.runtime_env.pip import PipManager
from ray._private.runtime_env.conda import CondaManager from ray._private.runtime_env.conda import CondaManager
from ray._private.runtime_env.context import RuntimeEnvContext from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.py_modules import PyModulesManager from ray._private.runtime_env.py_modules import PyModulesManager
@ -68,6 +69,7 @@ class RuntimeEnvAgent(dashboard_utils.DashboardAgentModule,
_initialize_internal_kv(self._dashboard_agent.gcs_client) _initialize_internal_kv(self._dashboard_agent.gcs_client)
assert _internal_kv_initialized() assert _internal_kv_initialized()
self._pip_manager = PipManager(self._runtime_env_dir)
self._conda_manager = CondaManager(self._runtime_env_dir) self._conda_manager = CondaManager(self._runtime_env_dir)
self._py_modules_manager = PyModulesManager(self._runtime_env_dir) self._py_modules_manager = PyModulesManager(self._runtime_env_dir)
self._working_dir_manager = WorkingDirManager(self._runtime_env_dir) self._working_dir_manager = WorkingDirManager(self._runtime_env_dir)
@ -100,6 +102,8 @@ class RuntimeEnvAgent(dashboard_utils.DashboardAgentModule,
per_job_logger.debug(f"Worker has resource :" per_job_logger.debug(f"Worker has resource :"
f"{allocated_resource}") f"{allocated_resource}")
context = RuntimeEnvContext(env_vars=runtime_env.env_vars()) context = RuntimeEnvContext(env_vars=runtime_env.env_vars())
self._pip_manager.setup(
runtime_env, context, logger=per_job_logger)
self._conda_manager.setup( self._conda_manager.setup(
runtime_env, context, logger=per_job_logger) runtime_env, context, logger=per_job_logger)
self._py_modules_manager.setup( self._py_modules_manager.setup(
@ -120,6 +124,9 @@ class RuntimeEnvAgent(dashboard_utils.DashboardAgentModule,
if runtime_env.conda_uri(): if runtime_env.conda_uri():
uri = runtime_env.conda_uri() uri = runtime_env.conda_uri()
self._uris_to_envs[uri].add(serialized_runtime_env) self._uris_to_envs[uri].add(serialized_runtime_env)
if runtime_env.pip_uri():
uri = runtime_env.pip_uri()
self._uris_to_envs[uri].add(serialized_runtime_env)
if runtime_env.plugin_uris(): if runtime_env.plugin_uris():
for uri in runtime_env.plugin_uris(): for uri in runtime_env.plugin_uris():
self._uris_to_envs[uri].add(serialized_runtime_env) self._uris_to_envs[uri].add(serialized_runtime_env)
@ -226,6 +233,9 @@ class RuntimeEnvAgent(dashboard_utils.DashboardAgentModule,
elif plugin == "conda": elif plugin == "conda":
if not self._conda_manager.delete_uri(uri): if not self._conda_manager.delete_uri(uri):
failed_uris.append(uri) failed_uris.append(uri)
elif plugin == "pip":
if not self._pip_manager.delete_uri(uri):
failed_uris.append(uri)
else: else:
raise ValueError( raise ValueError(
"RuntimeEnvAgent received DeleteURI request " "RuntimeEnvAgent received DeleteURI request "

View file

@ -25,21 +25,33 @@ from ray._private.runtime_env.packaging import Protocol, parse_uri
default_logger = logging.getLogger(__name__) default_logger = logging.getLogger(__name__)
def _resolve_current_ray_path(): def _resolve_current_ray_path() -> str:
# When ray is built from source with pip install -e, # When ray is built from source with pip install -e,
# ray.__file__ returns .../python/ray/__init__.py. # ray.__file__ returns .../python/ray/__init__.py and this function returns
# When ray is installed from a prebuilt binary, it returns # ".../python".
# .../site-packages/ray/__init__.py # When ray is installed from a prebuilt binary, ray.__file__ returns
# .../site-packages/ray/__init__.py and this function returns
# ".../site-packages".
return os.path.split(os.path.split(ray.__file__)[0])[0] return os.path.split(os.path.split(ray.__file__)[0])[0]
def _resolve_install_from_source_ray_dependencies(): def _get_ray_setup_spec():
"""Find the ray dependencies when Ray is install from source""" """Find the Ray setup_spec from the currently running Ray.
This function works even when Ray is built from source with pip install -e.
"""
ray_source_python_path = _resolve_current_ray_path() ray_source_python_path = _resolve_current_ray_path()
setup_py_path = os.path.join(ray_source_python_path, "setup.py") setup_py_path = os.path.join(ray_source_python_path, "setup.py")
ray_install_requires = runpy.run_path(setup_py_path)[ return runpy.run_path(setup_py_path)["setup_spec"]
"setup_spec"].install_requires
return ray_install_requires
def _resolve_install_from_source_ray_dependencies():
"""Find the Ray dependencies when Ray is installed from source."""
return _get_ray_setup_spec().install_requires
def _resolve_install_from_source_ray_extras() -> Dict[str, List[str]]:
return _get_ray_setup_spec().extras
def _inject_ray_to_conda_site( def _inject_ray_to_conda_site(
@ -69,43 +81,6 @@ def _current_py_version():
return ".".join(map(str, sys.version_info[:3])) # like 3.6.10 return ".".join(map(str, sys.version_info[:3])) # like 3.6.10
def get_conda_dict(runtime_env, resources_dir) -> Optional[Dict[Any, Any]]:
""" Construct a conda dependencies dict from a runtime env.
This function does not inject Ray or Python into the conda dict.
If the runtime env does not specify pip or conda, or if it specifies
the name of a preinstalled conda environment, this function returns
None. If pip is specified, a conda dict is created containing the
pip dependencies. If conda is already given as a dict, this function
is the identity function.
"""
if runtime_env.has_conda():
if runtime_env.conda_config():
return json.loads(runtime_env.conda_config())
else:
return None
if runtime_env.has_pip():
requirements_txt = "\n".join(runtime_env.pip_packages()) + "\n"
pip_hash = hashlib.sha1(requirements_txt.encode("utf-8")).hexdigest()
pip_hash_str = f"pip-generated-{pip_hash}"
requirements_txt_path = os.path.join(
resources_dir, f"requirements-{pip_hash_str}.txt")
conda_dict = {
"name": pip_hash_str,
"dependencies": ["pip", {
"pip": [f"-r {requirements_txt_path}"]
}]
}
file_lock_name = f"ray-{pip_hash_str}.lock"
with FileLock(os.path.join(resources_dir, file_lock_name)):
try_to_create_directory(resources_dir)
with open(requirements_txt_path, "w") as file:
file.write(requirements_txt)
return conda_dict
return None
def current_ray_pip_specifier( def current_ray_pip_specifier(
logger: Optional[logging.Logger] = default_logger) -> Optional[str]: logger: Optional[logging.Logger] = default_logger) -> Optional[str]:
"""The pip requirement specifier for the running version of Ray. """The pip requirement specifier for the running version of Ray.
@ -199,16 +174,9 @@ def _get_conda_env_hash(conda_dict: Dict) -> str:
return hash return hash
def _get_pip_hash(pip_list: List[str]) -> str:
serialized_pip_spec = json.dumps(pip_list)
hash = hashlib.sha1(serialized_pip_spec.encode("utf-8")).hexdigest()
return hash
def get_uri(runtime_env: Dict) -> Optional[str]: def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"conda://<hashed_dependencies>"`, or None if no GC required.""" """Return `"conda://<hashed_dependencies>"`, or None if no GC required."""
conda = runtime_env.get("conda") conda = runtime_env.get("conda")
pip = runtime_env.get("pip")
if conda is not None: if conda is not None:
if isinstance(conda, str): if isinstance(conda, str):
# User-preinstalled conda env. We don't garbage collect these, so # User-preinstalled conda env. We don't garbage collect these, so
@ -219,12 +187,6 @@ def get_uri(runtime_env: Dict) -> Optional[str]:
else: else:
raise TypeError("conda field received by RuntimeEnvAgent must be " raise TypeError("conda field received by RuntimeEnvAgent must be "
f"str or dict, not {type(conda).__name__}.") f"str or dict, not {type(conda).__name__}.")
elif pip is not None:
if isinstance(pip, list):
uri = "conda://" + _get_pip_hash(pip_list=pip)
else:
raise TypeError("pip field received by RuntimeEnvAgent must be "
f"list, not {type(pip).__name__}.")
else: else:
uri = None uri = None
return uri return uri
@ -233,8 +195,7 @@ def get_uri(runtime_env: Dict) -> Optional[str]:
class CondaManager: class CondaManager:
def __init__(self, resources_dir: str): def __init__(self, resources_dir: str):
self._resources_dir = os.path.join(resources_dir, "conda") self._resources_dir = os.path.join(resources_dir, "conda")
if not os.path.isdir(self._resources_dir): try_to_create_directory(self._resources_dir)
os.makedirs(self._resources_dir)
self._created_envs: Set[str] = set() self._created_envs: Set[str] = set()
def _get_path_from_hash(self, hash: str) -> str: def _get_path_from_hash(self, hash: str) -> str:
@ -252,7 +213,7 @@ class CondaManager:
def delete_uri(self, def delete_uri(self,
uri: str, uri: str,
logger: Optional[logging.Logger] = default_logger) -> bool: logger: Optional[logging.Logger] = default_logger) -> bool:
logger.debug(f"Got request to delete URI {uri}") logger.info(f"Got request to delete URI {uri}")
protocol, hash = parse_uri(uri) protocol, hash = parse_uri(uri)
if protocol != Protocol.CONDA: if protocol != Protocol.CONDA:
raise ValueError( raise ValueError(
@ -263,14 +224,14 @@ class CondaManager:
self._created_envs.remove(conda_env_path) self._created_envs.remove(conda_env_path)
successful = delete_conda_env(prefix=conda_env_path, logger=logger) successful = delete_conda_env(prefix=conda_env_path, logger=logger)
if not successful: if not successful:
logger.debug(f"Error when deleting conda env {conda_env_path}. ") logger.warning(f"Error when deleting conda env {conda_env_path}. ")
return successful return successful
def setup(self, def setup(self,
runtime_env: RuntimeEnv, runtime_env: RuntimeEnv,
context: RuntimeEnvContext, context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger): logger: Optional[logging.Logger] = default_logger):
if not runtime_env.has_conda() and not runtime_env.has_pip(): if not runtime_env.has_conda():
return return
logger.debug("Setting up conda or pip for runtime_env: " logger.debug("Setting up conda or pip for runtime_env: "
@ -279,7 +240,7 @@ class CondaManager:
if runtime_env.conda_env_name(): if runtime_env.conda_env_name():
conda_env_name = runtime_env.conda_env_name() conda_env_name = runtime_env.conda_env_name()
else: else:
conda_dict = get_conda_dict(runtime_env, self._resources_dir) conda_dict = json.loads(runtime_env.conda_config())
protocol, hash = parse_uri(runtime_env.conda_uri()) protocol, hash = parse_uri(runtime_env.conda_uri())
conda_env_name = self._get_path_from_hash(hash) conda_env_name = self._get_path_from_hash(hash)
assert conda_dict is not None assert conda_dict is not None

View file

@ -40,6 +40,7 @@ class Protocol(Enum):
GCS = "gcs", "For packages dynamically uploaded and managed by the GCS." GCS = "gcs", "For packages dynamically uploaded and managed by the GCS."
CONDA = "conda", "For conda environments installed locally on each node." CONDA = "conda", "For conda environments installed locally on each node."
PIP = "pip", "For pip environments installed locally on each node."
HTTPS = "https", ("Remote https path, " HTTPS = "https", ("Remote https path, "
"assumes everything packed in one zip file.") "assumes everything packed in one zip file.")
S3 = "s3", "Remote s3 path, assumes everything packed in one zip file." S3 = "s3", "Remote s3 path, assumes everything packed in one zip file."

View file

@ -0,0 +1,118 @@
import os
import json
import logging
import hashlib
import shutil
from pathlib import Path
from typing import Optional, List, Dict
from ray._private.runtime_env.conda_utils import exec_cmd_stream_to_logger
from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import Protocol, parse_uri
from ray._private.runtime_env.utils import RuntimeEnv
from ray._private.utils import try_to_create_directory
default_logger = logging.getLogger(__name__)
RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP = "RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP"
def _get_pip_hash(pip_list: List[str]) -> str:
serialized_pip_spec = json.dumps(pip_list, sort_keys=True)
hash = hashlib.sha1(serialized_pip_spec.encode("utf-8")).hexdigest()
return hash
def _install_pip_list_to_dir(
pip_list: List[str],
target_dir: str,
logger: Optional[logging.Logger] = default_logger):
try_to_create_directory(target_dir)
exit_code, output = exec_cmd_stream_to_logger(
["pip", "install", f"--target={target_dir}"] + pip_list, logger)
if exit_code != 0:
shutil.rmtree(target_dir)
raise RuntimeError(f"Failed to install pip requirements:\n{output}")
def get_uri(runtime_env: Dict) -> Optional[str]:
"""Return `"pip://<hashed_dependencies>"`, or None if no GC required."""
pip = runtime_env.get("pip")
if pip is not None:
if isinstance(pip, list):
uri = "pip://" + _get_pip_hash(pip_list=pip)
else:
raise TypeError("pip field received by RuntimeEnvAgent must be "
f"list, not {type(pip).__name__}.")
else:
uri = None
return uri
class PipManager:
def __init__(self, resources_dir: str):
self._resources_dir = os.path.join(resources_dir, "pip")
try_to_create_directory(self._resources_dir)
def _get_path_from_hash(self, hash: str) -> str:
"""Generate a path from the hash of a pip spec.
Example output:
/tmp/ray/session_2021-11-03_16-33-59_356303_41018/runtime_resources
/pip/ray-9a7972c3a75f55e976e620484f58410c920db091
"""
return os.path.join(self._resources_dir, hash)
def delete_uri(self,
uri: str,
logger: Optional[logging.Logger] = default_logger) -> bool:
logger.info(f"Got request to delete URI {uri}")
protocol, hash = parse_uri(uri)
if protocol != Protocol.PIP:
raise ValueError("PipManager can only delete URIs with protocol "
f"pip. Received protocol {protocol}, URI {uri}")
pip_env_path = self._get_path_from_hash(hash)
try:
shutil.rmtree(pip_env_path)
successful = True
except OSError:
successful = False
logger.warning(f"Error when deleting pip env {pip_env_path}.")
return successful
def setup(self,
runtime_env: RuntimeEnv,
context: RuntimeEnvContext,
logger: Optional[logging.Logger] = default_logger):
if not runtime_env.has_pip():
return
logger.debug(f"Setting up pip for runtime_env: {runtime_env}")
pip_packages: List[str] = runtime_env.pip_packages()
target_dir = self._get_path_from_hash(_get_pip_hash(pip_packages))
_install_pip_list_to_dir(pip_packages, target_dir, logger=logger)
# Despite Ray being removed from the input pip list during validation,
# other packages in the pip list (for example, xgboost_ray) may
# themselves include Ray as a dependency. In this case, we will have
# inadvertently installed the latest Ray version in the target_dir,
# which may cause Ray version mismatch issues. Uninstall it here, if it
# exists, to make the workers use the Ray that is already
# installed in the cluster.
#
# In the case where the user explicitly wants to include Ray in their
# pip list (and signals this by setting the environment variable below)
# then we don't want this deletion logic, so we skip it.
if os.environ.get(RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP) != 1:
ray_path = Path(target_dir) / "ray"
if ray_path.exists() and ray_path.is_dir():
shutil.rmtree(ray_path)
# Insert the target directory into the PYTHONPATH.
python_path = target_dir
if "PYTHONPATH" in context.env_vars:
python_path += os.pathsep + context.env_vars["PYTHONPATH"]
context.env_vars["PYTHONPATH"] = python_path

View file

@ -10,6 +10,7 @@ from ray._private.runtime_env.packaging import (
download_and_unpack_package, delete_package, get_uri_for_directory, download_and_unpack_package, delete_package, get_uri_for_directory,
parse_uri, Protocol, upload_package_if_needed) parse_uri, Protocol, upload_package_if_needed)
from ray._private.runtime_env.utils import RuntimeEnv from ray._private.runtime_env.utils import RuntimeEnv
from ray._private.utils import try_to_create_directory
default_logger = logging.getLogger(__name__) default_logger = logging.getLogger(__name__)
@ -89,8 +90,7 @@ def upload_py_modules_if_needed(
class PyModulesManager: class PyModulesManager:
def __init__(self, resources_dir: str): def __init__(self, resources_dir: str):
self._resources_dir = os.path.join(resources_dir, "py_modules_files") self._resources_dir = os.path.join(resources_dir, "py_modules_files")
if not os.path.isdir(self._resources_dir): try_to_create_directory(self._resources_dir)
os.makedirs(self._resources_dir)
assert _internal_kv_initialized() assert _internal_kv_initialized()
def delete_uri(self, def delete_uri(self,

View file

@ -135,6 +135,7 @@ class RuntimeEnv:
if uris.working_dir_uri \ if uris.working_dir_uri \
or uris.py_modules_uris \ or uris.py_modules_uris \
or uris.conda_uri \ or uris.conda_uri \
or uris.pip_uri \
or uris.plugin_uris: or uris.plugin_uris:
return True return True
return False return False
@ -148,6 +149,9 @@ class RuntimeEnv:
def conda_uri(self) -> str: def conda_uri(self) -> str:
return self._proto_runtime_env.uris.conda_uri return self._proto_runtime_env.uris.conda_uri
def pip_uri(self) -> str:
return self._proto_runtime_env.uris.pip_uri
def plugin_uris(self) -> List[str]: def plugin_uris(self) -> List[str]:
return list(self._proto_runtime_env.uris.plugin_uris) return list(self._proto_runtime_env.uris.plugin_uris)
@ -216,8 +220,8 @@ class RuntimeEnv:
self._proto_runtime_env.py_container_runtime_env.run_options) self._proto_runtime_env.py_container_runtime_env.run_options)
@classmethod @classmethod
def from_dict(cls, runtime_env_dict: Dict[str, Any], def from_dict(cls, runtime_env_dict: Dict[str, Any], conda_get_uri_fn,
conda_get_uri_fn) -> "RuntimeEnv": pip_get_uri_fn) -> "RuntimeEnv":
proto_runtime_env = ProtoRuntimeEnv() proto_runtime_env = ProtoRuntimeEnv()
proto_runtime_env.py_modules.extend( proto_runtime_env.py_modules.extend(
runtime_env_dict.get("py_modules", [])) runtime_env_dict.get("py_modules", []))
@ -228,10 +232,14 @@ class RuntimeEnv:
if "py_modules" in runtime_env_dict: if "py_modules" in runtime_env_dict:
for uri in runtime_env_dict["py_modules"]: for uri in runtime_env_dict["py_modules"]:
proto_runtime_env.uris.py_modules_uris.append(uri) proto_runtime_env.uris.py_modules_uris.append(uri)
if "conda" or "pip" in runtime_env_dict: if "conda" in runtime_env_dict:
uri = conda_get_uri_fn(runtime_env_dict) uri = conda_get_uri_fn(runtime_env_dict)
if uri is not None: if uri is not None:
proto_runtime_env.uris.conda_uri = uri proto_runtime_env.uris.conda_uri = uri
if "pip" in runtime_env_dict:
uri = pip_get_uri_fn(runtime_env_dict)
if uri is not None:
proto_runtime_env.uris.pip_uri = uri
env_vars = runtime_env_dict.get("env_vars", {}) env_vars = runtime_env_dict.get("env_vars", {})
proto_runtime_env.env_vars.update(env_vars.items()) proto_runtime_env.env_vars.update(env_vars.items())
if "_ray_release" in runtime_env_dict: if "_ray_release" in runtime_env_dict:

View file

@ -3,6 +3,8 @@ import os
from pathlib import Path from pathlib import Path
import sys import sys
from typing import Any, Dict, List, Optional, Set, Union from typing import Any, Dict, List, Optional, Set, Union
from pkg_resources import Requirement
from collections import OrderedDict
import yaml import yaml
import ray import ray
@ -10,7 +12,11 @@ from ray._private.runtime_env.plugin import (RuntimeEnvPlugin,
encode_plugin_uri) encode_plugin_uri)
from ray._private.runtime_env.utils import RuntimeEnv from ray._private.runtime_env.utils import RuntimeEnv
from ray._private.utils import import_attr from ray._private.utils import import_attr
from ray._private.runtime_env import conda from ray._private.runtime_env.conda import (
_resolve_install_from_source_ray_extras, get_uri as get_conda_uri)
from ray._private.runtime_env.pip import RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP
from ray._private.runtime_env.pip import get_uri as get_pip_uri
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -103,17 +109,57 @@ def parse_and_validate_conda(conda: Union[str, dict]) -> Union[str, dict]:
return result return result
def _rewrite_pip_list_ray_libraries(pip_list: List[str]) -> List[str]:
result = []
for specifier in pip_list:
requirement = Requirement.parse(specifier)
package_name = requirement.name
if package_name == "ray":
libraries = requirement.extras # e.g. ("serve", "tune")
if libraries == ():
# Ray alone was specified (e.g. "ray" or "ray>1.4"). Remove it.
if os.environ.get(RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP) != "1":
logger.warning(
"Ray was specified in the `pip` field of the "
f"`runtime_env`: '{specifier}'. This is not needed; "
"Ray is already installed on the cluster, so that Ray"
"installation will be used. To prevent Ray version "
f"incompatibility issues, '{specifier}' has been "
"deleted from the `pip` field. To disable this "
"deletion, set the environment variable "
f"{RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP} to 1.")
else:
result.append(specifier)
else:
# Replace the library with its dependencies.
extras = _resolve_install_from_source_ray_extras()
for library in libraries:
result += extras[library]
else:
# Pass through all non-Ray packages unmodified.
result.append(specifier)
return result
def parse_and_validate_pip(pip: Union[str, List[str]]) -> Optional[List[str]]: def parse_and_validate_pip(pip: Union[str, List[str]]) -> Optional[List[str]]:
"""Parses and validates a user-provided 'pip' option. """Parses and validates a user-provided 'pip' option.
Conda can be one of two cases: The value of the input 'pip' field can be one of two cases:
1) A List[str] describing the requirements. This is passed through. 1) A List[str] describing the requirements. This is passed through.
2) A string pointing to a local requirements file. In this case, the 2) A string pointing to a local requirements file. In this case, the
file contents will be read split into a list. file contents will be read split into a list.
The returned parsed value will be a list of pip packages. If a Ray library
(e.g. "ray[serve]") is specified, it will be deleted and replaced by its
dependencies (e.g. "uvicorn", "requests").
If the base Ray package (e.g. "ray>1.4" or "ray") is specified in the
input, it will be removed, unless the environment variable
RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP is set to 1.
""" """
assert pip is not None assert pip is not None
result = None pip_list = None
if sys.platform == "win32": if sys.platform == "win32":
raise NotImplementedError("The 'pip' field in runtime_env " raise NotImplementedError("The 'pip' field in runtime_env "
"is not currently supported on " "is not currently supported on "
@ -123,16 +169,26 @@ def parse_and_validate_pip(pip: Union[str, List[str]]) -> Optional[List[str]]:
pip_file = Path(pip) pip_file = Path(pip)
if not pip_file.is_file(): if not pip_file.is_file():
raise ValueError(f"{pip_file} is not a valid file") raise ValueError(f"{pip_file} is not a valid file")
result = pip_file.read_text().strip().split("\n") pip_list = pip_file.read_text().strip().split("\n")
elif isinstance(pip, list) and all(isinstance(dep, str) for dep in pip): elif isinstance(pip, list) and all(isinstance(dep, str) for dep in pip):
if len(pip) == 0: pip_list = pip
result = None
else:
result = pip
else: else:
raise TypeError("runtime_env['pip'] must be of type str or " raise TypeError("runtime_env['pip'] must be of type str or "
f"List[str], got {type(pip)}") f"List[str], got {type(pip)}")
result = _rewrite_pip_list_ray_libraries(pip_list)
# Eliminate duplicates to prevent `pip install` from erroring. Use
# OrderedDict to preserve the order of the list. This makes the output
# deterministic and easier to debug, because pip install can have
# different behavior depending on the order of the input.
result = list(OrderedDict.fromkeys(result))
if len(result) == 0:
result = None
logger.debug(f"Rewrote runtime_env `pip` field from {pip} to {result}.")
return result return result
@ -345,17 +401,22 @@ class ParsedRuntimeEnv(dict):
if "py_modules" in self: if "py_modules" in self:
for uri in self["py_modules"]: for uri in self["py_modules"]:
plugin_uris.append(encode_plugin_uri("py_modules", uri)) plugin_uris.append(encode_plugin_uri("py_modules", uri))
if "conda" or "pip" in self: if "conda" in self:
uri = conda.get_uri(self) uri = get_conda_uri(self)
if uri is not None: if uri is not None:
plugin_uris.append(encode_plugin_uri("conda", uri)) plugin_uris.append(encode_plugin_uri("conda", uri))
if "pip" in self:
uri = get_pip_uri(self)
if uri is not None:
plugin_uris.append(encode_plugin_uri("pip", uri))
return plugin_uris return plugin_uris
def get_proto_runtime_env(self): def get_proto_runtime_env(self):
"""Return the protobuf structure of runtime env.""" """Return the protobuf structure of runtime env."""
if self._cached_pb is None: if self._cached_pb is None:
self._cached_pb = RuntimeEnv.from_dict(self, conda.get_uri) self._cached_pb = RuntimeEnv.from_dict(self, get_conda_uri,
get_pip_uri)
return self._cached_pb return self._cached_pb

View file

@ -9,6 +9,7 @@ from ray._private.runtime_env.context import RuntimeEnvContext
from ray._private.runtime_env.packaging import ( from ray._private.runtime_env.packaging import (
download_and_unpack_package, delete_package, get_uri_for_directory, download_and_unpack_package, delete_package, get_uri_for_directory,
parse_uri, Protocol, upload_package_if_needed) parse_uri, Protocol, upload_package_if_needed)
from ray._private.utils import try_to_create_directory
default_logger = logging.getLogger(__name__) default_logger = logging.getLogger(__name__)
@ -61,8 +62,7 @@ def upload_working_dir_if_needed(
class WorkingDirManager: class WorkingDirManager:
def __init__(self, resources_dir: str): def __init__(self, resources_dir: str):
self._resources_dir = os.path.join(resources_dir, "working_dir_files") self._resources_dir = os.path.join(resources_dir, "working_dir_files")
if not os.path.isdir(self._resources_dir): try_to_create_directory(self._resources_dir)
os.makedirs(self._resources_dir)
assert _internal_kv_initialized() assert _internal_kv_initialized()
def delete_uri(self, def delete_uri(self,

View file

@ -1076,3 +1076,26 @@ def chdir(d: str):
os.chdir(d) os.chdir(d)
yield yield
os.chdir(old_dir) os.chdir(old_dir)
def generate_runtime_env_dict(field, spec_format, tmp_path, pip_list=None):
if pip_list is None:
pip_list = ["pip-install-test==0.5"]
if field == "conda":
conda_dict = {"dependencies": ["pip", {"pip": pip_list}]}
if spec_format == "file":
conda_file = tmp_path / f"environment-{hash(str(pip_list))}.yml"
conda_file.write_text(yaml.dump(conda_dict))
conda = str(conda_file)
elif spec_format == "python_object":
conda = conda_dict
runtime_env = {"conda": conda}
elif field == "pip":
if spec_format == "file":
pip_file = tmp_path / f"requirements-{hash(str(pip_list))}.txt"
pip_file.write_text("\n".join(pip_list))
pip = str(pip_file)
elif spec_format == "python_object":
pip = pip_list
runtime_env = {"pip": pip}
return runtime_env

View file

@ -209,8 +209,9 @@ py_test_module_list(
py_test_module_list( py_test_module_list(
files = [ files = [
"test_runtime_env_conda.py", "test_runtime_env_conda_and_pip.py",
"test_runtime_env_complicated.py", "test_runtime_env_conda_and_pip_2.py",
"test_runtime_env_complicated.py"
], ],
size = "large", size = "large",
extra_srcs = SRCS, extra_srcs = SRCS,

View file

@ -431,10 +431,17 @@ def test_pip_task(shutdown_only, pip_as_str, tmp_path):
@pytest.mark.skipif( @pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux", os.environ.get("CI") and sys.platform != "linux",
reason="This test is only run on linux CI machines.") reason="This test is only run on linux CI machines.")
def test_pip_ray_serve(shutdown_only): @pytest.mark.parametrize("option", ["conda", "pip"])
"""Tests that ray[serve] can be included as a pip dependency.""" def test_conda_pip_extras_ray_serve(shutdown_only, option):
"""Tests that ray[extras] can be included as a conda/pip dependency."""
ray.init() ray.init()
runtime_env = {"pip": ["pip-install-test==0.5", "ray[serve]"]} pip = ["pip-install-test==0.5", "ray[serve]"]
if option == "conda":
runtime_env = {"conda": {"dependencies": ["pip", {"pip": pip}]}}
elif option == "pip":
runtime_env = {"pip": pip}
else:
assert False, f"Unknown option: {option}"
@ray.remote @ray.remote
def f(): def f():
@ -775,7 +782,7 @@ def test_e2e_complex(call_ray_start, tmp_path):
"ray[serve, tune]", "ray[serve, tune]",
"texthero", "texthero",
"PyGithub", "PyGithub",
"xgboost_ray", "xgboost_ray", # has Ray as a dependency
"pandas==1.1", # pandas 1.2.4 in the demo, but not supported on py36 "pandas==1.1", # pandas 1.2.4 in the demo, but not supported on py36
"typer", "typer",
"aiofiles", "aiofiles",

View file

@ -1,7 +1,9 @@
import os import os
import pytest import pytest
import sys import sys
from ray._private.test_utils import wait_for_condition, chdir from ray._private.test_utils import (wait_for_condition, chdir,
generate_runtime_env_dict)
import yaml import yaml
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@ -16,7 +18,7 @@ if not os.environ.get("CI"):
def check_local_files_gced(cluster): def check_local_files_gced(cluster):
for node in cluster.list_all_nodes(): for node in cluster.list_all_nodes():
for subdir in ["conda"]: for subdir in ["conda", "pip"]:
all_files = os.listdir( all_files = os.listdir(
os.path.join(node.get_runtime_env_dir_path(), subdir)) os.path.join(node.get_runtime_env_dir_path(), subdir))
# Check that there are no files remaining except for .lock files # Check that there are no files remaining except for .lock files
@ -32,31 +34,6 @@ def check_local_files_gced(cluster):
return True return True
def generate_runtime_env_dict(field, spec_format, tmp_path):
if field == "conda":
conda_dict = {
"dependencies": ["pip", {
"pip": ["pip-install-test==0.5"]
}]
}
if spec_format == "file":
conda_file = tmp_path / "environment.yml"
conda_file.write_text(yaml.dump(conda_dict))
conda = str(conda_file)
elif spec_format == "python_object":
conda = conda_dict
runtime_env = {"conda": conda}
elif field == "pip":
if spec_format == "file":
pip_file = tmp_path / "requirements.txt"
pip_file.write_text("\n".join(["pip-install-test==0.5"]))
pip = str(pip_file)
elif spec_format == "python_object":
pip = ["pip-install-test==0.5"]
runtime_env = {"pip": pip}
return runtime_env
@pytest.mark.skipif( @pytest.mark.skipif(
os.environ.get("CI") and sys.platform != "linux", os.environ.get("CI") and sys.platform != "linux",
reason="Requires PR wheels built in CI, so only run on linux CI machines.") reason="Requires PR wheels built in CI, so only run on linux CI machines.")

View file

@ -0,0 +1,66 @@
import os
from typing import Dict
import pytest
import sys
from ray.exceptions import RuntimeEnvSetupError
from ray._private.test_utils import (wait_for_condition,
generate_runtime_env_dict)
import ray
if not os.environ.get("CI"):
# This flags turns on the local development that link against current ray
# packages and fall back all the dependencies to current python's site.
os.environ["RAY_RUNTIME_ENV_LOCAL_DEV_MODE"] = "1"
@pytest.mark.parametrize("field", ["conda", "pip"])
@pytest.mark.parametrize("specify_env_in_init", [False, True])
@pytest.mark.parametrize("spec_format", ["file", "python_object"])
def test_install_failure_logging(start_cluster, specify_env_in_init, field,
spec_format, tmp_path, capsys):
cluster, address = start_cluster
using_ray_client = address.startswith("ray://")
bad_envs: Dict[str, Dict] = {}
bad_packages: Dict[str, str] = {}
for scope in "init", "actor", "task":
bad_packages[scope] = "doesnotexist" + scope
bad_envs[scope] = generate_runtime_env_dict(
field, spec_format, tmp_path, pip_list=[bad_packages[scope]])
if specify_env_in_init:
if using_ray_client:
with pytest.raises(ConnectionAbortedError) as excinfo:
ray.init(address, runtime_env=bad_envs["init"])
assert bad_packages["init"] in str(excinfo.value)
else:
ray.init(address, runtime_env=bad_envs["init"])
wait_for_condition(
lambda: bad_packages["init"] in capsys.readouterr().out,
timeout=30)
return
ray.init(address)
@ray.remote(runtime_env=bad_envs["actor"])
class A:
pass
a = A.remote() # noqa
wait_for_condition(
lambda: bad_packages["actor"] in capsys.readouterr().out, timeout=30)
@ray.remote(runtime_env=bad_envs["task"])
def f():
pass
with pytest.raises(RuntimeEnvSetupError):
ray.get(f.remote())
wait_for_condition(
lambda: bad_packages["task"] in capsys.readouterr().out, timeout=30)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))

View file

@ -11,6 +11,7 @@ from ray._private.runtime_env.validation import (
parse_and_validate_conda, parse_and_validate_pip, parse_and_validate_conda, parse_and_validate_pip,
parse_and_validate_env_vars, parse_and_validate_py_modules, parse_and_validate_env_vars, parse_and_validate_py_modules,
ParsedRuntimeEnv) ParsedRuntimeEnv)
from ray._private.runtime_env.pip import RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP
from ray._private.runtime_env.plugin import (decode_plugin_uri, from ray._private.runtime_env.plugin import (decode_plugin_uri,
encode_plugin_uri) encode_plugin_uri)
@ -205,6 +206,23 @@ class TestValidatePip:
result = parse_and_validate_pip(PIP_LIST) result = parse_and_validate_pip(PIP_LIST)
assert result == PIP_LIST assert result == PIP_LIST
def test_remove_ray(self):
result = parse_and_validate_pip(["pkg1", "ray", "pkg2"])
assert result == ["pkg1", "pkg2"]
def test_remove_ray_env_var(self, monkeypatch):
monkeypatch.setenv(RAY_RUNTIME_ENV_ALLOW_RAY_IN_PIP, "1")
result = parse_and_validate_pip(["pkg1", "ray", "pkg2"])
assert result == ["pkg1", "ray", "pkg2"]
def test_replace_ray_libraries_with_dependencies(self):
result = parse_and_validate_pip(["pkg1", "ray[serve, tune]", "pkg2"])
assert "pkg1" in result
assert "pkg2" in result
assert "uvicorn" in result # from ray[serve]
assert "pandas" in result # from ray[tune]
assert not any(["ray" in specifier for specifier in result])
class TestValidateEnvVars: class TestValidateEnvVars:
def test_type_validation(self): def test_type_validation(self):

View file

@ -1425,6 +1425,10 @@ static std::vector<std::string> GetUrisFromRuntimeEnv(
const auto &uri = runtime_env->uris().conda_uri(); const auto &uri = runtime_env->uris().conda_uri();
result.emplace_back(encode_plugin_uri("conda", uri)); result.emplace_back(encode_plugin_uri("conda", uri));
} }
if (!runtime_env->uris().pip_uri().empty()) {
const auto &uri = runtime_env->uris().pip_uri();
result.emplace_back(encode_plugin_uri("pip", uri));
}
for (const auto &uri : runtime_env->uris().plugin_uris()) { for (const auto &uri : runtime_env->uris().plugin_uris()) {
result.emplace_back(encode_plugin_uri("plugin", uri)); result.emplace_back(encode_plugin_uri("plugin", uri));
} }

View file

@ -83,8 +83,10 @@ message RuntimeEnvUris {
repeated string py_modules_uris = 2; repeated string py_modules_uris = 2;
/// conda uri /// conda uri
string conda_uri = 3; string conda_uri = 3;
/// pip uri
string pip_uri = 4;
/// plugin uris /// plugin uris
repeated string plugin_uris = 4; repeated string plugin_uris = 5;
} }
/// The runtime environment describes all the runtime packages needed to /// The runtime environment describes all the runtime packages needed to