Skip to content

Commit b3f3daa

Browse files
authored
Support uv editable installs; fix cwd editable detection and secret mounts (#560)
Signed-off-by: redartera <reda@artera.ai>
1 parent adc1253 commit b3f3daa

File tree

6 files changed

+254
-33
lines changed

6 files changed

+254
-33
lines changed

src/flyte/_internal/imagebuild/docker_builder.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import click
1313

1414
from flyte import Secret
15+
from flyte._code_bundle._ignore import STANDARD_IGNORE_PATTERNS
1516
from flyte._image import (
1617
AptPackages,
1718
Commands,
@@ -38,7 +39,11 @@
3839
LocalDockerCommandImageChecker,
3940
LocalPodmanCommandImageChecker,
4041
)
41-
from flyte._internal.imagebuild.utils import copy_files_to_context, get_and_list_dockerignore
42+
from flyte._internal.imagebuild.utils import (
43+
copy_files_to_context,
44+
get_and_list_dockerignore,
45+
get_uv_editable_install_mounts,
46+
)
4247
from flyte._logging import logger
4348

4449
_F_IMG_ID = "_F_IMG_ID"
@@ -49,6 +54,7 @@
4954
RUN --mount=type=cache,sharing=locked,mode=0777,target=/root/.cache/uv,id=uv \
5055
--mount=type=bind,target=uv.lock,src=$UV_LOCK_PATH,rw \
5156
--mount=type=bind,target=pyproject.toml,src=$PYPROJECT_PATH \
57+
$EDITABLE_INSTALL_MOUNTS \
5258
$SECRET_MOUNT \
5359
VIRTUAL_ENV=$${VIRTUAL_ENV-/opt/venv} uv sync --active --inexact $PIP_INSTALL_ARGS
5460
""")
@@ -271,16 +277,24 @@ async def handle(
271277
pip_install_args = " ".join(layer.get_pip_install_args())
272278
if "--no-install-project" not in pip_install_args:
273279
pip_install_args += " --no-install-project"
274-
if "--no-sources" not in pip_install_args:
275-
pip_install_args += " --no-sources"
276-
# Only Copy pyproject.yaml and uv.lock.
280+
# Only Copy pyproject.yaml and uv.lock from the project root.
277281
pyproject_dst = copy_files_to_context(layer.pyproject, context_path)
278282
uvlock_dst = copy_files_to_context(layer.uvlock, context_path)
283+
# Apply any editable install mounts to the template.
284+
editable_install_mounts = get_uv_editable_install_mounts(
285+
project_root=layer.pyproject.parent,
286+
context_path=context_path,
287+
ignore_patterns=[
288+
*STANDARD_IGNORE_PATTERNS,
289+
*docker_ignore_patterns,
290+
],
291+
)
279292
delta = UV_LOCK_WITHOUT_PROJECT_INSTALL_TEMPLATE.substitute(
280293
UV_LOCK_PATH=uvlock_dst.relative_to(context_path),
281294
PYPROJECT_PATH=pyproject_dst.relative_to(context_path),
282295
PIP_INSTALL_ARGS=pip_install_args,
283296
SECRET_MOUNT=secret_mounts,
297+
EDITABLE_INSTALL_MOUNTS=editable_install_mounts,
284298
)
285299
else:
286300
# Copy the entire project.
@@ -429,19 +443,19 @@ def _get_secret_command(secret: str | Secret) -> typing.List[str]:
429443
def _get_secret_mounts_layer(secrets: typing.Tuple[str | Secret, ...] | None) -> str:
430444
if secrets is None:
431445
return ""
432-
secret_mounts_layer = ""
446+
secret_mounts_layer = []
433447
for s in secrets:
434448
secret = Secret(key=s) if isinstance(s, str) else s
435449
secret_id = hash(secret)
436450
if secret.mount:
437-
secret_mounts_layer += f"--mount=type=secret,id={secret_id},target={secret.mount}"
451+
secret_mounts_layer.append(f"--mount=type=secret,id={secret_id},target={secret.mount}")
438452
elif secret.as_env_var:
439-
secret_mounts_layer += f"--mount=type=secret,id={secret_id},env={secret.as_env_var}"
453+
secret_mounts_layer.append(f"--mount=type=secret,id={secret_id},env={secret.as_env_var}")
440454
else:
441455
secret_default_env_key = "_".join(list(filter(None, (secret.group, secret.key))))
442-
secret_mounts_layer += f"--mount=type=secret,id={secret_id},env={secret_default_env_key}"
456+
secret_mounts_layer.append(f"--mount=type=secret,id={secret_id},env={secret_default_env_key}")
443457

444-
return secret_mounts_layer
458+
return " ".join(secret_mounts_layer)
445459

446460

447461
async def _process_layer(

src/flyte/_internal/imagebuild/remote_builder.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from flyteidl2.common import phase_pb2
1414

1515
import flyte
16+
from flyte._code_bundle._ignore import STANDARD_IGNORE_PATTERNS
1617
import flyte.errors
1718
from flyte import Image, remote
1819
from flyte._code_bundle._utils import tar_strip_file_attributes
@@ -34,7 +35,11 @@
3435
WorkDir,
3536
)
3637
from flyte._internal.imagebuild.image_builder import ImageBuilder, ImageChecker
37-
from flyte._internal.imagebuild.utils import copy_files_to_context, get_and_list_dockerignore
38+
from flyte._internal.imagebuild.utils import (
39+
copy_files_to_context,
40+
get_and_list_dockerignore,
41+
get_uv_project_editable_dependencies,
42+
)
3843
from flyte._internal.runtime.task_serde import get_security_context
3944
from flyte._logging import logger
4045
from flyte._secret import Secret
@@ -299,25 +304,42 @@ def _get_layers_proto(image: Image, context_path: Path) -> "image_definition_pb2
299304
)
300305
layers.append(pip_layer)
301306
elif isinstance(layer, UVProject):
302-
if layer.project_install_mode == "dependencies_only":
303-
# Copy pyproject itself
304-
pyproject_dst = copy_files_to_context(layer.pyproject, context_path)
305-
if pip_options.extra_args:
306-
if "--no-install-project" not in pip_options.extra_args:
307+
pyproject_dst = copy_files_to_context(layer.pyproject, context_path)
308+
# Keep track of the directory containing the pyproject.toml file
309+
# this is what should be passed to the UVProject image definition proto as 'pyproject'
310+
pyproject_dir_dst = pyproject_dst.parent
311+
312+
# Copy uv.lock itself
313+
uvlock_dst = copy_files_to_context(layer.uvlock, context_path)
314+
315+
# Handle the project install mode
316+
match layer.project_install_mode:
317+
case "dependencies_only":
318+
if pip_options.extra_args and ("--no-install-project" not in pip_options.extra_args):
307319
pip_options.extra_args += " --no-install-project"
308-
else:
309-
pip_options.extra_args = " --no-install-project"
310-
if "--no-sources" not in pip_options.extra_args:
311-
pip_options.extra_args += " --no-sources"
312-
else:
313-
# Copy the entire project
314-
docker_ignore_patterns = get_and_list_dockerignore(image)
315-
pyproject_dst = copy_files_to_context(layer.pyproject.parent, context_path, docker_ignore_patterns)
320+
# Copy any editable dependencies to the context
321+
# We use the docker ignore patterns to avoid copying the editable dependencies to the context.
322+
docker_ignore_patterns = get_and_list_dockerignore(image)
323+
standard_ignore_patterns = STANDARD_IGNORE_PATTERNS.copy()
324+
for editable_dep in get_uv_project_editable_dependencies(layer.pyproject.parent):
325+
copy_files_to_context(
326+
editable_dep,
327+
context_path,
328+
ignore_patterns=[*standard_ignore_patterns, *docker_ignore_patterns],
329+
)
330+
case "install_project":
331+
# Copy the entire project
332+
docker_ignore_patterns = get_and_list_dockerignore(image)
333+
pyproject_dir_dst = copy_files_to_context(layer.pyproject.parent, context_path, docker_ignore_patterns)
334+
case _:
335+
raise ValueError(f"Invalid project install mode: {layer.project_install_mode}")
316336

317337
uv_layer = image_definition_pb2.Layer(
318338
uv_project=image_definition_pb2.UVProject(
319-
pyproject=str(pyproject_dst.relative_to(context_path)),
320-
uvlock=str(copy_files_to_context(layer.uvlock, context_path).relative_to(context_path)),
339+
# NOTE: UVProject expects 'pyproject' to be the directory containing the pyproject.toml file
340+
# whereas it expects 'uvlock' to be the path to the uv.lock file itself.
341+
pyproject=str(pyproject_dir_dst.relative_to(context_path)),
342+
uvlock=str(uvlock_dst.relative_to(context_path)),
321343
options=pip_options,
322344
secret_mounts=secret_mounts,
323345
)

src/flyte/_internal/imagebuild/utils.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import re
12
import shutil
3+
import subprocess
24
from pathlib import Path, PurePath
35
from typing import List, Optional
46

7+
from flyte._code_bundle._ignore import STANDARD_IGNORE_PATTERNS
58
from flyte._image import DockerIgnore, Image
69
from flyte._logging import logger
710

@@ -77,3 +80,65 @@ def get_and_list_dockerignore(image: Image) -> List[str]:
7780
logger.error(f"Failed to read .dockerignore file at {dockerignore_path}: {e}")
7881
return []
7982
return patterns
83+
84+
85+
def _extract_editables_from_uv_export(project_root: Path) -> list[str]:
86+
"""Extracts editable dependencies from a uv export output."""
87+
uv_export = subprocess.run(
88+
["uv", "export", "--no-emit-project"], cwd=project_root, capture_output=True, text=True, check=True
89+
)
90+
matches = []
91+
for line in uv_export.stdout.splitlines():
92+
if match := re.search(r"-e\s+([^\s]+)", line):
93+
matches.append(match.group(1))
94+
return matches
95+
96+
97+
def get_uv_project_editable_dependencies(project_root: Path) -> list[Path]:
98+
"""Parses uv export output to find editable path dependencies for a given project.
99+
100+
Args:
101+
project_root: Root of the uv project to inspect.
102+
103+
Returns:
104+
A list of local paths referenced as editable dependencies.
105+
"""
106+
paths = []
107+
for match in _extract_editables_from_uv_export(project_root):
108+
# If the the path is absolute already, keep as-is
109+
# otherwise we need to complete it by pre-pending the project root where 'uv export' was run from.
110+
resolved_path = Path(match) if Path(match).is_absolute() else (project_root / match)
111+
# Raise an error if the path isn't a child of the project root
112+
if not resolved_path.is_relative_to(project_root):
113+
raise ValueError(
114+
"Editable dependency paths must be within the project root, this is not supported."
115+
f"Found {resolved_path=} outside of {project_root=}."
116+
)
117+
paths.append(resolved_path)
118+
return paths
119+
120+
121+
def get_uv_editable_install_mounts(
122+
project_root: Path, context_path: Path, ignore_patterns: list[str] | None = None
123+
) -> str:
124+
"""Builds Docker bind mounts for uv editable path dependencies.
125+
126+
Args:
127+
project_root: Root of the uv project to inspect.
128+
context_path: Build context directory for Docker.
129+
ignore_patterns: A list of ignore patterns to apply when copying editable dependency contents.
130+
If None, the standard ignore patterns of 'StandardIgnore' will be used.
131+
Returns:
132+
A string of Docker bind-mount arguments for editable dependencies.
133+
"""
134+
ignore_patterns = ignore_patterns or STANDARD_IGNORE_PATTERNS.copy()
135+
mounts = []
136+
for editable_dep in get_uv_project_editable_dependencies(project_root):
137+
# Copy the contents of the editable install by applying ignores
138+
editable_dep_within_context = copy_files_to_context(editable_dep, context_path, ignore_patterns=ignore_patterns)
139+
mounts.append(
140+
"--mount=type=bind,"
141+
f"src={editable_dep_within_context.relative_to(context_path)},"
142+
f"target={editable_dep.relative_to(project_root)}"
143+
)
144+
return " ".join(mounts)

src/flyte/_utils/helpers.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,20 @@ def get_cwd_editable_install() -> typing.Optional[Path]:
106106
# - if cwd is nested inside the editable install folder.
107107
# - if the cwd is exactly one level above the editable install folder.
108108
cwd = Path.cwd()
109+
110+
# First, prioritize editable installs that are parents of cwd
109111
for install in editable_installs:
110112
# child.is_relative_to(parent) is True if child is inside parent
111113
if cwd.is_relative_to(install):
112114
return install
113-
else:
114-
# check if the cwd is one level above the install folder
115-
if install.parent == cwd:
116-
# check if the install folder contains a pyproject.toml file
117-
if (cwd / "pyproject.toml").exists() or (cwd / "setup.py").exists():
118-
return install # note we want the install folder, not the parent
115+
116+
# Then maybe see if one of the installs is exactly one level above cwd
117+
for install in editable_installs:
118+
# check if the cwd is one level above the install folder
119+
if install.parent == cwd:
120+
# check if the install folder contains a pyproject.toml file
121+
if (cwd / "pyproject.toml").exists() or (cwd / "setup.py").exists():
122+
return install # note we want the install folder, not the parent
119123

120124
return None
121125

tests/flyte/imagebuild/test_docker_builder.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import asyncio
2+
import subprocess
13
import tempfile
24
from pathlib import Path, PurePath
35
from unittest.mock import patch
46

57
import pytest
8+
import pytest_asyncio
69

710
from flyte import Secret
8-
from flyte._image import Image, PipPackages, PoetryProject, Requirements
11+
from flyte._image import Image, PipPackages, PoetryProject, Requirements, UVProject
912
from flyte._internal.imagebuild.docker_builder import (
1013
CopyConfig,
1114
CopyConfigHandler,
@@ -429,3 +432,55 @@ async def test_uvproject_handler_with_project_install():
429432
assert (expected_dst_path / "uv.lock").exists(), "uv.lock should be included"
430433
assert not (expected_dst_path / "memo.txt").exists(), "memo.txt should be excluded"
431434
assert not (expected_dst_path / ".cache").exists(), ".cache directory should be excluded"
435+
436+
437+
@pytest_asyncio.fixture
438+
async def uv_project_with_editable(tmp_path: Path):
439+
"""An empty uv project with a single editable dependency"""
440+
441+
async def _uv(cmd: list[str], cwd: Path):
442+
return await asyncio.to_thread(
443+
subprocess.run, ["uv", *cmd], cwd=str(cwd), capture_output=True, text=True, check=True
444+
)
445+
446+
project_root = tmp_path / "project"
447+
project_root.mkdir(parents=True)
448+
# Create a main project
449+
await _uv(["init", "--lib"], project_root)
450+
# Create an editable dependency
451+
dep_folder = project_root / "libs" / "editable_dep"
452+
dep_folder.mkdir(parents=True)
453+
# Create an editable dependency project and add it to the main project
454+
await _uv(["init", "--lib"], dep_folder)
455+
await _uv(["add", "--editable", "./libs/editable_dep", "--no-sync"], project_root)
456+
# Generate a lock file for the main project
457+
await _uv(["lock"], project_root)
458+
yield project_root, dep_folder
459+
460+
461+
@pytest.mark.asyncio
462+
async def test_uvproject_handler_includes_editable_mounts_in_dependencies_only_mode(uv_project_with_editable):
463+
with tempfile.TemporaryDirectory() as tmp_context:
464+
context_path = Path(tmp_context)
465+
466+
project_root, dep_folder = uv_project_with_editable
467+
pyproject_file = project_root / "pyproject.toml"
468+
uv_lock_file = project_root / "uv.lock"
469+
470+
uv_project = UVProject(
471+
pyproject=pyproject_file.absolute(),
472+
uvlock=uv_lock_file.absolute(),
473+
project_install_mode="dependencies_only",
474+
)
475+
476+
initial_dockerfile = "FROM python:3.9\n"
477+
result = await UVProjectHandler.handle(
478+
layer=uv_project,
479+
context_path=context_path,
480+
dockerfile=initial_dockerfile,
481+
docker_ignore_patterns=[],
482+
)
483+
expected_dep_in_context = "_flyte_abs_context" + str(dep_folder)
484+
expected_dep_in_container = dep_folder.relative_to(project_root)
485+
expected_mount = f"--mount=type=bind,src={expected_dep_in_context},target={expected_dep_in_container}"
486+
assert expected_mount in result

0 commit comments

Comments
 (0)