374 lines
15 KiB
Python
374 lines
15 KiB
Python
"""Internal utilities for parsing MLproject YAML files."""
|
|
|
|
import os
|
|
|
|
import yaml
|
|
|
|
from mlflow.exceptions import ExecutionException, MlflowException
|
|
from mlflow.projects import env_type
|
|
from mlflow.tracking import artifact_utils
|
|
from mlflow.utils import data_utils
|
|
from mlflow.utils.environment import _PYTHON_ENV_FILE_NAME
|
|
from mlflow.utils.file_utils import get_local_path_or_none
|
|
from mlflow.utils.string_utils import is_string_type, quote
|
|
|
|
MLPROJECT_FILE_NAME = "mlproject"
|
|
DEFAULT_CONDA_FILE_NAME = "conda.yaml"
|
|
|
|
|
|
def _find_mlproject(directory):
|
|
filenames = os.listdir(directory)
|
|
for filename in filenames:
|
|
if filename.lower() == MLPROJECT_FILE_NAME:
|
|
return os.path.join(directory, filename)
|
|
return None
|
|
|
|
|
|
def load_project(directory):
|
|
mlproject_path = _find_mlproject(directory)
|
|
|
|
# TODO: Validate structure of YAML loaded from the file
|
|
yaml_obj = {}
|
|
if mlproject_path is not None:
|
|
with open(mlproject_path) as mlproject_file:
|
|
yaml_obj = yaml.safe_load(mlproject_file)
|
|
|
|
# Validate the project config does't contain multiple environment fields
|
|
env_fields = set(yaml_obj.keys()).intersection(env_type.ALL)
|
|
if len(env_fields) > 1:
|
|
raise ExecutionException(
|
|
f"Project cannot contain multiple environment fields: {env_fields}"
|
|
)
|
|
|
|
project_name = yaml_obj.get("name")
|
|
|
|
# Parse entry points
|
|
entry_points = {}
|
|
for name, entry_point_yaml in yaml_obj.get("entry_points", {}).items():
|
|
parameters = entry_point_yaml.get("parameters", {})
|
|
command = entry_point_yaml.get("command")
|
|
entry_points[name] = EntryPoint(name, parameters, command)
|
|
|
|
databricks_spark_job_yaml = yaml_obj.get("databricks_spark_job")
|
|
if databricks_spark_job_yaml is not None:
|
|
python_file = databricks_spark_job_yaml.get("python_file")
|
|
|
|
if python_file is None and not entry_points:
|
|
raise MlflowException(
|
|
"Databricks Spark job requires either 'databricks_spark_job.python_file' "
|
|
"setting or 'entry_points' setting."
|
|
)
|
|
if python_file is not None and entry_points:
|
|
raise MlflowException(
|
|
"Databricks Spark job does not allow setting both "
|
|
"'databricks_spark_job.python_file' and 'entry_points'."
|
|
)
|
|
|
|
for entry_point in entry_points.values():
|
|
for param in entry_point.parameters.values():
|
|
if param.type == "path":
|
|
raise MlflowException(
|
|
"Databricks Spark job does not support entry point parameter of 'path' "
|
|
f"type. '{param.name}' value type is invalid."
|
|
)
|
|
|
|
if env_type.DOCKER in yaml_obj:
|
|
raise MlflowException(
|
|
"Databricks Spark job does not support setting docker environment."
|
|
)
|
|
|
|
if env_type.PYTHON in yaml_obj:
|
|
raise MlflowException(
|
|
"Databricks Spark job does not support setting python environment."
|
|
)
|
|
|
|
if env_type.CONDA in yaml_obj:
|
|
raise MlflowException(
|
|
"Databricks Spark job does not support setting conda environment."
|
|
)
|
|
|
|
databricks_spark_job_spec = DatabricksSparkJobSpec(
|
|
python_file=databricks_spark_job_yaml.get("python_file"),
|
|
parameters=databricks_spark_job_yaml.get("parameters", []),
|
|
python_libraries=databricks_spark_job_yaml.get("python_libraries", []),
|
|
)
|
|
return Project(
|
|
databricks_spark_job_spec=databricks_spark_job_spec,
|
|
name=project_name,
|
|
entry_points=entry_points,
|
|
)
|
|
|
|
# Validate config if docker_env parameter is present
|
|
if docker_env := yaml_obj.get(env_type.DOCKER):
|
|
if not docker_env.get("image"):
|
|
raise ExecutionException(
|
|
"Project configuration (MLproject file) was invalid: Docker "
|
|
"environment specified but no image attribute found."
|
|
)
|
|
if docker_env.get("volumes"):
|
|
if not (
|
|
isinstance(docker_env["volumes"], list)
|
|
and all(isinstance(i, str) for i in docker_env["volumes"])
|
|
):
|
|
raise ExecutionException(
|
|
"Project configuration (MLproject file) was invalid: "
|
|
"Docker volumes must be a list of strings, "
|
|
"""e.g.: '["/path1/:/path1", "/path2/:/path2"])"""
|
|
)
|
|
if docker_env.get("environment"):
|
|
if not (
|
|
isinstance(docker_env["environment"], list)
|
|
and all(isinstance(i, (list, str)) for i in docker_env["environment"])
|
|
):
|
|
raise ExecutionException(
|
|
"Project configuration (MLproject file) was invalid: "
|
|
"environment must be a list containing either strings (to copy environment "
|
|
"variables from host system) or lists of string pairs (to define new "
|
|
"environment variables)."
|
|
"""E.g.: '[["NEW_VAR", "new_value"], "VAR_TO_COPY_FROM_HOST"])"""
|
|
)
|
|
return Project(
|
|
env_type=env_type.DOCKER,
|
|
env_config_path=None,
|
|
entry_points=entry_points,
|
|
docker_env=docker_env,
|
|
name=project_name,
|
|
)
|
|
|
|
if python_env := yaml_obj.get(env_type.PYTHON):
|
|
python_env_path = os.path.join(directory, python_env)
|
|
if not os.path.exists(python_env_path):
|
|
raise ExecutionException(
|
|
f"Project specified python_env file {python_env_path}, but no such file was found."
|
|
)
|
|
return Project(
|
|
env_type=env_type.PYTHON,
|
|
env_config_path=python_env_path,
|
|
entry_points=entry_points,
|
|
docker_env=None,
|
|
name=project_name,
|
|
)
|
|
|
|
if conda_path := yaml_obj.get(env_type.CONDA):
|
|
conda_env_path = os.path.join(directory, conda_path)
|
|
if not os.path.exists(conda_env_path):
|
|
raise ExecutionException(
|
|
f"Project specified conda environment file {conda_env_path}, but no such "
|
|
"file was found."
|
|
)
|
|
return Project(
|
|
env_type=env_type.CONDA,
|
|
env_config_path=conda_env_path,
|
|
entry_points=entry_points,
|
|
docker_env=None,
|
|
name=project_name,
|
|
)
|
|
|
|
default_python_env_path = os.path.join(directory, _PYTHON_ENV_FILE_NAME)
|
|
if os.path.exists(default_python_env_path):
|
|
return Project(
|
|
env_type=env_type.PYTHON,
|
|
env_config_path=default_python_env_path,
|
|
entry_points=entry_points,
|
|
docker_env=None,
|
|
name=project_name,
|
|
)
|
|
|
|
default_conda_path = os.path.join(directory, DEFAULT_CONDA_FILE_NAME)
|
|
if os.path.exists(default_conda_path):
|
|
return Project(
|
|
env_type=env_type.CONDA,
|
|
env_config_path=default_conda_path,
|
|
entry_points=entry_points,
|
|
docker_env=None,
|
|
name=project_name,
|
|
)
|
|
|
|
return Project(
|
|
env_type=env_type.PYTHON,
|
|
env_config_path=None,
|
|
entry_points=entry_points,
|
|
docker_env=None,
|
|
name=project_name,
|
|
)
|
|
|
|
|
|
class Project:
|
|
"""A project specification loaded from an MLproject file in the passed-in directory."""
|
|
|
|
def __init__(
|
|
self,
|
|
name,
|
|
env_type=None,
|
|
env_config_path=None,
|
|
entry_points=None,
|
|
docker_env=None,
|
|
databricks_spark_job_spec=None,
|
|
):
|
|
self.env_type = env_type
|
|
self.env_config_path = env_config_path
|
|
self._entry_points = entry_points
|
|
self.docker_env = docker_env
|
|
self.name = name
|
|
self.databricks_spark_job_spec = databricks_spark_job_spec
|
|
|
|
def get_entry_point(self, entry_point):
|
|
if self.databricks_spark_job_spec:
|
|
if self.databricks_spark_job_spec.python_file is not None:
|
|
# If Databricks Spark job is configured with python_file field,
|
|
# it does not need to configure entry_point section
|
|
# and the 'entry_point' param in 'mlflow run' command is ignored
|
|
return None
|
|
|
|
if self._entry_points is None or entry_point not in self._entry_points:
|
|
raise MlflowException(
|
|
f"The entry point '{entry_point}' is not defined in the Databricks spark job "
|
|
f"MLproject file."
|
|
)
|
|
|
|
if entry_point in self._entry_points:
|
|
return self._entry_points[entry_point]
|
|
_, file_extension = os.path.splitext(entry_point)
|
|
ext_to_cmd = {".py": "python", ".sh": os.environ.get("SHELL", "bash")}
|
|
if file_extension in ext_to_cmd:
|
|
command = f"{ext_to_cmd[file_extension]} {quote(entry_point)}"
|
|
if not is_string_type(command):
|
|
command = command.encode("utf-8")
|
|
return EntryPoint(name=entry_point, parameters={}, command=command)
|
|
elif file_extension == ".R":
|
|
command = f"Rscript -e \"mlflow::mlflow_source('{quote(entry_point)}')\" --args"
|
|
return EntryPoint(name=entry_point, parameters={}, command=command)
|
|
raise ExecutionException(
|
|
"Could not find {0} among entry points {1} or interpret {0} as a "
|
|
"runnable script. Supported script file extensions: "
|
|
"{2}".format(entry_point, list(self._entry_points.keys()), list(ext_to_cmd.keys()))
|
|
)
|
|
|
|
|
|
class EntryPoint:
|
|
"""An entry point in an MLproject specification."""
|
|
|
|
def __init__(self, name, parameters, command):
|
|
self.name = name
|
|
self.parameters = {k: Parameter(k, v) for (k, v) in parameters.items()}
|
|
self.command = command
|
|
|
|
def _validate_parameters(self, user_parameters):
|
|
missing_params = [
|
|
name
|
|
for name in self.parameters
|
|
if name not in user_parameters and self.parameters[name].default is None
|
|
]
|
|
if missing_params:
|
|
raise ExecutionException(
|
|
"No value given for missing parameters: {}".format(
|
|
", ".join([f"'{name}'" for name in missing_params])
|
|
)
|
|
)
|
|
|
|
def compute_parameters(self, user_parameters, storage_dir):
|
|
"""
|
|
Given a dict mapping user-specified param names to values, computes parameters to
|
|
substitute into the command for this entry point. Returns a tuple (params, extra_params)
|
|
where `params` contains key-value pairs for parameters specified in the entry point
|
|
definition, and `extra_params` contains key-value pairs for additional parameters passed
|
|
by the user.
|
|
|
|
Note that resolving parameter values can be a heavy operation, e.g. if a remote URI is
|
|
passed for a parameter of type `path`, we download the URI to a local path within
|
|
`storage_dir` and substitute in the local path as the parameter value.
|
|
|
|
If `storage_dir` is `None`, report path will be return as parameter.
|
|
"""
|
|
if user_parameters is None:
|
|
user_parameters = {}
|
|
# Validate params before attempting to resolve parameter values
|
|
self._validate_parameters(user_parameters)
|
|
final_params = {}
|
|
extra_params = {}
|
|
|
|
parameter_keys = list(self.parameters.keys())
|
|
for key in parameter_keys:
|
|
param_obj = self.parameters[key]
|
|
key_position = parameter_keys.index(key)
|
|
value = user_parameters[key] if key in user_parameters else self.parameters[key].default
|
|
final_params[key] = param_obj.compute_value(value, storage_dir, key_position)
|
|
for key in user_parameters:
|
|
if key not in final_params:
|
|
extra_params[key] = user_parameters[key]
|
|
return (
|
|
self._sanitize_value_dict(final_params),
|
|
self._sanitize_extra_param_dict(extra_params),
|
|
)
|
|
|
|
def compute_command(self, user_parameters, storage_dir):
|
|
params, extra_params = self.compute_parameters(user_parameters, storage_dir)
|
|
command_with_params = self.command.format(**params)
|
|
command_arr = [command_with_params]
|
|
command_arr.extend([f"--{key} {value}" for key, value in extra_params.items()])
|
|
return " ".join(command_arr)
|
|
|
|
@staticmethod
|
|
def _sanitize_value_dict(param_dict):
|
|
# Keys here are used as str.format placeholders against self.command,
|
|
# not shell tokens, so quoting them would break {placeholder} resolution.
|
|
# Only values flow into the shell command unquoted, so only values need
|
|
# shell-quoting.
|
|
return {str(key): quote(str(value)) for key, value in param_dict.items()}
|
|
|
|
@staticmethod
|
|
def _sanitize_extra_param_dict(param_dict):
|
|
# Both keys and values get joined into the shell command as
|
|
# `--{key} {value}` tokens, so both need shell-quoting.
|
|
return {quote(str(key)): quote(str(value)) for key, value in param_dict.items()}
|
|
|
|
|
|
class Parameter:
|
|
"""A parameter in an MLproject entry point."""
|
|
|
|
def __init__(self, name, yaml_obj):
|
|
self.name = name
|
|
if is_string_type(yaml_obj):
|
|
self.type = yaml_obj
|
|
self.default = None
|
|
else:
|
|
self.type = yaml_obj.get("type", "string")
|
|
self.default = yaml_obj.get("default")
|
|
|
|
def _compute_uri_value(self, user_param_value):
|
|
if not data_utils.is_uri(user_param_value):
|
|
raise ExecutionException(
|
|
f"Expected URI for parameter {self.name} but got {user_param_value}"
|
|
)
|
|
return user_param_value
|
|
|
|
def _compute_path_value(self, user_param_value, storage_dir, key_position):
|
|
if local_path := get_local_path_or_none(user_param_value):
|
|
if not os.path.exists(local_path):
|
|
raise ExecutionException(
|
|
f"Got value {user_param_value} for parameter {self.name}, but no such file or "
|
|
"directory was found."
|
|
)
|
|
return os.path.abspath(local_path)
|
|
target_sub_dir = f"param_{key_position}"
|
|
download_dir = os.path.join(storage_dir, target_sub_dir)
|
|
os.mkdir(download_dir)
|
|
return artifact_utils._download_artifact_from_uri(
|
|
artifact_uri=user_param_value, output_path=download_dir
|
|
)
|
|
|
|
def compute_value(self, param_value, storage_dir, key_position):
|
|
if storage_dir and self.type == "path":
|
|
return self._compute_path_value(param_value, storage_dir, key_position)
|
|
elif self.type == "uri":
|
|
return self._compute_uri_value(param_value)
|
|
else:
|
|
return param_value
|
|
|
|
|
|
class DatabricksSparkJobSpec:
|
|
def __init__(self, python_file, parameters, python_libraries):
|
|
self.python_file = python_file
|
|
self.parameters = parameters
|
|
self.python_libraries = python_libraries
|