Skip to content

Commit 5d40f98

Browse files
authored
Merge branch 'main' into worktree-deepseek-harness-plugin
2 parents 8d7aa11 + 7e2d423 commit 5d40f98

9 files changed

Lines changed: 929 additions & 5 deletions

File tree

examples/image/pixi_image.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# /// script
2+
# requires-python = ">=3.12"
3+
# dependencies = [
4+
# "flyte",
5+
# ]
6+
#
7+
# [tool.pixi.workspace]
8+
# channels = ["conda-forge"]
9+
#
10+
# [tool.pixi.dependencies]
11+
# numpy = "*"
12+
# ///
13+
"""Build a task image from a pixi script.
14+
15+
`Image.from_pixi_script` is the pixi counterpart to `Image.from_uv_script`: the image is
16+
described by the PEP 723 block at the top of this very file. What pixi adds over uv is the
17+
conda side of the world — `[tool.pixi.dependencies]` pulls packages from conda channels,
18+
which is how you get binaries (MKL builds, GDAL, CUDA toolkits) that are not on PyPI.
19+
20+
Note that `flyte` itself has to be listed in `dependencies`: unlike `from_debian_base`, a
21+
script-defined image installs exactly what the script declares.
22+
23+
`[tool.pixi.workspace]` deliberately leaves `platforms` out here, so flyte fills it in from
24+
the platforms the image is built for. Declare it explicitly if you want to pin the
25+
resolution — for instance before running `pixi lock --script` to produce a
26+
`pixi_image.py.pixi.lock` sidecar, which flyte then installs with `--locked`.
27+
28+
Run it with:
29+
30+
python examples/image/pixi_image.py
31+
"""
32+
33+
import flyte
34+
from flyte import Image
35+
36+
image = Image.from_pixi_script(__file__, name="pixi-hello", registry="ghcr.io/flyteorg")
37+
38+
env = flyte.TaskEnvironment(name="pixi_hello", image=image)
39+
40+
41+
@env.task
42+
async def mean(values: list[float]) -> float:
43+
# numpy comes from the conda channel via [tool.pixi.dependencies].
44+
import numpy as np
45+
46+
return float(np.mean(values))
47+
48+
49+
@env.task
50+
async def main(values: list[float] | None = None) -> str:
51+
values = values or [1.0, 2.0, 3.0, 4.0]
52+
return f"mean({values}) = {await mean(values)}"
53+
54+
55+
if __name__ == "__main__":
56+
flyte.init_from_config()
57+
run = flyte.run(main)
58+
print(run.name)
59+
print(run.url)
60+
run.wait()

src/flyte/_image.py

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,75 @@ def update_hash(self, hasher: hashlib._Hash, ignore: Optional[Any] = None):
350350
).update_hash(hasher, ignore=ignore)
351351

352352

353+
@rich.repr.auto
354+
@dataclass(frozen=True, repr=True)
355+
class PixiScript(Layer):
356+
"""
357+
A standalone Python script whose dependencies are declared in a PEP 723 block with
358+
pixi-specific `[tool.pixi.*]` tables.
359+
360+
Pixi has no `install --script`, so at build time the script's metadata is lowered into
361+
an equivalent `pixi.toml` workspace manifest and installed like any other
362+
[`PixiProject`][flyte.Image.with_pixi_project]. Like `PixiProject`, pixi resolves conda
363+
and PyPI packages from its own manifest, so this layer does not inherit `PipOption`.
364+
"""
365+
366+
script: Path
367+
script_name: str = field(init=False)
368+
#: Conda subdirs the generated workspace supports, derived from the image's platforms.
369+
#: A `platforms` key declared by the script itself takes precedence over these.
370+
platforms: Tuple[str, ...] = ("linux-64",)
371+
environment: str = "default"
372+
extra_args: Optional[str] = None
373+
secret_mounts: Optional[Tuple[str | Secret, ...]] = None
374+
375+
def __post_init__(self):
376+
object.__setattr__(self, "script_name", self.script.name)
377+
super().__post_init__()
378+
379+
@property
380+
def pixi_lock(self) -> Optional[Path]:
381+
"""The script's sidecar lock file (`<script>.pixi.lock`), if `pixi lock --script` made one."""
382+
lock = self.script.with_name(f"{self.script.name}.pixi.lock")
383+
return lock if lock.exists() else None
384+
385+
def validate(self):
386+
if not self.script.exists():
387+
raise FileNotFoundError(f"Pixi script {self.script} does not exist")
388+
if not self.script.is_file():
389+
raise ValueError(f"Pixi script {self.script} is not a file")
390+
if self.script.suffix != ".py":
391+
raise ValueError(f"Pixi script {self.script} must have a .py extension")
392+
393+
from ._utils import check_pixi_platforms_supported, parse_pixi_script_file
394+
395+
check_pixi_platforms_supported(parse_pixi_script_file(self.script), self.platforms)
396+
super().validate()
397+
398+
def render_manifest(self) -> str:
399+
"""The source of the `pixi.toml` this script lowers to."""
400+
from ._utils import parse_pixi_script_file, render_pixi_manifest
401+
402+
return render_pixi_manifest(parse_pixi_script_file(self.script), self.platforms)
403+
404+
def update_hash(self, hasher: hashlib._Hash, ignore: Optional[Any] = None):
405+
# Hash the generated manifest rather than the script body: only the PEP 723 block
406+
# affects the image, so edits to the script's code reuse the built image.
407+
hash_input = self.render_manifest() + self.environment
408+
if self.extra_args:
409+
hash_input += self.extra_args
410+
if self.secret_mounts:
411+
for secret_mount in self.secret_mounts:
412+
hash_input += str(secret_mount)
413+
hasher.update(hash_input.encode("utf-8"))
414+
415+
pixi_lock = self.pixi_lock
416+
if pixi_lock is not None:
417+
from ._utils import filehash_update
418+
419+
filehash_update(pixi_lock, hasher)
420+
421+
353422
@rich.repr.auto
354423
@dataclass(frozen=True, repr=True)
355424
class AptPackages(Layer):
@@ -515,6 +584,9 @@ def from_dict(cls, envs: Dict[str, str]) -> Env:
515584

516585
Architecture = Literal["linux/amd64", "linux/arm64"]
517586

587+
# Platforms a default image is built for when the caller does not name any.
588+
_DEFAULT_PLATFORMS: Tuple[Architecture, ...] = ("linux/amd64", "linux/arm64")
589+
518590
_BASE_REGISTRY = "ghcr.io/flyteorg"
519591
_LOCALHOST_REGISTRY = "localhost:30000"
520592
_DEFAULT_IMAGE_NAME = "flyte"
@@ -610,6 +682,7 @@ class Image:
610682
- `from_debian_base()` — Debian-based image with a specified Python version
611683
- `from_base()` — Any base image by name (e.g., `"python:3.12-slim"`)
612684
- `from_uv_script()` — Image from a `uv`-compatible script with inline dependencies
685+
- `from_pixi_script()` — Image from a `pixi`-compatible script with inline dependencies
613686
- `from_dockerfile()` — Image from a custom Dockerfile
614687
- `from_ref_name()` — Reference to a pre-built image by name
615688
@@ -719,15 +792,15 @@ def _get_default_image_for(
719792
registry=_get_push_registry(),
720793
name=_DEFAULT_IMAGE_NAME,
721794
python_version=python_version,
722-
platform=("linux/amd64", "linux/arm64") if platform is None else platform,
795+
platform=_DEFAULT_PLATFORMS if platform is None else platform,
723796
extendable=True,
724797
)
725798
image = Image._new(
726799
base_image=f"python:{python_version[0]}.{python_version[1]}-slim-bookworm",
727800
registry=_get_push_registry(),
728801
name=_DEFAULT_IMAGE_NAME,
729802
python_version=python_version,
730-
platform=("linux/amd64", "linux/arm64") if platform is None else platform,
803+
platform=_DEFAULT_PLATFORMS if platform is None else platform,
731804
extendable=True,
732805
)
733806
labels = _DockerLines(
@@ -930,6 +1003,99 @@ def from_uv_script(
9301003

9311004
return img.clone(addl_layer=ll)
9321005

1006+
@classmethod
1007+
def from_pixi_script(
1008+
cls,
1009+
script: Path | str,
1010+
*,
1011+
name: str,
1012+
registry: str | None = None,
1013+
registry_secret: Optional[str | Secret] = None,
1014+
environment: str = "default",
1015+
extra_args: Optional[str] = None,
1016+
platform: Optional[Tuple[Architecture, ...]] = None,
1017+
secret_mounts: Optional[SecretRequest] = None,
1018+
) -> Image:
1019+
"""
1020+
Create an image from a `pixi`-compatible script, using the PEP 723 block at the top of
1021+
the script to determine the Python version and the conda and PyPI packages to install.
1022+
1023+
A pixi script declares portable PEP 723 fields alongside pixi-specific `[tool.pixi.*]`
1024+
tables, which is what lets it pull conda packages that `from_uv_script` cannot:
1025+
1026+
```python
1027+
#!/usr/bin/env -S pixi run --script
1028+
# /// script
1029+
# requires-python = ">=3.12"
1030+
# dependencies = ["flyte"]
1031+
#
1032+
# [tool.pixi.workspace]
1033+
# channels = ["conda-forge"]
1034+
#
1035+
# [tool.pixi.dependencies]
1036+
# gdal = "*"
1037+
# ///
1038+
```
1039+
1040+
`requires-python` becomes the environment's Python (an explicit
1041+
`[tool.pixi.dependencies].python` wins over it), `dependencies` become PyPI packages, and
1042+
`[tool.pixi.dependencies]` become conda packages. Channels default to `conda-forge` and
1043+
the supported platforms default to those of the image, unless `[tool.pixi.workspace]`
1044+
declares its own. Every other `[tool.pixi.*]` table (`target`, `feature`, `activation`,
1045+
...) is passed through to pixi untouched.
1046+
1047+
If a sidecar lock file created by `pixi lock --script <script>` sits next to the script
1048+
as `<script>.pixi.lock`, it is used and the install is run with `--locked`. Note that
1049+
pixi locks only the platforms the workspace declares, so to lock an image built for
1050+
`linux/amd64` the script should declare `platforms = ["linux-64"]` under
1051+
`[tool.pixi.workspace]` before locking.
1052+
1053+
Unlike `from_uv_script`, flyte is not installed for you: list `flyte` in the script's
1054+
`dependencies` so the task can run in the resulting environment.
1055+
1056+
For more information on the pixi script format, see the documentation:
1057+
[Pixi: Python scripts](https://pixi.prefix.dev/latest/python/scripts/)
1058+
1059+
Args:
1060+
script: path to the pixi script
1061+
name: name of the image
1062+
registry: registry to use for the image
1063+
registry_secret: Secret to use to pull/push the private image.
1064+
environment: pixi environment to install, default is "default"
1065+
extra_args: extra arguments to pass to `pixi install`, default is None
1066+
platform: architecture to use for the image, default is linux/amd64, use tuple for
1067+
multiple values
1068+
secret_mounts: Secret mounts to use for the image, default is None.
1069+
1070+
Returns:
1071+
Image
1072+
"""
1073+
from ._utils import pixi_platforms_for
1074+
1075+
# The generated manifest must name the platforms it supports, so resolve them from the
1076+
# image's platforms up front and keep them on the layer: the layer has no way to reach
1077+
# back to the image at build time. Mirrors the multi-arch default `from_debian_base`
1078+
# applies when no platform is given.
1079+
resolved_platform = platform or _DEFAULT_PLATFORMS
1080+
1081+
ll = PixiScript(
1082+
script=Path(script),
1083+
platforms=pixi_platforms_for(resolved_platform),
1084+
environment=environment,
1085+
extra_args=extra_args,
1086+
secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
1087+
)
1088+
1089+
img = cls.from_debian_base(
1090+
registry=registry,
1091+
registry_secret=registry_secret,
1092+
install_flyte=False,
1093+
name=name,
1094+
platform=platform,
1095+
)
1096+
1097+
return img.clone(addl_layer=ll)
1098+
9331099
def clone(
9341100
self,
9351101
registry: Optional[str] = None,

src/flyte/_internal/imagebuild/docker_builder.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
PipOption,
2828
PipPackages,
2929
PixiProject,
30+
PixiScript,
3031
PoetryProject,
3132
PythonWheels,
3233
Requirements,
@@ -50,6 +51,7 @@
5051
copy_files_to_context,
5152
get_and_list_dockerignore,
5253
get_uv_editable_install_mounts,
54+
pixi_script_to_project,
5355
)
5456
from flyte._logging import logger
5557
from flyte._utils.asyncify import run_sync_with_loop
@@ -553,7 +555,7 @@ def _get_secret_command(secret: str | Secret) -> typing.List[str]:
553555
return ["--secret", f"id={secret_id},src={secret_file_path}"]
554556

555557
for layer in layers:
556-
if isinstance(layer, (PipOption, AptPackages, Commands, PixiProject)):
558+
if isinstance(layer, (PipOption, AptPackages, Commands, PixiProject, PixiScript)):
557559
if layer.secret_mounts:
558560
for secret_mount in layer.secret_mounts:
559561
secret = Secret(key=secret_mount) if isinstance(secret_mount, str) else secret_mount
@@ -651,6 +653,12 @@ async def _process_layer(
651653
# Handle pixi project
652654
dockerfile = await PixiProjectHandler.handle(layer, context_path, dockerfile, docker_ignore_patterns)
653655

656+
case PixiScript():
657+
# A pixi script installs as the pixi project its PEP 723 metadata describes.
658+
dockerfile = await PixiProjectHandler.handle(
659+
pixi_script_to_project(layer), context_path, dockerfile, docker_ignore_patterns
660+
)
661+
654662
case CopyConfig():
655663
# Handle local files and folders
656664
dockerfile = await CopyConfigHandler.handle(layer, context_path, dockerfile, docker_ignore_patterns)

src/flyte/_internal/imagebuild/remote_builder.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
PipOption,
3030
PipPackages,
3131
PixiProject,
32+
PixiScript,
3233
PoetryProject,
3334
PythonWheels,
3435
Requirements,
@@ -42,6 +43,7 @@
4243
get_and_list_dockerignore,
4344
get_uv_project_editable_dependencies,
4445
pixi_project_to_primitive_layers,
46+
pixi_script_to_project,
4547
)
4648
from flyte._internal.runtime.task_serde import get_security_context
4749
from flyte._logging import logger
@@ -235,7 +237,9 @@ def _get_layers_proto(image: Image, context_path: Path) -> "image_definition_pb2
235237
# layers (apt / copy / commands / env) that the remote builder understands.
236238
expanded_layers: typing.List[typing.Any] = []
237239
for layer in image._layers:
238-
if isinstance(layer, PixiProject):
240+
if isinstance(layer, PixiScript):
241+
expanded_layers.extend(pixi_project_to_primitive_layers(pixi_script_to_project(layer)))
242+
elif isinstance(layer, PixiProject):
239243
expanded_layers.extend(pixi_project_to_primitive_layers(layer))
240244
else:
241245
expanded_layers.append(layer)
@@ -470,7 +474,10 @@ def _get_build_secrets_from_image(image: Image) -> Optional[typing.List[Secret]]
470474
seen_secrets: typing.Set[typing.Tuple[typing.Optional[str], str]] = set()
471475
DEFAULT_SECRET_DIR = Path("/etc/flyte/secrets")
472476
for layer in image._layers:
473-
if isinstance(layer, (PipOption, Commands, AptPackages, PixiProject)) and layer.secret_mounts is not None:
477+
if (
478+
isinstance(layer, (PipOption, Commands, AptPackages, PixiProject, PixiScript))
479+
and layer.secret_mounts is not None
480+
):
474481
for secret_mount in layer.secret_mounts:
475482
# Mount all the image secrets to a default directory that will be passed to the BuildKit server.
476483
if isinstance(secret_mount, Secret):

0 commit comments

Comments
 (0)