From 1e36f7430322b0fa761ac9df9875d87f135f4cd3 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:30:01 -0700 Subject: [PATCH 01/16] feat(image): add tag= param to Image.clone() Add tag: Optional[str] = None parameter to clone() method, allowing users to override the content-hash-based tag with an explicit tag. Empty strings are normalized to None to fall back to content-hash-based tagging. Signed-off-by: Jeev B --- src/flyte/_image.py | 5 +++++ tests/flyte/test_image.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 46a352da5..4fe61df84 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -727,6 +727,7 @@ def clone( python_version: Optional[Tuple[int, int]] = None, addl_layer: Optional[Layer] = None, extendable: Optional[bool] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to clone the current image and change the registry and name @@ -779,6 +780,10 @@ def clone( _ref_name=self._ref_name, ) + tag = tag or None # normalize empty string to None + if tag is not None: + object.__setattr__(img, "_tag", tag) + return img @classmethod diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index a82b2b183..4d76f71eb 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -525,3 +525,26 @@ def test_resolve_code_bundle_loaded_modules_copy_style_none(tmp_path): assert bundle_layers[0].root_dir == tmp_path assert bundle_layers[0].copy_style == "loaded_modules" assert bundle_layers[0].dst == "." + + +def test_clone_with_explicit_tag(): + img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) + cloned = img.clone(registry="other-reg", name="other-img", tag="v1.2.3") + assert cloned._tag == "v1.2.3" + assert cloned.uri == "other-reg/other-img:v1.2.3" + + +def test_clone_tag_not_inherited_from_source(): + # clone() never inherits _tag from source — always fresh content hash unless tag= given + img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) + # default image has _tag set (version-based); clone without tag= should content-hash + cloned = img.clone(registry="other-reg", name="other-img") + assert cloned._tag is None + assert cloned.uri.startswith("other-reg/other-img:") + assert cloned.uri != img.uri # different because hash differs (no version tag) + + +def test_clone_empty_string_tag_falls_back_to_content_hash(): + img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) + cloned = img.clone(registry="reg", name="img", tag="") + assert cloned._tag is None # empty string normalized to None From 8e8639b8f620573305565359732b38cf958d9a92 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:33:05 -0700 Subject: [PATCH 02/16] feat(image): add tag= param to Image.from_dockerfile() Signed-off-by: Jeev B --- src/flyte/_image.py | 11 ++++++++++- tests/flyte/test_image.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 4fe61df84..dbdf7999b 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -788,7 +788,12 @@ def clone( @classmethod def from_dockerfile( - cls, file: Path, registry: str, name: str, platform: Union[Architecture, Tuple[Architecture, ...], None] = None + cls, + file: Path, + registry: str, + name: str, + platform: Union[Architecture, Tuple[Architecture, ...], None] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified dockerfile. Note you cannot use additional layers @@ -817,6 +822,10 @@ def from_dockerfile( kwargs["platform"] = platform img = cls._new(**kwargs) + tag = tag or None # normalize empty string to None + if tag is not None: + object.__setattr__(img, "_tag", tag) + return img def _get_hash_digest(self) -> str: diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index 4d76f71eb..9493d7613 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -440,6 +440,21 @@ def test_from_dockerfile_is_not_extendable(): dockerfile_path.unlink() +def test_from_dockerfile_with_explicit_tag(tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\n") + img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="v2.0.0") + assert img._tag == "v2.0.0" + assert img.uri == "reg/my-img:v2.0.0" + + +def test_from_dockerfile_empty_string_tag_falls_back_to_content_hash(tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\n") + img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="") + assert img._tag is None + + def test_with_code_bundle_defaults(): """with_code_bundle() creates a CodeBundleLayer with default values.""" img = Image.from_debian_base(registry="localhost", name="test-image").with_code_bundle() From 6b08e294b6b6d05feec93887a0bb75fbc3a109a0 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:36:46 -0700 Subject: [PATCH 03/16] feat(image): add tag= param to from_debian_base() and from_uv_script() Signed-off-by: Jeev B --- src/flyte/_image.py | 9 +++++++-- tests/flyte/test_image.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index dbdf7999b..5ca0c32b3 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -596,6 +596,7 @@ def from_debian_base( registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to start using the default base image, built from this library's base Dockerfile @@ -623,8 +624,11 @@ def from_debian_base( ) if registry or name: - return base_image.clone(registry=registry, name=name, registry_secret=registry_secret, extendable=True) + return base_image.clone(registry=registry, name=name, registry_secret=registry_secret, extendable=True, tag=tag) + tag = tag or None # normalize empty string to None + if tag is not None: + object.__setattr__(base_image, "_tag", tag) return base_image @classmethod @@ -660,6 +664,7 @@ def from_uv_script( extra_args: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, secret_mounts: Optional[SecretRequest] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified uv script. @@ -716,7 +721,7 @@ def from_uv_script( platform=platform, ) - return img.clone(addl_layer=ll) + return img.clone(addl_layer=ll, tag=tag) def clone( self, diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index 9493d7613..974ed8c7a 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -563,3 +563,25 @@ def test_clone_empty_string_tag_falls_back_to_content_hash(): img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) cloned = img.clone(registry="reg", name="img", tag="") assert cloned._tag is None # empty string normalized to None + + +def test_from_debian_base_with_explicit_tag(): + img = Image.from_debian_base(registry="reg", name="my-img", tag="v3.0.0", python_version=(3, 12)) + assert img._tag == "v3.0.0" + assert img.uri == "reg/my-img:v3.0.0" + + +def test_from_uv_script_with_explicit_tag(tmp_path): + script = tmp_path / "script.py" + script.write_text( + "# /// script\n# requires-python = '>=3.12'\n# dependencies = []\n# ///\nprint('hello')\n" + ) + img = Image.from_uv_script( + script=script, + name="my-script-img", + registry="reg", + python_version=(3, 12), + tag="v1.0.0", + ) + assert img._tag == "v1.0.0" + assert img.uri == "reg/my-script-img:v1.0.0" From 0b5f08f673920662cfe8df3d3b272a3a4fd79eed Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:39:37 -0700 Subject: [PATCH 04/16] feat(deploy): add force param to _build_image_bg Signed-off-by: Jeev B --- src/flyte/_deploy.py | 4 ++-- tests/flyte/test_deploy.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/flyte/_deploy.py b/src/flyte/_deploy.py index ca6d77f9b..8e0eba0f3 100644 --- a/src/flyte/_deploy.py +++ b/src/flyte/_deploy.py @@ -349,7 +349,7 @@ def _update_interface_inputs_and_outputs_docstring( return updated_interface -async def _build_image_bg(env_name: str, image: Image) -> Tuple[str, str, Optional[Any]]: +async def _build_image_bg(env_name: str, image: Image, force: bool = False) -> Tuple[str, str, Optional[Any]]: """ Build the image in the background and return the environment name, the built image URI, and the RunIdentifierData (if built by the remote image builder). @@ -358,7 +358,7 @@ async def _build_image_bg(env_name: str, image: Image) -> Tuple[str, str, Option from ._internal.imagebuild.image_builder import RunIdentifierData status.step(f"Building image {image.name} for environment {env_name}") - result = await build.aio(image) + result = await build.aio(image, force=force) assert result.uri is not None, "Image build result URI is None, make sure to wait for the build to complete" run_id_data = None if result.remote_run: diff --git a/tests/flyte/test_deploy.py b/tests/flyte/test_deploy.py index 48b8aded1..fa302e74a 100644 --- a/tests/flyte/test_deploy.py +++ b/tests/flyte/test_deploy.py @@ -354,3 +354,26 @@ async def test_build_images_no_build_run_urls_for_local_build(): assert cache.image_lookup["my-env"] == "registry/my-image:sha256abc" assert cache.build_run_ids == {} + +@pytest.mark.asyncio +async def test_build_image_bg_passes_force_to_build(): + """force=True is forwarded to build.aio.""" + image = flyte.Image.from_base("python:3.10") + mock_result = ImageBuild(uri="registry/my-image:abc", remote_run=None) + + with patch("flyte._build.build") as mock_build: + mock_build.aio = AsyncMock(return_value=mock_result) + await _build_image_bg("my-env", image, force=True) + mock_build.aio.assert_called_once_with(image, force=True) + + +@pytest.mark.asyncio +async def test_build_image_bg_force_defaults_to_false(): + """force defaults to False when not specified.""" + image = flyte.Image.from_base("python:3.10") + mock_result = ImageBuild(uri="registry/my-image:abc", remote_run=None) + + with patch("flyte._build.build") as mock_build: + mock_build.aio = AsyncMock(return_value=mock_result) + await _build_image_bg("my-env", image) + mock_build.aio.assert_called_once_with(image, force=False) From 0c5aadab3277f36bb50416a1a3a46eef2a07525a Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:42:05 -0700 Subject: [PATCH 05/16] =?UTF-8?q?feat(deploy):=20thread=20force=20param=20?= =?UTF-8?q?through=20build=5Fimages=20=E2=86=92=20=5Fbuild=5Fimages=20?= =?UTF-8?q?=E2=86=92=20=5Fbuild=5Fimage=5Fbg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jeev B --- src/flyte/_deploy.py | 10 +++++----- tests/flyte/test_deploy.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/flyte/_deploy.py b/src/flyte/_deploy.py index 8e0eba0f3..56026cf36 100644 --- a/src/flyte/_deploy.py +++ b/src/flyte/_deploy.py @@ -367,7 +367,7 @@ async def _build_image_bg(env_name: str, image: Image, force: bool = False) -> T return env_name, result.uri, run_id_data -async def _build_images(deployment: DeploymentPlan, image_refs: Dict[str, str] | None = None) -> ImageCache: +async def _build_images(deployment: DeploymentPlan, image_refs: Dict[str, str] | None = None, force: bool = False) -> ImageCache: """ Build the images for the given deployment plan and update the environment with the built image. """ @@ -397,7 +397,7 @@ async def _build_images(deployment: DeploymentPlan, image_refs: Dict[str, str] | image_identifier_map[env_name] = image_uri continue logger.debug(f"Building Image for environment {env_name}, image: {env.image}") - images.append(_build_image_bg(env_name, env.image)) + images.append(_build_image_bg(env_name, env.image, force=force)) elif env.image == "auto" and "auto" not in image_identifier_map: if _DEFAULT_IMAGE_REF_NAME in image_refs: @@ -406,7 +406,7 @@ async def _build_images(deployment: DeploymentPlan, image_refs: Dict[str, str] | image_identifier_map[env_name] = image_uri continue auto_image = Image.from_debian_base() - images.append(_build_image_bg(env_name, auto_image)) + images.append(_build_image_bg(env_name, auto_image, force=force)) if images: with status.group(f"Building {len(images)} image{'s' if len(images) > 1 else ''}..."): @@ -603,7 +603,7 @@ async def deploy( @syncify -async def build_images(envs: Environment) -> ImageCache: +async def build_images(envs: Environment, force: bool = False) -> ImageCache: """ Build the images for the given environments. :param envs: Environment to build images for. @@ -612,4 +612,4 @@ async def build_images(envs: Environment) -> ImageCache: cfg = get_init_config() images = cfg.images if cfg else {} deployment = plan_deploy(envs) - return await _build_images(deployment[0], images) + return await _build_images(deployment[0], images, force=force) diff --git a/tests/flyte/test_deploy.py b/tests/flyte/test_deploy.py index fa302e74a..5b91c8811 100644 --- a/tests/flyte/test_deploy.py +++ b/tests/flyte/test_deploy.py @@ -377,3 +377,31 @@ async def test_build_image_bg_force_defaults_to_false(): mock_build.aio = AsyncMock(return_value=mock_result) await _build_image_bg("my-env", image) mock_build.aio.assert_called_once_with(image, force=False) + + +@pytest.mark.asyncio +async def test_build_images_passes_force_to_bg(): + """force=True is forwarded through _build_images to _build_image_bg.""" + image = flyte.Image.from_base("python:3.10") + env = flyte.TaskEnvironment(name="my-env", image=image) + plan = DeploymentPlan(envs={"my-env": env}) + mock_result = ImageBuild(uri="registry/my-image:abc", remote_run=None) + + with patch("flyte._build.build") as mock_build: + mock_build.aio = AsyncMock(return_value=mock_result) + await _build_images(plan, force=True) + mock_build.aio.assert_called_once_with(image, force=True) + + +@pytest.mark.asyncio +async def test_build_images_force_defaults_to_false(): + """force defaults to False when not specified.""" + image = flyte.Image.from_base("python:3.10") + env = flyte.TaskEnvironment(name="my-env", image=image) + plan = DeploymentPlan(envs={"my-env": env}) + mock_result = ImageBuild(uri="registry/my-image:abc", remote_run=None) + + with patch("flyte._build.build") as mock_build: + mock_build.aio = AsyncMock(return_value=mock_result) + await _build_images(plan) + mock_build.aio.assert_called_once_with(image, force=False) From 10abdfd8f4a2c332eb1a08a5f95f6bd2ad0dd9b5 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:48:45 -0700 Subject: [PATCH 06/16] feat(cli): add --force flag to flyte build Adds a --force group-level option to the `flyte build` command that is forwarded to `flyte.build_images` to skip image existence checks and always rebuild. Tests verify the flag is passed correctly when set and defaults to False when omitted. Signed-off-by: Jeev B --- src/flyte/cli/_build.py | 14 +++++++- tests/cli/test_build.py | 74 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 tests/cli/test_build.py diff --git a/src/flyte/cli/_build.py b/src/flyte/cli/_build.py index 982b448c6..ed731b013 100644 --- a/src/flyte/cli/_build.py +++ b/src/flyte/cli/_build.py @@ -23,6 +23,18 @@ class BuildArguments: ) }, ) + force: bool = field( + default=False, + metadata={ + "click.option": click.Option( + ["--force"], + is_flag=True, + type=bool, + default=False, + help="Skip existence check and always rebuild the image.", + ) + }, + ) @classmethod def from_dict(cls, d: Dict[str, Any]) -> "BuildArguments": @@ -50,7 +62,7 @@ def invoke(self, ctx: click.Context): status.step(f"Building environment: {self.obj_name}") obj.init() with common.cli_status(obj.output_format, "Building...", spinner="dots"): - image_cache = flyte.build_images(self.obj) + image_cache = flyte.build_images(self.obj, force=self.build_args.force) status.success(f"Environment {self.obj_name} built") common.print_output(common.format("Images", image_cache.repr(), obj.output_format), obj.output_format) diff --git a/tests/cli/test_build.py b/tests/cli/test_build.py new file mode 100644 index 000000000..e0034bd62 --- /dev/null +++ b/tests/cli/test_build.py @@ -0,0 +1,74 @@ +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner + +import flyte +from flyte._internal.imagebuild.image_builder import ImageCache +from flyte.cli._build import build + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_cli_config(): + cfg = Mock() + cfg.output_format = "table-simple" + cfg.log_level = None + cfg.init.return_value = None + return cfg + + +def test_build_force_flag_passed_to_build_images(runner, tmp_path, mock_cli_config): + """--force is forwarded to flyte.build_images.""" + images_file = tmp_path / "images.py" + # Variable name "my_env" is used as the subcommand, not the Environment's name attribute. + images_file.write_text( + "import flyte\n" + "my_env = flyte.Environment(name='my_env',\n" + " image=flyte.Image.from_base('python:3.12').clone(\n" + " registry='reg', name='img'))\n" + ) + + mock_cache = ImageCache(image_lookup={"my_env": "reg/img:abc123"}) + + with patch("flyte.build_images", return_value=mock_cache) as mock_build: + result = runner.invoke( + build, + ["--force", str(images_file), "my_env"], + obj=mock_cli_config, + ) + + assert result.exit_code == 0, result.output + mock_build.assert_called_once() + call_kwargs = mock_build.call_args[1] + assert call_kwargs.get("force") is True + + +def test_build_force_defaults_to_false(runner, tmp_path, mock_cli_config): + """force defaults to False when --force is not passed.""" + images_file = tmp_path / "images.py" + # Variable name "my_env" is used as the subcommand, not the Environment's name attribute. + images_file.write_text( + "import flyte\n" + "my_env = flyte.Environment(name='my_env',\n" + " image=flyte.Image.from_base('python:3.12').clone(\n" + " registry='reg', name='img'))\n" + ) + + mock_cache = ImageCache(image_lookup={"my_env": "reg/img:abc123"}) + + with patch("flyte.build_images", return_value=mock_cache) as mock_build: + result = runner.invoke( + build, + [str(images_file), "my_env"], + obj=mock_cli_config, + ) + + assert result.exit_code == 0, result.output + mock_build.assert_called_once() + call_kwargs = mock_build.call_args[1] + assert not call_kwargs.get("force", False) From 87c0e1f7451acf30556fce818e34aa164f40944a Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:50:59 -0700 Subject: [PATCH 07/16] docs: deprecate ci_build_image.py; add _tag coupling notes to maint_tools Signed-off-by: Jeev B --- examples/image/ci_build_image.py | 3 +++ maint_tools/build_default_image.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/image/ci_build_image.py b/examples/image/ci_build_image.py index 08659195e..e68154fb7 100644 --- a/examples/image/ci_build_image.py +++ b/examples/image/ci_build_image.py @@ -1,3 +1,6 @@ +# DEPRECATED: This script is no longer the recommended way to build and push images from CI. +# Use `flyte build` instead. See the documentation for the supported CI image build path. +# This file is retained as a reference implementation only. """Build and push an image to a user-specified target from CI. Takes a source image and re-tags/pushes it to a target registry/name:tag. diff --git a/maint_tools/build_default_image.py b/maint_tools/build_default_image.py index bfb418261..c073e4ca1 100644 --- a/maint_tools/build_default_image.py +++ b/maint_tools/build_default_image.py @@ -70,7 +70,7 @@ async def build_flyte_connector_image( suffix = __version__.replace("+", "-") python_version = _detect_python_version() tag = f"py{python_version[0]}.{python_version[1]}-{suffix}" - object.__setattr__(default_image, "_tag", tag) + object.__setattr__(default_image, "_tag", tag) # NOTE: references _tag by name; update if Image._tag is renamed await ImageBuildEngine.build(default_image, builder=builder) From c7fd6e7c011a3a323bc47103221825823c8c21b3 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 10:59:45 -0700 Subject: [PATCH 08/16] refactor(image): move tag= to after name= in all constructor signatures Signed-off-by: Jeev B --- src/flyte/_image.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 5ca0c32b3..8d077152a 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -595,8 +595,8 @@ def from_debian_base( registry: Optional[str] = None, registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, - platform: Optional[Tuple[Architecture, ...]] = None, tag: Optional[str] = None, + platform: Optional[Tuple[Architecture, ...]] = None, ) -> Image: """ Use this method to start using the default base image, built from this library's base Dockerfile @@ -655,6 +655,7 @@ def from_uv_script( script: Path | str, *, name: str, + tag: Optional[str] = None, registry: str | None = None, registry_secret: Optional[str | Secret] = None, python_version: Optional[Tuple[int, int]] = None, @@ -664,7 +665,6 @@ def from_uv_script( extra_args: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, secret_mounts: Optional[SecretRequest] = None, - tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified uv script. @@ -728,11 +728,11 @@ def clone( registry: Optional[str] = None, registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, + tag: Optional[str] = None, base_image: Optional[str] = None, python_version: Optional[Tuple[int, int]] = None, addl_layer: Optional[Layer] = None, extendable: Optional[bool] = None, - tag: Optional[str] = None, ) -> Image: """ Use this method to clone the current image and change the registry and name @@ -797,8 +797,8 @@ def from_dockerfile( file: Path, registry: str, name: str, - platform: Union[Architecture, Tuple[Architecture, ...], None] = None, tag: Optional[str] = None, + platform: Union[Architecture, Tuple[Architecture, ...], None] = None, ) -> Image: """ Use this method to create a new image with the specified dockerfile. Note you cannot use additional layers From 14590cf39d400ae977477eae7ffbf3053e07f563 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 12:32:45 -0700 Subject: [PATCH 09/16] refactor(image): replace private _tag field with public tag attribute Signed-off-by: Jeev B --- examples/image/ci_build_image.py | 2 +- maint_tools/build_default_image.py | 2 +- src/flyte/_image.py | 24 ++++++++---------------- tests/flyte/test_image.py | 18 +++++++++--------- 4 files changed, 19 insertions(+), 27 deletions(-) diff --git a/examples/image/ci_build_image.py b/examples/image/ci_build_image.py index e68154fb7..c67a7e1f1 100644 --- a/examples/image/ci_build_image.py +++ b/examples/image/ci_build_image.py @@ -60,7 +60,7 @@ async def build_and_push(from_image: str, to_target: str, builder: str = "local" """Build an image from a base and push it to a target registry/name:tag.""" registry, name, tag = parse_target(to_target) image = Image.from_base(from_image).clone(registry=registry, name=name) - object.__setattr__(image, "_tag", tag) + object.__setattr__(image, "tag", tag) result = await ImageBuildEngine.build(image, builder=builder, force=force) return result.uri diff --git a/maint_tools/build_default_image.py b/maint_tools/build_default_image.py index c073e4ca1..dd7304636 100644 --- a/maint_tools/build_default_image.py +++ b/maint_tools/build_default_image.py @@ -70,7 +70,7 @@ async def build_flyte_connector_image( suffix = __version__.replace("+", "-") python_version = _detect_python_version() tag = f"py{python_version[0]}.{python_version[1]}-{suffix}" - object.__setattr__(default_image, "_tag", tag) # NOTE: references _tag by name; update if Image._tag is renamed + object.__setattr__(default_image, "tag", tag) await ImageBuildEngine.build(default_image, builder=builder) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 8d077152a..6f75a4d62 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -483,8 +483,8 @@ class Image: # Layers to be added to the image. In init, because frozen, but users shouldn't access, so underscore. _layers: Tuple[Layer, ...] = field(default_factory=tuple) - # Only settable internally. - _tag: Optional[str] = field(default=None, init=False) + # Explicitly set tag — overrides the content-hash default in _final_tag when provided. + tag: Optional[str] = field(default=None) _DEFAULT_IMAGE_PREFIXES: ClassVar = { PYTHON_3_10: "py3.10-", @@ -582,7 +582,7 @@ def _get_default_image_for( else: image = image.with_pip_packages(f"flyte=={flyte_version}") if not dev_mode: - object.__setattr__(image, "_tag", preset_tag) + object.__setattr__(image, "tag", preset_tag) return image @@ -628,7 +628,7 @@ def from_debian_base( tag = tag or None # normalize empty string to None if tag is not None: - object.__setattr__(base_image, "_tag", tag) + object.__setattr__(base_image, "tag", tag) return base_image @classmethod @@ -777,6 +777,7 @@ def clone( dockerfile=self.dockerfile, registry=registry, name=name, + tag=tag or None, platform=self.platform, python_version=python_version or self.python_version, extendable=extendable if extendable is not None else self.extendable, @@ -785,10 +786,6 @@ def clone( _ref_name=self._ref_name, ) - tag = tag or None # normalize empty string to None - if tag is not None: - object.__setattr__(img, "_tag", tag) - return img @classmethod @@ -821,17 +818,12 @@ def from_dockerfile( "dockerfile": file, "registry": registry, "name": name, + "tag": tag or None, "extendable": False, # Dockerfile-based images cannot have additional layers } if platform: kwargs["platform"] = platform - img = cls._new(**kwargs) - - tag = tag or None # normalize empty string to None - if tag is not None: - object.__setattr__(img, "_tag", tag) - - return img + return cls._new(**kwargs) def _get_hash_digest(self) -> str: """ @@ -854,7 +846,7 @@ def _get_hash_digest(self) -> str: @property def _final_tag(self) -> str: - t = self._tag or self._get_hash_digest() + t = self.tag or self._get_hash_digest() return t or "latest" @cached_property diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index 974ed8c7a..ae90142eb 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -444,7 +444,7 @@ def test_from_dockerfile_with_explicit_tag(tmp_path): dockerfile = tmp_path / "Dockerfile" dockerfile.write_text("FROM python:3.12-slim\n") img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="v2.0.0") - assert img._tag == "v2.0.0" + assert img.tag == "v2.0.0" assert img.uri == "reg/my-img:v2.0.0" @@ -452,7 +452,7 @@ def test_from_dockerfile_empty_string_tag_falls_back_to_content_hash(tmp_path): dockerfile = tmp_path / "Dockerfile" dockerfile.write_text("FROM python:3.12-slim\n") img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="") - assert img._tag is None + assert img.tag is None def test_with_code_bundle_defaults(): @@ -545,16 +545,16 @@ def test_resolve_code_bundle_loaded_modules_copy_style_none(tmp_path): def test_clone_with_explicit_tag(): img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) cloned = img.clone(registry="other-reg", name="other-img", tag="v1.2.3") - assert cloned._tag == "v1.2.3" + assert cloned.tag == "v1.2.3" assert cloned.uri == "other-reg/other-img:v1.2.3" def test_clone_tag_not_inherited_from_source(): - # clone() never inherits _tag from source — always fresh content hash unless tag= given + # clone() never inherits tag from source — always fresh content hash unless tag= given img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) - # default image has _tag set (version-based); clone without tag= should content-hash + # default image has tag set (version-based); clone without tag= should content-hash cloned = img.clone(registry="other-reg", name="other-img") - assert cloned._tag is None + assert cloned.tag is None assert cloned.uri.startswith("other-reg/other-img:") assert cloned.uri != img.uri # different because hash differs (no version tag) @@ -562,12 +562,12 @@ def test_clone_tag_not_inherited_from_source(): def test_clone_empty_string_tag_falls_back_to_content_hash(): img = Image.from_debian_base(registry="reg", name="img", python_version=(3, 12)) cloned = img.clone(registry="reg", name="img", tag="") - assert cloned._tag is None # empty string normalized to None + assert cloned.tag is None # empty string normalized to None def test_from_debian_base_with_explicit_tag(): img = Image.from_debian_base(registry="reg", name="my-img", tag="v3.0.0", python_version=(3, 12)) - assert img._tag == "v3.0.0" + assert img.tag == "v3.0.0" assert img.uri == "reg/my-img:v3.0.0" @@ -583,5 +583,5 @@ def test_from_uv_script_with_explicit_tag(tmp_path): python_version=(3, 12), tag="v1.0.0", ) - assert img._tag == "v1.0.0" + assert img.tag == "v1.0.0" assert img.uri == "reg/my-script-img:v1.0.0" From 6bc897f7b41358268e64e1039b399ec2a37b0603 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 19:06:34 -0700 Subject: [PATCH 10/16] fix(lint): apply ruff format and remove unused import Signed-off-by: Jeev B --- FEATURES.md | 2 +- examples/genai/handoff/README.md | 2 +- maint_tools/build_default_image.py | 2 +- src/flyte/_deploy.py | 4 +++- src/flyte/_image.py | 13 +++++++++++-- src/flyte/cli/_build.py | 2 +- tests/cli/test_build.py | 1 - tests/flyte/test_deploy.py | 1 + tests/flyte/test_image.py | 4 +--- 9 files changed, 20 insertions(+), 11 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 717ddf09e..eda45a15c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -98,7 +98,7 @@ The Flyte CLI follows a **verb noun** structure. Full reference: [CLI Docs](http flyte run hello.py main --numbers '[1,2,3]' # Run a task flyte serve serving.py env # Serve an app flyte deploy my_workflow.py # Deploy environments -flyte build my_workflow.py --push # Build and push images +flyte build my_workflow.py # Build and push images flyte get logs # Get logs for a run flyte abort run # Abort a run ``` diff --git a/examples/genai/handoff/README.md b/examples/genai/handoff/README.md index 8957aaf8b..ee684aed2 100644 --- a/examples/genai/handoff/README.md +++ b/examples/genai/handoff/README.md @@ -211,7 +211,7 @@ flyte deploy agent_handoff.py ```bash # Build and push image -flyte build agent_handoff.py --push +flyte build agent_handoff.py # Deploy to Flyte cluster flyte deploy agent_handoff.py --domain production diff --git a/maint_tools/build_default_image.py b/maint_tools/build_default_image.py index dd7304636..d236c4b74 100644 --- a/maint_tools/build_default_image.py +++ b/maint_tools/build_default_image.py @@ -70,7 +70,7 @@ async def build_flyte_connector_image( suffix = __version__.replace("+", "-") python_version = _detect_python_version() tag = f"py{python_version[0]}.{python_version[1]}-{suffix}" - object.__setattr__(default_image, "tag", tag) + default_image = default_image.clone(tag=tag) await ImageBuildEngine.build(default_image, builder=builder) diff --git a/src/flyte/_deploy.py b/src/flyte/_deploy.py index 56026cf36..241055f47 100644 --- a/src/flyte/_deploy.py +++ b/src/flyte/_deploy.py @@ -367,7 +367,9 @@ async def _build_image_bg(env_name: str, image: Image, force: bool = False) -> T return env_name, result.uri, run_id_data -async def _build_images(deployment: DeploymentPlan, image_refs: Dict[str, str] | None = None, force: bool = False) -> ImageCache: +async def _build_images( + deployment: DeploymentPlan, image_refs: Dict[str, str] | None = None, force: bool = False +) -> ImageCache: """ Build the images for the given deployment plan and update the environment with the built image. """ diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 6f75a4d62..5a298ea82 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -595,6 +595,7 @@ def from_debian_base( registry: Optional[str] = None, registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, + *, tag: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, ) -> Image: @@ -608,6 +609,7 @@ def from_debian_base( :param registry: Registry to use for the image :param registry_secret: Secret to use to pull/push the private image. :param name: Name of the image if you want to override the default name + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :param platform: Platform to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") @@ -624,7 +626,9 @@ def from_debian_base( ) if registry or name: - return base_image.clone(registry=registry, name=name, registry_secret=registry_secret, extendable=True, tag=tag) + return base_image.clone( + registry=registry, name=name, registry_secret=registry_secret, extendable=True, tag=tag + ) tag = tag or None # normalize empty string to None if tag is not None: @@ -685,6 +689,7 @@ def from_uv_script( [UV: Declaring script dependencies](https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies) :param name: name of the image + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :param registry: registry to use for the image :param registry_secret: Secret to use to pull/push the private image. :param python_version: Python version to use for the image, if not specified, will use the current Python @@ -728,6 +733,7 @@ def clone( registry: Optional[str] = None, registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, + *, tag: Optional[str] = None, base_image: Optional[str] = None, python_version: Optional[Tuple[int, int]] = None, @@ -740,6 +746,7 @@ def clone( :param registry: Registry to use for the image :param registry_secret: Secret to use to pull/push the private image. :param name: Name of the image + :param tag: Explicit tag for the cloned image. If omitted, a content-hash tag is used. :param base_image: Base image to use for the image :param python_version: Python version for the image, if not specified, will use the current Python version :param addl_layer: Additional layer to add to the image. This will be added to the end of the layers. @@ -791,6 +798,7 @@ def clone( @classmethod def from_dockerfile( cls, + *, file: Path, registry: str, name: str, @@ -806,8 +814,9 @@ def from_dockerfile( context for the builder will be the directory where the dockerfile is located. :param file: path to the dockerfile - :param name: name of the image :param registry: registry to use for the image + :param name: name of the image + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") diff --git a/src/flyte/cli/_build.py b/src/flyte/cli/_build.py index ed731b013..76e8008c6 100644 --- a/src/flyte/cli/_build.py +++ b/src/flyte/cli/_build.py @@ -31,7 +31,7 @@ class BuildArguments: is_flag=True, type=bool, default=False, - help="Skip existence check and always rebuild the image.", + help="Force rebuild and push even if the image already exists in the registry.", ) }, ) diff --git a/tests/cli/test_build.py b/tests/cli/test_build.py index e0034bd62..fa4f4ab67 100644 --- a/tests/cli/test_build.py +++ b/tests/cli/test_build.py @@ -3,7 +3,6 @@ import pytest from click.testing import CliRunner -import flyte from flyte._internal.imagebuild.image_builder import ImageCache from flyte.cli._build import build diff --git a/tests/flyte/test_deploy.py b/tests/flyte/test_deploy.py index 5b91c8811..3ad80132a 100644 --- a/tests/flyte/test_deploy.py +++ b/tests/flyte/test_deploy.py @@ -355,6 +355,7 @@ async def test_build_images_no_build_run_urls_for_local_build(): assert cache.image_lookup["my-env"] == "registry/my-image:sha256abc" assert cache.build_run_ids == {} + @pytest.mark.asyncio async def test_build_image_bg_passes_force_to_build(): """force=True is forwarded to build.aio.""" diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index ae90142eb..82f4a79c7 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -573,9 +573,7 @@ def test_from_debian_base_with_explicit_tag(): def test_from_uv_script_with_explicit_tag(tmp_path): script = tmp_path / "script.py" - script.write_text( - "# /// script\n# requires-python = '>=3.12'\n# dependencies = []\n# ///\nprint('hello')\n" - ) + script.write_text("# /// script\n# requires-python = '>=3.12'\n# dependencies = []\n# ///\nprint('hello')\n") img = Image.from_uv_script( script=script, name="my-script-img", From 3370631b6771e99462509e3355f84e884a295533 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Fri, 13 Mar 2026 19:23:25 -0700 Subject: [PATCH 11/16] refactor(image): remove keyword-only enforcement; move tag= to last position The *-separator changes made tag and other parameters keyword-only, which risks breaking existing user code. Roll back those changes and instead append tag= as the last positional argument in each factory method and clone() for consistency without imposing keyword-only constraints on callers. from_uv_script retains its pre-existing *-separator (it was keyword-only on main already); tag= is appended after secret_mounts. Signed-off-by: Jeev B --- src/flyte/_image.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 5a298ea82..66927d4e7 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -595,9 +595,8 @@ def from_debian_base( registry: Optional[str] = None, registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, - *, - tag: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to start using the default base image, built from this library's base Dockerfile @@ -609,9 +608,9 @@ def from_debian_base( :param registry: Registry to use for the image :param registry_secret: Secret to use to pull/push the private image. :param name: Name of the image if you want to override the default name - :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :param platform: Platform to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: Image """ @@ -659,7 +658,6 @@ def from_uv_script( script: Path | str, *, name: str, - tag: Optional[str] = None, registry: str | None = None, registry_secret: Optional[str | Secret] = None, python_version: Optional[Tuple[int, int]] = None, @@ -669,6 +667,7 @@ def from_uv_script( extra_args: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, secret_mounts: Optional[SecretRequest] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified uv script. @@ -689,24 +688,20 @@ def from_uv_script( [UV: Declaring script dependencies](https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies) :param name: name of the image - :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :param registry: registry to use for the image :param registry_secret: Secret to use to pull/push the private image. :param python_version: Python version to use for the image, if not specified, will use the current Python version :param script: path to the uv script :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values - :param python_version: Python version for the image, if not specified, will use the current Python version :param index_url: index url to use for pip install, default is None :param extra_index_urls: extra index urls to use for pip install, default is True :param pre: whether to allow pre-release versions, default is False :param extra_args: extra arguments to pass to pip install, default is None :param secret_mounts: Secret mounts to use for the image, default is None. + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: Image - - Args: - secret_mounts: """ ll = UVScript( script=Path(script), @@ -733,12 +728,11 @@ def clone( registry: Optional[str] = None, registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, - *, - tag: Optional[str] = None, base_image: Optional[str] = None, python_version: Optional[Tuple[int, int]] = None, addl_layer: Optional[Layer] = None, extendable: Optional[bool] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to clone the current image and change the registry and name @@ -746,7 +740,6 @@ def clone( :param registry: Registry to use for the image :param registry_secret: Secret to use to pull/push the private image. :param name: Name of the image - :param tag: Explicit tag for the cloned image. If omitted, a content-hash tag is used. :param base_image: Base image to use for the image :param python_version: Python version for the image, if not specified, will use the current Python version :param addl_layer: Additional layer to add to the image. This will be added to the end of the layers. @@ -754,6 +747,7 @@ def clone( image for other images, and additional layers can be added on top of it. If False, the image cannot be used as a base image for other images, and additional layers cannot be added on top of it. If None (default), defaults to False for safety. + :param tag: Explicit tag for the cloned image. If omitted, a content-hash tag is used. :return: """ from flyte import Secret @@ -798,12 +792,11 @@ def clone( @classmethod def from_dockerfile( cls, - *, file: Path, registry: str, name: str, - tag: Optional[str] = None, platform: Union[Architecture, Tuple[Architecture, ...], None] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified dockerfile. Note you cannot use additional layers @@ -816,9 +809,9 @@ def from_dockerfile( :param file: path to the dockerfile :param registry: registry to use for the image :param name: name of the image - :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: """ From edec308d86fed19c577fde7f3faab2883d0d1ac1 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Mon, 16 Mar 2026 09:11:18 -0700 Subject: [PATCH 12/16] docs(image): restore ci_build_image.py as authoritative CI reference Remove the deprecation header and rewrite the module docstring to clearly describe when to use this script (re-tagging a pre-built image) versus `flyte build` (building images from Environment definitions). Also replace the fragile object.__setattr__(image, "tag", tag) mutation with clone(tag=tag), using the proper public API. Signed-off-by: Jeev B --- examples/image/ci_build_image.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/examples/image/ci_build_image.py b/examples/image/ci_build_image.py index c67a7e1f1..0ba98fc57 100644 --- a/examples/image/ci_build_image.py +++ b/examples/image/ci_build_image.py @@ -1,10 +1,13 @@ -# DEPRECATED: This script is no longer the recommended way to build and push images from CI. -# Use `flyte build` instead. See the documentation for the supported CI image build path. -# This file is retained as a reference implementation only. -"""Build and push an image to a user-specified target from CI. +"""Re-tag and push an existing image to a target registry from CI. -Takes a source image and re-tags/pushes it to a target registry/name:tag. -Works with both the local Docker builder and the remote builder. +Use this script when you want to take a pre-built image (e.g. the Flyte default +image) and publish it under your own registry, name, and tag — without rebuilding +it from scratch. + +For building images defined as :class:`flyte.Environment` objects in Python, use +the ``flyte build`` CLI command instead:: + + flyte build my_workflow.py Usage:: @@ -19,17 +22,11 @@ --to 123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0 \ --builder remote - # Force rebuild even if the target already exists + # Force push even if the target already exists python examples/image/ci_build_image.py \ --from ghcr.io/flyteorg/flyte:py3.12-v2.0.0b56 \ --to 123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0 \ --force - - # Force rebuild using the remote builder - python examples/image/ci_build_image.py \ - --from ghcr.io/flyteorg/flyte:py3.12-v2.0.0b56 \ - --to 123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0 \ - --builder remote --force """ import argparse @@ -57,10 +54,9 @@ def parse_target(target: str) -> tuple[str, str, str]: async def build_and_push(from_image: str, to_target: str, builder: str = "local", force: bool = False) -> str: - """Build an image from a base and push it to a target registry/name:tag.""" + """Re-tag a pre-built image and push it to a target registry/name:tag.""" registry, name, tag = parse_target(to_target) - image = Image.from_base(from_image).clone(registry=registry, name=name) - object.__setattr__(image, "tag", tag) + image = Image.from_base(from_image).clone(registry=registry, name=name, tag=tag) result = await ImageBuildEngine.build(image, builder=builder, force=force) return result.uri From 9843729a54691b0b4fc0448f216d9cf8df57226b Mon Sep 17 00:00:00 2001 From: Jeev B Date: Mon, 16 Mar 2026 09:12:14 -0700 Subject: [PATCH 13/16] refactor(image): simplify ci_build_image.py to a plain flyte build example Replace the argparse CLI with a minimal Environment definition that users can build and push with `flyte build examples/image/ci_build_image.py env`. Signed-off-by: Jeev B --- examples/image/ci_build_image.py | 79 ++++---------------------------- 1 file changed, 10 insertions(+), 69 deletions(-) diff --git a/examples/image/ci_build_image.py b/examples/image/ci_build_image.py index 0ba98fc57..9fdf75c3d 100644 --- a/examples/image/ci_build_image.py +++ b/examples/image/ci_build_image.py @@ -1,75 +1,16 @@ -"""Re-tag and push an existing image to a target registry from CI. +"""Build and push a custom image from CI. -Use this script when you want to take a pre-built image (e.g. the Flyte default -image) and publish it under your own registry, name, and tag — without rebuilding -it from scratch. +Define your image below, then run: -For building images defined as :class:`flyte.Environment` objects in Python, use -the ``flyte build`` CLI command instead:: - - flyte build my_workflow.py - -Usage:: - - # Re-tag an existing image and push to ECR - python examples/image/ci_build_image.py \ - --from ghcr.io/flyteorg/flyte:py3.12-v2.0.0b56 \ - --to 123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0 - - # Use the remote builder - python examples/image/ci_build_image.py \ - --from ghcr.io/flyteorg/flyte:py3.12-v2.0.0b56 \ - --to 123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0 \ - --builder remote - - # Force push even if the target already exists - python examples/image/ci_build_image.py \ - --from ghcr.io/flyteorg/flyte:py3.12-v2.0.0b56 \ - --to 123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0 \ - --force + flyte build examples/image/ci_build_image.py env """ -import argparse -import asyncio - import flyte -from flyte import Image -from flyte.extend import ImageBuildEngine - - -def parse_target(target: str) -> tuple[str, str, str]: - """Parse a target image string into (registry, name, tag). - - Example: - >>> parse_target("123456789.dkr.ecr.us-west-2.amazonaws.com/myorg/myimage:v1.0.0") - ('123456789.dkr.ecr.us-west-2.amazonaws.com/myorg', 'myimage', 'v1.0.0') - """ - if ":" not in target: - raise ValueError(f"Target '{target}' must contain a tag (e.g., myregistry/myimage:v1.0)") - image_path, tag = target.rsplit(":", 1) - if "/" not in image_path: - raise ValueError(f"Target '{target}' must contain a registry (e.g., myregistry/myimage:v1.0)") - registry, name = image_path.rsplit("/", 1) - return registry, name, tag - - -async def build_and_push(from_image: str, to_target: str, builder: str = "local", force: bool = False) -> str: - """Re-tag a pre-built image and push it to a target registry/name:tag.""" - registry, name, tag = parse_target(to_target) - image = Image.from_base(from_image).clone(registry=registry, name=name, tag=tag) - result = await ImageBuildEngine.build(image, builder=builder, force=force) - return result.uri - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Build and push an image to a target registry/name:tag.") - parser.add_argument("--from", dest="from_image", required=True, help="Source image URI") - parser.add_argument("--to", dest="to_target", required=True, help="Target image as registry/name:tag") - parser.add_argument("--builder", choices=["local", "remote"], default="local", help="Image builder to use") - parser.add_argument("--force", action="store_true", help="Skip existence check, always rebuild") - - args = parser.parse_args() - flyte.init_from_config() - uri = asyncio.run(build_and_push(args.from_image, args.to_target, args.builder, args.force)) - print(uri) +env = flyte.Environment( + name="env", + image=flyte.Image.from_debian_base( + registry="ghcr.io/myorg", + name="myimage", + ), +) From 3133e07a1f883594b3e43674fab53974a1aee861 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Mon, 16 Mar 2026 10:22:38 -0700 Subject: [PATCH 14/16] fix(image): strip _BASE_REGISTRY on clone() of SDK base image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user derives from Image.from_debian_base() — via .clone(name=...) or any with_*() chain — the clone inherited _BASE_REGISTRY ("ghcr.io/flyteorg"), a registry the user doesn't own. The clone should have registry=None: a user-owned derivative whose push target is determined by the build system, not by the SDK's registry constant. clone() rule (condition 3 only): parent_registry = None if self.registry == _BASE_REGISTRY else self.registry registry = registry or parent_registry This is clean because _get_default_image_for() now builds the base image with registry=None during construction (so internal clone() calls never trigger the strip), then stamps _BASE_REGISTRY explicitly at the end — consistent with the existing object.__setattr__ pattern used for `tag`. Result: from_debian_base() → registry=_BASE_REGISTRY (unchanged) from_debian_base().with_pip_packages() → registry=None from_debian_base().clone(name="ci") → registry=None .clone(registry="myreg", name="x") → registry="myreg" (explicit wins) Signed-off-by: Jeev B --- src/flyte/_image.py | 19 +++++++++++++++++-- tests/flyte/test_image.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 66927d4e7..027468bfb 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -545,7 +545,7 @@ def _get_default_image_for( preset_tag = f"py{python_version[0]}.{python_version[1]}-{suffix}" image = Image._new( base_image=f"python:{python_version[0]}.{python_version[1]}-slim-bookworm", - registry=_BASE_REGISTRY, + registry=None, name=_DEFAULT_IMAGE_NAME, python_version=python_version, platform=("linux/amd64", "linux/arm64") if platform is None else platform, @@ -581,6 +581,11 @@ def _get_default_image_for( image = image.with_pip_packages(f"flyte=={flyte_version}", pre=True) else: image = image.with_pip_packages(f"flyte=={flyte_version}") + # Set the registry last so internal clone() calls during construction + # (above) don't inherit _BASE_REGISTRY — clone() strips it whenever + # self.registry == _BASE_REGISTRY, so we defer stamping it until the + # image is fully assembled. + object.__setattr__(image, "registry", _BASE_REGISTRY) if not dev_mode: object.__setattr__(image, "tag", preset_tag) @@ -764,7 +769,17 @@ def clone( "Flyte current cannot add additional layers to a Dockerfile-based Image." " Please amend the dockerfile directly." ) - registry = registry or self.registry + # Registry inheritance: only carry forward a registry that the caller + # actually owns. _BASE_REGISTRY ("ghcr.io/flyteorg") is an internal + # constant used as the home of the SDK's own prebuilt base images — + # virtually no user has write access to it. Inheriting it into a + # user-defined clone would silently produce a URI like + # "ghcr.io/flyteorg/my-image:tag" that can never be pushed. + # When no explicit registry is provided and the parent carries + # _BASE_REGISTRY, treat the clone as registry-less (None) so the + # build system assigns the correct target registry at build time. + parent_registry = None if self.registry == _BASE_REGISTRY else self.registry + registry = registry or parent_registry name = name or self.name registry_secret = registry_secret or self._image_registry_secret base_image = base_image or self.base_image diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index 82f4a79c7..d41432c11 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -199,6 +199,35 @@ def test_base_image_cloned(): assert cloned_default_image.uri.startswith("ghcr.io/flyteorg/flyte-clone") +def test_clone_strips_base_registry(): + # Any clone of a from_debian_base() image without an explicit registry should + # have registry=None — the clone is a user-owned derivative, not the SDK's + # ghcr.io/flyteorg image. + img = Image.from_debian_base(python_version=(3, 13)).clone(name="my-image") + assert img.registry is None + assert img.uri.startswith("my-image:") + + # Same via from_debian_base(name=...) + img2 = Image.from_debian_base(python_version=(3, 13), name="my-image") + assert img2.registry is None + assert img2.uri.startswith("my-image:") + + # with_* chains also strip _BASE_REGISTRY — they produce user-owned derivatives. + img3 = Image.from_debian_base(python_version=(3, 13)).with_pip_packages("numpy") + assert img3.registry is None + assert img3.uri.startswith("flyte:") + + # Explicit registry always wins regardless. + img4 = Image.from_debian_base(python_version=(3, 13)).clone( + registry="myregistry.io", name="my-image" + ) + assert img4.registry == "myregistry.io" + + # The unmodified base image itself still carries _BASE_REGISTRY. + base = Image.from_debian_base(python_version=(3, 13)) + assert base.registry == "ghcr.io/flyteorg" + + def test_base_image_clone_same(): default_image = Image.from_debian_base(python_version=(3, 13)) cloned_default_image = Image.from_debian_base(python_version=(3, 13)).clone( From 37b271ed7fc0366efb3d168288c36186bb9690e7 Mon Sep 17 00:00:00 2001 From: Jeev B Date: Mon, 16 Mar 2026 12:19:09 -0700 Subject: [PATCH 15/16] refactor(image): prune tag param from factory methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove tag= from from_debian_base(), from_dockerfile(), and from_uv_script() — clone(tag=...) covers all cases and is the cleaner, consistent API for pinning an explicit tag. --- src/flyte/_image.py | 14 ++------------ tests/flyte/test_image.py | 33 --------------------------------- 2 files changed, 2 insertions(+), 45 deletions(-) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 027468bfb..4aa7e4778 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -601,7 +601,6 @@ def from_debian_base( registry_secret: Optional[str | Secret] = None, name: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, - tag: Optional[str] = None, ) -> Image: """ Use this method to start using the default base image, built from this library's base Dockerfile @@ -615,7 +614,6 @@ def from_debian_base( :param name: Name of the image if you want to override the default name :param platform: Platform to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") - :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: Image """ @@ -631,12 +629,9 @@ def from_debian_base( if registry or name: return base_image.clone( - registry=registry, name=name, registry_secret=registry_secret, extendable=True, tag=tag + registry=registry, name=name, registry_secret=registry_secret, extendable=True ) - tag = tag or None # normalize empty string to None - if tag is not None: - object.__setattr__(base_image, "tag", tag) return base_image @classmethod @@ -672,7 +667,6 @@ def from_uv_script( extra_args: Optional[str] = None, platform: Optional[Tuple[Architecture, ...]] = None, secret_mounts: Optional[SecretRequest] = None, - tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified uv script. @@ -704,7 +698,6 @@ def from_uv_script( :param pre: whether to allow pre-release versions, default is False :param extra_args: extra arguments to pass to pip install, default is None :param secret_mounts: Secret mounts to use for the image, default is None. - :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: Image """ @@ -726,7 +719,7 @@ def from_uv_script( platform=platform, ) - return img.clone(addl_layer=ll, tag=tag) + return img.clone(addl_layer=ll) def clone( self, @@ -811,7 +804,6 @@ def from_dockerfile( registry: str, name: str, platform: Union[Architecture, Tuple[Architecture, ...], None] = None, - tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified dockerfile. Note you cannot use additional layers @@ -826,7 +818,6 @@ def from_dockerfile( :param name: name of the image :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") - :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: """ @@ -835,7 +826,6 @@ def from_dockerfile( "dockerfile": file, "registry": registry, "name": name, - "tag": tag or None, "extendable": False, # Dockerfile-based images cannot have additional layers } if platform: diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index d41432c11..5a2de3604 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -469,21 +469,6 @@ def test_from_dockerfile_is_not_extendable(): dockerfile_path.unlink() -def test_from_dockerfile_with_explicit_tag(tmp_path): - dockerfile = tmp_path / "Dockerfile" - dockerfile.write_text("FROM python:3.12-slim\n") - img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="v2.0.0") - assert img.tag == "v2.0.0" - assert img.uri == "reg/my-img:v2.0.0" - - -def test_from_dockerfile_empty_string_tag_falls_back_to_content_hash(tmp_path): - dockerfile = tmp_path / "Dockerfile" - dockerfile.write_text("FROM python:3.12-slim\n") - img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="") - assert img.tag is None - - def test_with_code_bundle_defaults(): """with_code_bundle() creates a CodeBundleLayer with default values.""" img = Image.from_debian_base(registry="localhost", name="test-image").with_code_bundle() @@ -594,21 +579,3 @@ def test_clone_empty_string_tag_falls_back_to_content_hash(): assert cloned.tag is None # empty string normalized to None -def test_from_debian_base_with_explicit_tag(): - img = Image.from_debian_base(registry="reg", name="my-img", tag="v3.0.0", python_version=(3, 12)) - assert img.tag == "v3.0.0" - assert img.uri == "reg/my-img:v3.0.0" - - -def test_from_uv_script_with_explicit_tag(tmp_path): - script = tmp_path / "script.py" - script.write_text("# /// script\n# requires-python = '>=3.12'\n# dependencies = []\n# ///\nprint('hello')\n") - img = Image.from_uv_script( - script=script, - name="my-script-img", - registry="reg", - python_version=(3, 12), - tag="v1.0.0", - ) - assert img.tag == "v1.0.0" - assert img.uri == "reg/my-script-img:v1.0.0" From 0485021146df5486f2d2b81751a1a5f8391b406a Mon Sep 17 00:00:00 2001 From: Jeev B Date: Mon, 16 Mar 2026 12:31:55 -0700 Subject: [PATCH 16/16] refactor(image): restore tag param on from_dockerfile Dockerfile-based images are non-extendable so clone(tag=...) feels awkward as the only way to pin a tag. Restore tag= directly on from_dockerfile() as the natural place to set it. --- src/flyte/_image.py | 3 +++ tests/flyte/test_image.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/flyte/_image.py b/src/flyte/_image.py index 4aa7e4778..ca92206b5 100644 --- a/src/flyte/_image.py +++ b/src/flyte/_image.py @@ -804,6 +804,7 @@ def from_dockerfile( registry: str, name: str, platform: Union[Architecture, Tuple[Architecture, ...], None] = None, + tag: Optional[str] = None, ) -> Image: """ Use this method to create a new image with the specified dockerfile. Note you cannot use additional layers @@ -818,6 +819,7 @@ def from_dockerfile( :param name: name of the image :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values Example: ("linux/amd64", "linux/arm64") + :param tag: Explicit tag for the built image. If omitted, a content-hash tag is used. :return: """ @@ -826,6 +828,7 @@ def from_dockerfile( "dockerfile": file, "registry": registry, "name": name, + "tag": tag or None, "extendable": False, # Dockerfile-based images cannot have additional layers } if platform: diff --git a/tests/flyte/test_image.py b/tests/flyte/test_image.py index 5a2de3604..21455b987 100644 --- a/tests/flyte/test_image.py +++ b/tests/flyte/test_image.py @@ -469,6 +469,21 @@ def test_from_dockerfile_is_not_extendable(): dockerfile_path.unlink() +def test_from_dockerfile_with_explicit_tag(tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\n") + img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="v2.0.0") + assert img.tag == "v2.0.0" + assert img.uri == "reg/my-img:v2.0.0" + + +def test_from_dockerfile_empty_string_tag_falls_back_to_content_hash(tmp_path): + dockerfile = tmp_path / "Dockerfile" + dockerfile.write_text("FROM python:3.12-slim\n") + img = Image.from_dockerfile(file=dockerfile, registry="reg", name="my-img", tag="") + assert img.tag is None + + def test_with_code_bundle_defaults(): """with_code_bundle() creates a CodeBundleLayer with default values.""" img = Image.from_debian_base(registry="localhost", name="test-image").with_code_bundle()