987 lines
32 KiB
Python
987 lines
32 KiB
Python
import atexit
|
|
import codecs
|
|
import errno
|
|
import fnmatch
|
|
import gzip
|
|
import importlib.util
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import pathlib
|
|
import posixpath
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from concurrent.futures import as_completed
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from subprocess import CalledProcessError, TimeoutExpired
|
|
from types import TracebackType
|
|
from typing import Any
|
|
from urllib.parse import unquote
|
|
from urllib.request import pathname2url
|
|
|
|
from mlflow.entities import FileInfo
|
|
from mlflow.environment_variables import (
|
|
_MLFLOW_MPD_NUM_RETRIES,
|
|
_MLFLOW_MPD_RETRY_INTERVAL_SECONDS,
|
|
MLFLOW_DOWNLOAD_CHUNK_TIMEOUT,
|
|
MLFLOW_ENABLE_ARTIFACTS_PROGRESS_BAR,
|
|
)
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.protos.databricks_artifacts_pb2 import ArtifactCredentialType
|
|
from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE
|
|
from mlflow.utils import download_cloud_file_chunk
|
|
from mlflow.utils.databricks_utils import (
|
|
get_databricks_local_temp_dir,
|
|
get_databricks_nfs_temp_dir,
|
|
)
|
|
from mlflow.utils.os import is_windows
|
|
from mlflow.utils.process import cache_return_value_per_process
|
|
from mlflow.utils.request_utils import cloud_storage_http_request, download_chunk
|
|
from mlflow.utils.rest_utils import augmented_raise_for_status
|
|
|
|
ENCODING = "utf-8"
|
|
_PROGRESS_BAR_DISPLAY_THRESHOLD = 500_000_000 # 500 MB
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# This is for backward compatibility with databricks-feature-engineering<=0.10.2
|
|
if importlib.util.find_spec("yaml") is not None:
|
|
try:
|
|
from yaml import CSafeDumper as YamlSafeDumper
|
|
except ImportError:
|
|
from yaml import SafeDumper as YamlSafeDumper # noqa: F401
|
|
|
|
|
|
class ArtifactProgressBar:
|
|
def __init__(self, desc, total, step, **kwargs) -> None:
|
|
self.desc = desc
|
|
self.total = total
|
|
self.step = step
|
|
self.pbar = None
|
|
self.progress = 0
|
|
self.kwargs = kwargs
|
|
|
|
def set_pbar(self):
|
|
if MLFLOW_ENABLE_ARTIFACTS_PROGRESS_BAR.get():
|
|
try:
|
|
from tqdm.auto import tqdm
|
|
|
|
self.pbar = tqdm(total=self.total, desc=self.desc, **self.kwargs)
|
|
except ImportError:
|
|
pass
|
|
|
|
@classmethod
|
|
def chunks(cls, file_size, desc, chunk_size):
|
|
bar = cls(
|
|
desc,
|
|
total=file_size,
|
|
step=chunk_size,
|
|
unit="iB",
|
|
unit_scale=True,
|
|
unit_divisor=1024,
|
|
miniters=1,
|
|
)
|
|
if file_size >= _PROGRESS_BAR_DISPLAY_THRESHOLD:
|
|
bar.set_pbar()
|
|
return bar
|
|
|
|
@classmethod
|
|
def files(cls, desc, total):
|
|
bar = cls(desc, total=total, step=1)
|
|
bar.set_pbar()
|
|
return bar
|
|
|
|
def update(self):
|
|
if self.pbar:
|
|
update_step = min(self.total - self.progress, self.step)
|
|
self.pbar.update(update_step)
|
|
self.pbar.refresh()
|
|
self.progress += update_step
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
if self.pbar:
|
|
self.pbar.close()
|
|
|
|
|
|
def is_directory(name):
|
|
return os.path.isdir(name)
|
|
|
|
|
|
def is_file(name):
|
|
return os.path.isfile(name)
|
|
|
|
|
|
def exists(name):
|
|
return os.path.exists(name)
|
|
|
|
|
|
def list_all(root, filter_func=lambda x: True, full_path=False):
|
|
"""List all entities directly under 'dir_name' that satisfy 'filter_func'
|
|
|
|
Args:
|
|
root: Name of directory to start search.
|
|
filter_func: function or lambda that takes path.
|
|
full_path: If True will return results as full path including `root`.
|
|
|
|
Returns:
|
|
list of all files or directories that satisfy the criteria.
|
|
|
|
"""
|
|
if not is_directory(root):
|
|
raise Exception(f"Invalid parent directory '{root}'")
|
|
matches = [x for x in os.listdir(root) if filter_func(os.path.join(root, x))]
|
|
return [os.path.join(root, m) for m in matches] if full_path else matches
|
|
|
|
|
|
def list_subdirs(dir_name, full_path=False):
|
|
"""
|
|
Equivalent to UNIX command:
|
|
``find $dir_name -depth 1 -type d``
|
|
|
|
Args:
|
|
dir_name: Name of directory to start search.
|
|
full_path: If True will return results as full path including `root`.
|
|
|
|
Returns:
|
|
list of all directories directly under 'dir_name'.
|
|
"""
|
|
return list_all(dir_name, os.path.isdir, full_path)
|
|
|
|
|
|
def list_files(dir_name, full_path=False):
|
|
"""
|
|
Equivalent to UNIX command:
|
|
``find $dir_name -depth 1 -type f``
|
|
|
|
Args:
|
|
dir_name: Name of directory to start search.
|
|
full_path: If True will return results as full path including `root`.
|
|
|
|
Returns:
|
|
list of all files directly under 'dir_name'.
|
|
"""
|
|
return list_all(dir_name, os.path.isfile, full_path)
|
|
|
|
|
|
def find(root, name, full_path=False):
|
|
"""Search for a file in a root directory. Equivalent to:
|
|
``find $root -name "$name" -depth 1``
|
|
|
|
Args:
|
|
root: Name of root directory for find.
|
|
name: Name of file or directory to find directly under root directory.
|
|
full_path: If True will return results as full path including `root`.
|
|
|
|
Returns:
|
|
list of matching files or directories.
|
|
"""
|
|
path_name = os.path.join(root, name)
|
|
return list_all(root, lambda x: x == path_name, full_path)
|
|
|
|
|
|
def mkdir(root, name=None):
|
|
"""Make directory with name "root/name", or just "root" if name is None.
|
|
|
|
Args:
|
|
root: Name of parent directory.
|
|
name: Optional name of leaf directory.
|
|
|
|
Returns:
|
|
Path to created directory.
|
|
"""
|
|
target = os.path.join(root, name) if name is not None else root
|
|
try:
|
|
os.makedirs(target, exist_ok=True)
|
|
except OSError as e:
|
|
if e.errno != errno.EEXIST or not os.path.isdir(target):
|
|
raise e
|
|
return target
|
|
|
|
|
|
def make_containing_dirs(path):
|
|
"""
|
|
Create the base directory for a given file path if it does not exist; also creates parent
|
|
directories.
|
|
"""
|
|
dir_name = os.path.dirname(path)
|
|
if not os.path.exists(dir_name):
|
|
os.makedirs(dir_name)
|
|
|
|
|
|
class TempDir:
|
|
def __init__(self, chdr=False, remove_on_exit=True):
|
|
self._dir = None
|
|
self._path = None
|
|
self._chdr = chdr
|
|
self._remove = remove_on_exit
|
|
|
|
def __enter__(self):
|
|
self._path = os.path.abspath(create_tmp_dir())
|
|
assert os.path.exists(self._path)
|
|
if self._chdr:
|
|
self._dir = os.path.abspath(os.getcwd())
|
|
os.chdir(self._path)
|
|
return self
|
|
|
|
def __exit__(self, tp, val, traceback):
|
|
if self._chdr and self._dir:
|
|
os.chdir(self._dir)
|
|
self._dir = None
|
|
if self._remove and os.path.exists(self._path):
|
|
shutil.rmtree(self._path)
|
|
|
|
assert not self._remove or not os.path.exists(self._path)
|
|
|
|
def path(self, *path):
|
|
return os.path.join("./", *path) if self._chdr else os.path.join(self._path, *path)
|
|
|
|
|
|
def read_file_lines(parent_path, file_name):
|
|
"""Return the contents of the file as an array where each element is a separate line.
|
|
|
|
Args:
|
|
parent_path: Full path to the directory that contains the file.
|
|
file_name: Leaf file name.
|
|
|
|
Returns:
|
|
All lines in the file as an array.
|
|
|
|
"""
|
|
file_path = os.path.join(parent_path, file_name)
|
|
with codecs.open(file_path, mode="r", encoding=ENCODING) as f:
|
|
return f.readlines()
|
|
|
|
|
|
def read_file(parent_path, file_name):
|
|
"""Return the contents of the file.
|
|
|
|
Args:
|
|
parent_path: Full path to the directory that contains the file.
|
|
file_name: Leaf file name.
|
|
|
|
Returns:
|
|
The contents of the file.
|
|
|
|
"""
|
|
file_path = os.path.join(parent_path, file_name)
|
|
with codecs.open(file_path, mode="r", encoding=ENCODING) as f:
|
|
return f.read()
|
|
|
|
|
|
def get_file_info(path, rel_path):
|
|
"""Returns file meta data : location, size, ... etc
|
|
|
|
Args:
|
|
path: Path to artifact.
|
|
rel_path: Relative path.
|
|
|
|
Returns:
|
|
`FileInfo` object
|
|
"""
|
|
if is_directory(path):
|
|
return FileInfo(rel_path, True, None)
|
|
else:
|
|
return FileInfo(rel_path, False, os.path.getsize(path))
|
|
|
|
|
|
def mv(target, new_parent):
|
|
shutil.move(target, new_parent)
|
|
|
|
|
|
def write_to(filename, data):
|
|
with codecs.open(filename, mode="w", encoding=ENCODING) as handle:
|
|
handle.write(data)
|
|
|
|
|
|
def append_to(filename, data):
|
|
with open(filename, "a") as handle:
|
|
handle.write(data)
|
|
|
|
|
|
def make_tarfile(output_filename, source_dir, archive_name, custom_filter=None):
|
|
# Helper for filtering out modification timestamps
|
|
def _filter_timestamps(tar_info):
|
|
tar_info.mtime = 0
|
|
return tar_info if custom_filter is None else custom_filter(tar_info)
|
|
|
|
unzipped_file_handle, unzipped_filename = tempfile.mkstemp()
|
|
try:
|
|
with tarfile.open(unzipped_filename, "w") as tar:
|
|
tar.add(source_dir, arcname=archive_name, filter=_filter_timestamps)
|
|
# When gzipping the tar, don't include the tar's filename or modification time in the
|
|
# zipped archive (see https://docs.python.org/3/library/gzip.html#gzip.GzipFile)
|
|
with (
|
|
gzip.GzipFile(
|
|
filename="", fileobj=open(output_filename, "wb"), mode="wb", mtime=0
|
|
) as gzipped_tar,
|
|
open(unzipped_filename, "rb") as tar,
|
|
):
|
|
gzipped_tar.write(tar.read())
|
|
finally:
|
|
os.close(unzipped_file_handle)
|
|
|
|
|
|
def _copy_project(src_path, dst_path=""):
|
|
"""Internal function used to copy MLflow project during development.
|
|
|
|
Copies the content of the whole directory tree except patterns defined in .dockerignore.
|
|
The MLflow is assumed to be accessible as a local directory in this case.
|
|
|
|
Args:
|
|
src_path: Path to the original MLflow project
|
|
dst_path: MLflow will be copied here
|
|
|
|
Returns:
|
|
Name of the MLflow project directory.
|
|
"""
|
|
|
|
def _docker_ignore(mlflow_root):
|
|
docker_ignore = os.path.join(mlflow_root, ".dockerignore")
|
|
patterns = []
|
|
if os.path.exists(docker_ignore):
|
|
with open(docker_ignore) as f:
|
|
patterns = [x.strip() for x in f]
|
|
|
|
def ignore(_, names):
|
|
res = set()
|
|
for p in patterns:
|
|
res.update(set(fnmatch.filter(names, p)))
|
|
return list(res)
|
|
|
|
return ignore if patterns else None
|
|
|
|
mlflow_dir = "mlflow-project"
|
|
# check if we have project root
|
|
assert os.path.isfile(os.path.join(src_path, "pyproject.toml")), "file not found " + str(
|
|
os.path.abspath(os.path.join(src_path, "pyproject.toml"))
|
|
)
|
|
shutil.copytree(src_path, os.path.join(dst_path, mlflow_dir), ignore=_docker_ignore(src_path))
|
|
return mlflow_dir
|
|
|
|
|
|
def _copy_file_or_tree(src, dst, dst_dir=None):
|
|
"""
|
|
Returns:
|
|
The path to the copied artifacts, relative to `dst`.
|
|
"""
|
|
dst_subpath = os.path.basename(os.path.abspath(src))
|
|
if dst_dir is not None:
|
|
dst_subpath = os.path.join(dst_dir, dst_subpath)
|
|
dst_path = os.path.join(dst, dst_subpath)
|
|
if os.path.isfile(src):
|
|
dst_dirpath = os.path.dirname(dst_path)
|
|
if not os.path.exists(dst_dirpath):
|
|
os.makedirs(dst_dirpath)
|
|
shutil.copy(src=src, dst=dst_path)
|
|
else:
|
|
shutil.copytree(src=src, dst=dst_path, ignore=shutil.ignore_patterns("__pycache__"))
|
|
return dst_subpath
|
|
|
|
|
|
def _get_local_project_dir_size(project_path):
|
|
"""Internal function for reporting the size of a local project directory before copying to
|
|
destination for cli logging reporting to stdout.
|
|
|
|
Args:
|
|
project_path: local path of the project directory
|
|
|
|
Returns:
|
|
directory file sizes in KB, rounded to single decimal point for legibility
|
|
"""
|
|
|
|
total_size = 0
|
|
for root, _, files in os.walk(project_path):
|
|
for f in files:
|
|
path = os.path.join(root, f)
|
|
total_size += os.path.getsize(path)
|
|
return round(total_size / 1024.0, 1)
|
|
|
|
|
|
def _get_local_file_size(file):
|
|
"""
|
|
Get the size of a local file in KB
|
|
"""
|
|
return round(os.path.getsize(file) / 1024.0, 1)
|
|
|
|
|
|
def get_parent_dir(path):
|
|
return os.path.abspath(os.path.join(path, os.pardir))
|
|
|
|
|
|
def relative_path_to_artifact_path(path):
|
|
if os.path == posixpath:
|
|
return path
|
|
if os.path.abspath(path) == path:
|
|
raise Exception("This method only works with relative paths.")
|
|
return unquote(pathname2url(path))
|
|
|
|
|
|
def path_to_local_file_uri(path):
|
|
"""
|
|
Convert local filesystem path to local file uri.
|
|
"""
|
|
return pathlib.Path(os.path.abspath(path)).as_uri()
|
|
|
|
|
|
def path_to_local_sqlite_uri(path):
|
|
"""
|
|
Convert local filesystem path to sqlite uri.
|
|
"""
|
|
path = posixpath.abspath(pathname2url(os.path.abspath(path)))
|
|
prefix = "sqlite://" if sys.platform == "win32" else "sqlite:///"
|
|
return prefix + path
|
|
|
|
|
|
def local_file_uri_to_path(uri):
|
|
"""
|
|
Convert URI to local filesystem path.
|
|
No-op if the uri does not have the expected scheme.
|
|
"""
|
|
path = uri
|
|
if uri.startswith("file:"):
|
|
parsed_path = urllib.parse.urlparse(uri)
|
|
path = parsed_path.path
|
|
# Fix for retaining server name in UNC path.
|
|
if is_windows() and parsed_path.netloc:
|
|
return urllib.request.url2pathname(rf"\\{parsed_path.netloc}{path}")
|
|
return urllib.request.url2pathname(path)
|
|
|
|
|
|
def get_local_path_or_none(path_or_uri):
|
|
"""Check if the argument is a local path (no scheme or file:///) and return local path if true,
|
|
None otherwise.
|
|
"""
|
|
parsed_uri = urllib.parse.urlparse(path_or_uri)
|
|
if len(parsed_uri.scheme) == 0 or parsed_uri.scheme == "file" and len(parsed_uri.netloc) == 0:
|
|
return local_file_uri_to_path(path_or_uri)
|
|
else:
|
|
return None
|
|
|
|
|
|
def download_file_using_http_uri(http_uri, download_path, chunk_size=100000000, headers=None):
|
|
"""
|
|
Downloads a file specified using the `http_uri` to a local `download_path`. This function
|
|
uses a `chunk_size` to ensure an OOM error is not raised a large file is downloaded.
|
|
|
|
Note : This function is meant to download files using presigned urls from various cloud
|
|
providers.
|
|
"""
|
|
if headers is None:
|
|
headers = {}
|
|
with cloud_storage_http_request("get", http_uri, stream=True, headers=headers) as response:
|
|
augmented_raise_for_status(response)
|
|
with open(download_path, "wb") as output_file:
|
|
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
if not chunk:
|
|
break
|
|
output_file.write(chunk)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Chunk:
|
|
index: int
|
|
start: int
|
|
end: int
|
|
path: str
|
|
|
|
|
|
def _yield_chunks(path, file_size, chunk_size):
|
|
num_requests = int(math.ceil(file_size / float(chunk_size)))
|
|
for i in range(num_requests):
|
|
range_start = i * chunk_size
|
|
range_end = min(range_start + chunk_size - 1, file_size - 1)
|
|
yield _Chunk(i, range_start, range_end, path)
|
|
|
|
|
|
def parallelized_download_file_using_http_uri(
|
|
thread_pool_executor,
|
|
http_uri,
|
|
download_path,
|
|
remote_file_path,
|
|
file_size,
|
|
uri_type,
|
|
chunk_size,
|
|
env,
|
|
headers=None,
|
|
):
|
|
"""
|
|
Downloads a file specified using the `http_uri` to a local `download_path`. This function
|
|
sends multiple requests in parallel each specifying its own desired byte range as a header,
|
|
then reconstructs the file from the downloaded chunks. This allows for downloads of large files
|
|
without OOM risk.
|
|
|
|
Note : This function is meant to download files using presigned urls from various cloud
|
|
providers.
|
|
Returns a dict of chunk index : exception, if one was thrown for that index.
|
|
"""
|
|
|
|
def run_download(chunk: _Chunk):
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
sys.executable,
|
|
download_cloud_file_chunk.__file__,
|
|
"--range-start",
|
|
str(chunk.start),
|
|
"--range-end",
|
|
str(chunk.end),
|
|
"--headers",
|
|
json.dumps(headers or {}),
|
|
"--download-path",
|
|
download_path,
|
|
"--http-uri",
|
|
http_uri,
|
|
],
|
|
text=True,
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=MLFLOW_DOWNLOAD_CHUNK_TIMEOUT.get(),
|
|
env=env,
|
|
)
|
|
except (TimeoutExpired, CalledProcessError) as e:
|
|
raise MlflowException(
|
|
f"""
|
|
----- stdout -----
|
|
{e.stdout.strip()}
|
|
|
|
----- stderr -----
|
|
{e.stderr.strip()}
|
|
"""
|
|
) from e
|
|
|
|
chunks = _yield_chunks(remote_file_path, file_size, chunk_size)
|
|
# Create file if it doesn't exist or erase the contents if it does. We should do this here
|
|
# before sending to the workers so they can each individually seek to their respective positions
|
|
# and write chunks without overwriting.
|
|
with open(download_path, "w"):
|
|
pass
|
|
if uri_type == ArtifactCredentialType.GCP_SIGNED_URL or uri_type is None:
|
|
chunk = next(chunks)
|
|
# GCP files could be transcoded, in which case the range header is ignored.
|
|
# Test if this is the case by downloading one chunk and seeing if it's larger than the
|
|
# requested size. If yes, let that be the file; if not, continue downloading more chunks.
|
|
download_chunk(
|
|
range_start=chunk.start,
|
|
range_end=chunk.end,
|
|
headers=headers,
|
|
download_path=download_path,
|
|
http_uri=http_uri,
|
|
)
|
|
downloaded_size = os.path.getsize(download_path)
|
|
# If downloaded size was equal to the chunk size it would have been downloaded serially,
|
|
# so we don't need to consider this here
|
|
if downloaded_size > chunk_size:
|
|
return {}
|
|
|
|
futures = {thread_pool_executor.submit(run_download, chunk): chunk for chunk in chunks}
|
|
failed_downloads = {}
|
|
with ArtifactProgressBar.chunks(file_size, f"Downloading {download_path}", chunk_size) as pbar:
|
|
for future in as_completed(futures):
|
|
chunk = futures[future]
|
|
try:
|
|
future.result()
|
|
except Exception as e:
|
|
_logger.debug(
|
|
f"Failed to download chunk {chunk.index} for {chunk.path}: {e}. "
|
|
f"The download of this chunk will be retried later."
|
|
)
|
|
failed_downloads[chunk] = future.exception()
|
|
else:
|
|
pbar.update()
|
|
|
|
return failed_downloads
|
|
|
|
|
|
def download_chunk_retries(*, chunks, http_uri, headers, download_path):
|
|
num_retries = _MLFLOW_MPD_NUM_RETRIES.get()
|
|
interval = _MLFLOW_MPD_RETRY_INTERVAL_SECONDS.get()
|
|
for chunk in chunks:
|
|
_logger.info(f"Retrying download of chunk {chunk.index} for {chunk.path}")
|
|
for retry in range(num_retries):
|
|
try:
|
|
download_chunk(
|
|
range_start=chunk.start,
|
|
range_end=chunk.end,
|
|
headers=headers,
|
|
download_path=download_path,
|
|
http_uri=http_uri,
|
|
)
|
|
_logger.info(f"Successfully downloaded chunk {chunk.index} for {chunk.path}")
|
|
break
|
|
except Exception:
|
|
if retry == num_retries - 1:
|
|
raise
|
|
time.sleep(interval)
|
|
|
|
|
|
def _handle_readonly_on_windows(func, path, exc_info):
|
|
"""
|
|
This function should not be called directly but should be passed to `onerror` of
|
|
`shutil.rmtree` in order to reattempt the removal of a read-only file after making
|
|
it writable on Windows.
|
|
|
|
References:
|
|
- https://bugs.python.org/issue19643
|
|
- https://bugs.python.org/issue43657
|
|
"""
|
|
exc_type, exc_value = exc_info[:2]
|
|
should_reattempt = (
|
|
is_windows()
|
|
and func in (os.unlink, os.rmdir)
|
|
and issubclass(exc_type, PermissionError)
|
|
and exc_value.winerror == 5
|
|
)
|
|
if not should_reattempt:
|
|
raise exc_value
|
|
os.chmod(path, stat.S_IWRITE)
|
|
func(path)
|
|
|
|
|
|
def _get_tmp_dir():
|
|
from mlflow.utils.databricks_utils import get_repl_id, is_in_databricks_runtime
|
|
|
|
if is_in_databricks_runtime():
|
|
try:
|
|
return get_databricks_local_temp_dir()
|
|
except Exception:
|
|
pass
|
|
|
|
if repl_id := get_repl_id():
|
|
return os.path.join("/tmp", "repl_tmp_data", repl_id)
|
|
|
|
return None
|
|
|
|
|
|
def create_tmp_dir():
|
|
if directory := _get_tmp_dir():
|
|
os.makedirs(directory, exist_ok=True)
|
|
return tempfile.mkdtemp(dir=directory)
|
|
|
|
return tempfile.mkdtemp()
|
|
|
|
|
|
@cache_return_value_per_process
|
|
def get_or_create_tmp_dir():
|
|
"""
|
|
Get or create a temporary directory which will be removed once python process exit.
|
|
"""
|
|
from mlflow.utils.databricks_utils import get_repl_id, is_in_databricks_runtime
|
|
|
|
if is_in_databricks_runtime() and get_repl_id() is not None:
|
|
# Note: For python process attached to databricks notebook, atexit does not work.
|
|
# The directory returned by `get_databricks_local_tmp_dir`
|
|
# will be removed once databricks notebook detaches.
|
|
# The temp directory is designed to be used by all kinds of applications,
|
|
# so create a child directory "mlflow" for storing mlflow temp data.
|
|
try:
|
|
repl_local_tmp_dir = get_databricks_local_temp_dir()
|
|
except Exception:
|
|
repl_local_tmp_dir = os.path.join("/tmp", "repl_tmp_data", get_repl_id())
|
|
|
|
tmp_dir = os.path.join(repl_local_tmp_dir, "mlflow")
|
|
os.makedirs(tmp_dir, exist_ok=True)
|
|
else:
|
|
tmp_dir = tempfile.mkdtemp()
|
|
# mkdtemp creates a directory with permission 0o700
|
|
# For Spark UDFs, we need to make it accessible to other processes
|
|
# Use 0o750 (owner: rwx, group: r-x, others: None) instead of 0o777
|
|
# This allows read/execute but not write for group and others
|
|
os.chmod(tmp_dir, 0o750)
|
|
atexit.register(shutil.rmtree, tmp_dir, ignore_errors=True)
|
|
|
|
return tmp_dir
|
|
|
|
|
|
@cache_return_value_per_process
|
|
def get_or_create_nfs_tmp_dir():
|
|
"""
|
|
Get or create a temporary NFS directory which will be removed once python process exit.
|
|
"""
|
|
from mlflow.utils.databricks_utils import get_repl_id, is_in_databricks_runtime
|
|
from mlflow.utils.nfs_on_spark import get_nfs_cache_root_dir
|
|
|
|
nfs_root_dir = get_nfs_cache_root_dir()
|
|
|
|
if is_in_databricks_runtime() and get_repl_id() is not None:
|
|
# Note: In databricks, atexit hook does not work.
|
|
# The directory returned by `get_databricks_nfs_tmp_dir`
|
|
# will be removed once databricks notebook detaches.
|
|
# The temp directory is designed to be used by all kinds of applications,
|
|
# so create a child directory "mlflow" for storing mlflow temp data.
|
|
try:
|
|
repl_nfs_tmp_dir = get_databricks_nfs_temp_dir()
|
|
except Exception:
|
|
repl_nfs_tmp_dir = os.path.join(nfs_root_dir, "repl_tmp_data", get_repl_id())
|
|
|
|
tmp_nfs_dir = os.path.join(repl_nfs_tmp_dir, "mlflow")
|
|
os.makedirs(tmp_nfs_dir, exist_ok=True)
|
|
else:
|
|
tmp_nfs_dir = tempfile.mkdtemp(dir=nfs_root_dir)
|
|
# mkdtemp creates a directory with permission 0o700
|
|
# For Spark UDFs, we need to make it accessible to other processes
|
|
# Use 0o750 (owner: rwx, group: r-x, others: None) instead of 0o777
|
|
os.chmod(tmp_nfs_dir, 0o750)
|
|
atexit.register(shutil.rmtree, tmp_nfs_dir, ignore_errors=True)
|
|
|
|
return tmp_nfs_dir
|
|
|
|
|
|
def shutil_copytree_without_file_permissions(src_dir, dst_dir):
|
|
"""
|
|
Copies the directory src_dir into dst_dir, without preserving filesystem permissions
|
|
"""
|
|
for dirpath, dirnames, filenames in os.walk(src_dir):
|
|
for dirname in dirnames:
|
|
relative_dir_path = os.path.relpath(os.path.join(dirpath, dirname), src_dir)
|
|
# For each directory <dirname> immediately under <dirpath>, create an equivalently-named
|
|
# directory under the destination directory
|
|
abs_dir_path = os.path.join(dst_dir, relative_dir_path)
|
|
if not os.path.exists(abs_dir_path):
|
|
os.mkdir(abs_dir_path)
|
|
for filename in filenames:
|
|
# For each file with name <filename> immediately under <dirpath>, copy that file to
|
|
# the appropriate location in the destination directory
|
|
file_path = os.path.join(dirpath, filename)
|
|
relative_file_path = os.path.relpath(file_path, src_dir)
|
|
abs_file_path = os.path.join(dst_dir, relative_file_path)
|
|
shutil.copy2(file_path, abs_file_path)
|
|
|
|
|
|
def contains_path_separator(path):
|
|
"""
|
|
Returns True if a path contains a path separator, False otherwise.
|
|
"""
|
|
return any((sep in path) for sep in (os.path.sep, os.path.altsep) if sep is not None)
|
|
|
|
|
|
def contains_percent(path):
|
|
"""
|
|
Returns True if a path contains a percent character, False otherwise.
|
|
"""
|
|
return "%" in path
|
|
|
|
|
|
def read_chunk(path: os.PathLike, size: int, start_byte: int = 0) -> bytes:
|
|
"""Read a chunk of bytes from a file.
|
|
|
|
Args:
|
|
path: Path to the file.
|
|
size: The size of the chunk.
|
|
start_byte: The start byte of the chunk.
|
|
|
|
Returns:
|
|
The chunk of bytes.
|
|
|
|
"""
|
|
with open(path, "rb") as f:
|
|
if start_byte > 0:
|
|
f.seek(start_byte)
|
|
return f.read(size)
|
|
|
|
|
|
@contextmanager
|
|
def remove_on_error(path: os.PathLike, onerror=None):
|
|
"""A context manager that removes a file or directory if an exception is raised during
|
|
execution.
|
|
|
|
Args:
|
|
path: Path to the file or directory.
|
|
onerror: A callback function that will be called with the captured exception before
|
|
the file or directory is removed. For example, you can use this callback to
|
|
log the exception.
|
|
|
|
"""
|
|
try:
|
|
yield
|
|
except Exception as e:
|
|
if onerror:
|
|
onerror(e)
|
|
if os.path.exists(path):
|
|
if os.path.isfile(path):
|
|
os.remove(path)
|
|
elif os.path.isdir(path):
|
|
shutil.rmtree(path)
|
|
_logger.warning(
|
|
f"Failed to remove {path}" if os.path.exists(path) else f"Successfully removed {path}"
|
|
)
|
|
raise
|
|
|
|
|
|
def get_total_file_size(path: str | pathlib.Path) -> int | None:
|
|
"""Return the size of all files under given path, including files in subdirectories.
|
|
|
|
Args:
|
|
path: The absolute path of a local directory.
|
|
|
|
Returns:
|
|
size in bytes.
|
|
|
|
"""
|
|
try:
|
|
if isinstance(path, pathlib.Path):
|
|
path = str(path)
|
|
if not os.path.exists(path):
|
|
raise MlflowException(
|
|
message=f"The given {path} does not exist.", error_code=INVALID_PARAMETER_VALUE
|
|
)
|
|
if not os.path.isdir(path):
|
|
raise MlflowException(
|
|
message=f"The given {path} is not a directory.", error_code=INVALID_PARAMETER_VALUE
|
|
)
|
|
|
|
total_size = 0
|
|
for cur_path, dirs, files in os.walk(path):
|
|
full_paths = [os.path.join(cur_path, file) for file in files]
|
|
total_size += sum(map(os.path.getsize, full_paths))
|
|
return total_size
|
|
except Exception as e:
|
|
_logger.info(f"Failed to get the total size of {path} because of error :{e}")
|
|
return None
|
|
|
|
|
|
def write_yaml(
|
|
root: str,
|
|
file_name: str,
|
|
data: dict[str, Any],
|
|
overwrite: bool = False,
|
|
sort_keys: bool = True,
|
|
ensure_yaml_extension: bool = True,
|
|
) -> None:
|
|
"""
|
|
NEVER TOUCH THIS FUNCTION. KEPT FOR BACKWARD COMPATIBILITY with
|
|
databricks-feature-engineering<=0.10.2
|
|
"""
|
|
import yaml
|
|
|
|
with open(os.path.join(root, file_name), "w") as f:
|
|
yaml.safe_dump(
|
|
data,
|
|
f,
|
|
default_flow_style=False,
|
|
allow_unicode=True,
|
|
sort_keys=sort_keys,
|
|
)
|
|
|
|
|
|
def read_yaml(root: str, file_name: str) -> dict[str, Any]:
|
|
"""
|
|
NEVER TOUCH THIS FUNCTION. KEPT FOR BACKWARD COMPATIBILITY with
|
|
databricks-feature-engineering<=0.10.2
|
|
"""
|
|
import yaml
|
|
|
|
with open(os.path.join(root, file_name)) as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
class ExclusiveFileLock:
|
|
"""
|
|
Exclusive file lock (only works on Unix system)
|
|
"""
|
|
|
|
def __init__(self, path: str):
|
|
if os.name == "nt":
|
|
raise MlflowException("ExclusiveFileLock class does not support Windows system.")
|
|
self.path = path
|
|
self.fd = None
|
|
|
|
def __enter__(self) -> None:
|
|
# Python on Windows does not have `fcntl` module, so importing it lazily.
|
|
import fcntl # clint: disable=lazy-import
|
|
|
|
# Open file (create if missing)
|
|
self.fd = open(self.path, "w")
|
|
# Acquire exclusive lock (blocking)
|
|
fcntl.flock(self.fd, fcntl.LOCK_EX)
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_val: BaseException | None,
|
|
exc_tb: TracebackType | None,
|
|
):
|
|
# Python on Windows does not have `fcntl` module, so importing it lazily.
|
|
import fcntl # clint: disable=lazy-import
|
|
|
|
# Release lock
|
|
fcntl.flock(self.fd, fcntl.LOCK_UN)
|
|
self.fd.close()
|
|
|
|
|
|
def check_tarfile_security(archive_path: str) -> None:
|
|
"""
|
|
Check the tar file content.
|
|
If its members contain any of the following paths:
|
|
* An absolute path.
|
|
* A relative path that escapes the extraction directory.
|
|
* A relative path that goes through a symlink.
|
|
then raise an error.
|
|
"""
|
|
with tarfile.open(archive_path, "r") as tar:
|
|
symlink_set = set()
|
|
for m in tar.getmembers():
|
|
# Normalize backslashes to forward slashes before path validation to prevent
|
|
# bypass on Windows where backslashes are treated as directory separators.
|
|
path = posixpath.normpath(m.name.replace("\\", "/"))
|
|
_check_path_is_safe(path)
|
|
if m.issym():
|
|
symlink_set.add(path)
|
|
elif m.islnk():
|
|
symlink_set.add(path)
|
|
# Hard link targets are dangerous: tar.extract creates an actual hard
|
|
# link to the target path, so validate they don't escape.
|
|
link_target = posixpath.normpath(m.linkname.replace("\\", "/"))
|
|
_check_path_is_safe(link_target, context=f"hard link target of {path}")
|
|
link_parent = posixpath.dirname(path)
|
|
resolved = posixpath.normpath(posixpath.join(link_parent, link_target))
|
|
if resolved == ".." or resolved.startswith("../"):
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Hard link target that escapes the extraction directory is not "
|
|
f"allowed, but got {path} -> {link_target}."
|
|
)
|
|
for m in tar.getmembers():
|
|
if not m.issym() and not m.islnk():
|
|
path = posixpath.normpath(m.name.replace("\\", "/"))
|
|
path_parts = path.split("/")
|
|
for prefix_len in range(1, len(path_parts) + 1):
|
|
prefix_path = "/".join(path_parts[:prefix_len])
|
|
if prefix_path in symlink_set:
|
|
raise MlflowException.invalid_parameter_value(
|
|
"Destination path in the archive file can not go through a symlink, "
|
|
f"but got path {path}."
|
|
)
|
|
|
|
|
|
def _check_path_is_safe(path: str, context: str = "") -> None:
|
|
label = f" ({context})" if context else ""
|
|
# Reject Unix absolute paths
|
|
if path.startswith("/"):
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Absolute path destination in the archive file is not allowed, "
|
|
f"but got path {path}{label}."
|
|
)
|
|
# Reject Windows drive-letter absolute paths (e.g., C:/...) and UNC paths (//server/...)
|
|
if len(path) >= 3 and path[0].isalpha() and path[1:3] == ":/":
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Absolute path destination in the archive file is not allowed, "
|
|
f"but got path {path}{label}."
|
|
)
|
|
path_parts = path.split("/")
|
|
if path_parts[0] == "..":
|
|
raise MlflowException.invalid_parameter_value(
|
|
f"Escaped path destination in the archive file is not allowed, "
|
|
f"but got path {path}{label}."
|
|
)
|