flyte 2.0.0b20__py3-none-any.whl → 2.0.0b22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. flyte/_bin/runtime.py +3 -3
  2. flyte/_code_bundle/_ignore.py +11 -3
  3. flyte/_code_bundle/_packaging.py +9 -5
  4. flyte/_code_bundle/_utils.py +2 -2
  5. flyte/_deploy.py +16 -8
  6. flyte/_image.py +5 -1
  7. flyte/_initialize.py +21 -8
  8. flyte/_interface.py +37 -2
  9. flyte/_internal/imagebuild/docker_builder.py +61 -8
  10. flyte/_internal/imagebuild/image_builder.py +8 -7
  11. flyte/_internal/imagebuild/remote_builder.py +9 -26
  12. flyte/_internal/runtime/task_serde.py +16 -6
  13. flyte/_keyring/__init__.py +0 -0
  14. flyte/_keyring/file.py +85 -0
  15. flyte/_logging.py +19 -8
  16. flyte/_task_environment.py +1 -1
  17. flyte/_utils/coro_management.py +2 -1
  18. flyte/_version.py +3 -3
  19. flyte/cli/_common.py +2 -2
  20. flyte/cli/_deploy.py +11 -1
  21. flyte/cli/_run.py +13 -3
  22. flyte/config/_config.py +6 -4
  23. flyte/config/_reader.py +19 -4
  24. flyte/git/_config.py +2 -0
  25. flyte/io/_dataframe/dataframe.py +3 -2
  26. flyte/io/_dir.py +72 -72
  27. flyte/models.py +6 -2
  28. flyte/remote/_action.py +9 -8
  29. flyte/remote/_client/auth/_authenticators/device_code.py +3 -4
  30. flyte/remote/_data.py +2 -3
  31. flyte/remote/_run.py +17 -1
  32. flyte/storage/_config.py +5 -1
  33. flyte/types/_pickle.py +18 -4
  34. flyte/types/_type_engine.py +13 -0
  35. {flyte-2.0.0b20.data → flyte-2.0.0b22.data}/scripts/runtime.py +3 -3
  36. {flyte-2.0.0b20.dist-info → flyte-2.0.0b22.dist-info}/METADATA +1 -1
  37. {flyte-2.0.0b20.dist-info → flyte-2.0.0b22.dist-info}/RECORD +42 -40
  38. {flyte-2.0.0b20.dist-info → flyte-2.0.0b22.dist-info}/entry_points.txt +3 -0
  39. {flyte-2.0.0b20.data → flyte-2.0.0b22.data}/scripts/debug.py +0 -0
  40. {flyte-2.0.0b20.dist-info → flyte-2.0.0b22.dist-info}/WHEEL +0 -0
  41. {flyte-2.0.0b20.dist-info → flyte-2.0.0b22.dist-info}/licenses/LICENSE +0 -0
  42. {flyte-2.0.0b20.dist-info → flyte-2.0.0b22.dist-info}/top_level.txt +0 -0
flyte/_bin/runtime.py CHANGED
@@ -21,8 +21,8 @@ import click
21
21
 
22
22
  ACTION_NAME = "ACTION_NAME"
23
23
  RUN_NAME = "RUN_NAME"
24
- PROJECT_NAME = "FLYTE_INTERNAL_TASK_PROJECT"
25
- DOMAIN_NAME = "FLYTE_INTERNAL_TASK_DOMAIN"
24
+ PROJECT_NAME = "FLYTE_INTERNAL_EXECUTION_PROJECT"
25
+ DOMAIN_NAME = "FLYTE_INTERNAL_EXECUTION_DOMAIN"
26
26
  ORG_NAME = "_U_ORG_NAME"
27
27
  ENDPOINT_OVERRIDE = "_U_EP_OVERRIDE"
28
28
  RUN_OUTPUT_BASE_DIR = "_U_RUN_BASE"
@@ -137,7 +137,7 @@ def main(
137
137
  logger.debug(f"Using controller endpoint: {ep} with kwargs: {controller_kwargs}")
138
138
 
139
139
  bundle = CodeBundle(tgz=tgz, pkl=pkl, destination=dest, computed_version=version)
140
- init(org=org, project=project, domain=domain, **controller_kwargs)
140
+ init(org=org, project=project, domain=domain, image_builder="remote", **controller_kwargs)
141
141
  # Controller is created with the same kwargs as init, so that it can be used to run tasks
142
142
  controller = create_controller(ct="remote", **controller_kwargs)
143
143
 
@@ -83,8 +83,15 @@ class StandardIgnore(Ignore):
83
83
  self.patterns = patterns if patterns else STANDARD_IGNORE_PATTERNS
84
84
 
85
85
  def _is_ignored(self, path: pathlib.Path) -> bool:
86
+ # Convert to relative path for pattern matching
87
+ try:
88
+ rel_path = path.relative_to(self.root)
89
+ except ValueError:
90
+ # If path is not under root, don't ignore it
91
+ return False
92
+
86
93
  for pattern in self.patterns:
87
- if fnmatch(str(path), pattern):
94
+ if fnmatch(str(rel_path), pattern):
88
95
  return True
89
96
  return False
90
97
 
@@ -105,9 +112,10 @@ class IgnoreGroup(Ignore):
105
112
 
106
113
  def list_ignored(self) -> List[str]:
107
114
  ignored = []
108
- for dir, _, files in self.root.walk():
115
+ for dir, _, files in os.walk(self.root):
116
+ dir_path = Path(dir)
109
117
  for file in files:
110
- abs_path = dir / file
118
+ abs_path = dir_path / file
111
119
  if self.is_ignored(abs_path):
112
120
  ignored.append(str(abs_path.relative_to(self.root)))
113
121
  return ignored
@@ -14,10 +14,9 @@ import typing
14
14
  from typing import List, Optional, Tuple, Union
15
15
 
16
16
  import click
17
- from rich import print as rich_print
18
17
  from rich.tree import Tree
19
18
 
20
- from flyte._logging import logger
19
+ from flyte._logging import _get_console, logger
21
20
 
22
21
  from ._ignore import Ignore, IgnoreGroup
23
22
  from ._utils import CopyFiles, _filehash_update, _pathhash_update, ls_files, tar_strip_file_attributes
@@ -27,10 +26,10 @@ FAST_FILEENDING = ".tar.gz"
27
26
 
28
27
 
29
28
  def print_ls_tree(source: os.PathLike, ls: typing.List[str]):
30
- click.secho("Files to be copied for fast registration...", fg="bright_blue")
29
+ logger.info("Files to be copied for fast registration...")
31
30
 
32
31
  tree_root = Tree(
33
- f":open_file_folder: [link file://{source}]{source} (detected source root)",
32
+ f"File structure:\n:open_file_folder: {source}",
34
33
  guide_style="bold bright_blue",
35
34
  )
36
35
  trees = {pathlib.Path(source): tree_root}
@@ -49,7 +48,12 @@ def print_ls_tree(source: os.PathLike, ls: typing.List[str]):
49
48
  else:
50
49
  current = trees[current_path]
51
50
  trees[fpp.parent].add(f"{fpp.name}", guide_style="bold bright_blue")
52
- rich_print(tree_root)
51
+
52
+ console = _get_console()
53
+ with console.capture() as capture:
54
+ console.print(tree_root, overflow="ignore", no_wrap=True, crop=False)
55
+ logger.info(f"Root directory: [link=file://{source}]{source}[/link]")
56
+ logger.info(capture.get(), extra={"console": console})
53
57
 
54
58
 
55
59
  def _compress_tarball(source: pathlib.Path, output: pathlib.Path) -> None:
@@ -156,7 +156,7 @@ def list_all_files(source_path: pathlib.Path, deref_symlinks, ignore_group: Opti
156
156
 
157
157
  # This is needed to prevent infinite recursion when walking with followlinks
158
158
  visited_inodes = set()
159
- for root, dirnames, files in source_path.walk(top_down=True, follow_symlinks=deref_symlinks):
159
+ for root, dirnames, files in os.walk(source_path, topdown=True, followlinks=deref_symlinks):
160
160
  dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
161
161
  if deref_symlinks:
162
162
  inode = os.stat(root).st_ino
@@ -167,7 +167,7 @@ def list_all_files(source_path: pathlib.Path, deref_symlinks, ignore_group: Opti
167
167
  ff = []
168
168
  files.sort()
169
169
  for fname in files:
170
- abspath = (root / fname).absolute()
170
+ abspath = (pathlib.Path(root) / fname).absolute()
171
171
  # Only consider files that exist (e.g. disregard symlinks that point to non-existent files)
172
172
  if not os.path.exists(abspath):
173
173
  logger.info(f"Skipping non-existent file {abspath}")
flyte/_deploy.py CHANGED
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import hashlib
5
+ import sys
4
6
  import typing
5
7
  from dataclasses import dataclass
6
8
  from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
7
9
 
10
+ import cloudpickle
8
11
  import rich.repr
9
12
 
10
13
  import flyte.errors
@@ -160,9 +163,11 @@ async def _build_images(deployment: DeploymentPlan) -> ImageCache:
160
163
  logger.warning(f"Built Image for environment {env_name}, image: {image_uri}")
161
164
  env = deployment.envs[env_name]
162
165
  if isinstance(env.image, Image):
163
- image_identifier_map[env.image.identifier] = image_uri
166
+ py_version = "{}.{}".format(*env.image.python_version)
167
+ image_identifier_map[env.image.identifier] = {py_version: image_uri}
164
168
  elif env.image == "auto":
165
- image_identifier_map["auto"] = image_uri
169
+ py_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
170
+ image_identifier_map["auto"] = {py_version: image_uri}
166
171
 
167
172
  return ImageCache(image_lookup=image_identifier_map)
168
173
 
@@ -175,15 +180,18 @@ async def apply(deployment_plan: DeploymentPlan, copy_style: CopyFiles, dryrun:
175
180
 
176
181
  image_cache = await _build_images(deployment_plan)
177
182
 
178
- version = deployment_plan.version
179
- if copy_style == "none" and not version:
183
+ if copy_style == "none" and not deployment_plan.version:
180
184
  raise flyte.errors.DeploymentError("Version must be set when copy_style is none")
181
185
  else:
182
186
  code_bundle = await build_code_bundle(from_dir=cfg.root_dir, dryrun=dryrun, copy_style=copy_style)
183
- version = version or code_bundle.computed_version
184
- # TODO we should update the version to include the image cache digest and code bundle digest. This is
185
- # to ensure that changes in image dependencies, cause an update to the deployment version.
186
- # TODO Also hash the environment and tasks to ensure that changes in the environment or tasks
187
+ if deployment_plan.version:
188
+ version = deployment_plan.version
189
+ else:
190
+ h = hashlib.md5()
191
+ h.update(cloudpickle.dumps(deployment_plan.envs))
192
+ h.update(code_bundle.computed_version.encode("utf-8"))
193
+ h.update(cloudpickle.dumps(image_cache))
194
+ version = h.hexdigest()
187
195
 
188
196
  sc = SerializationContext(
189
197
  project=cfg.project,
flyte/_image.py CHANGED
@@ -203,6 +203,7 @@ class UVProject(PipOption, Layer):
203
203
 
204
204
  super().update_hash(hasher)
205
205
  filehash_update(self.uvlock, hasher)
206
+ filehash_update(self.pyproject, hasher)
206
207
 
207
208
 
208
209
  @rich.repr.auto
@@ -435,7 +436,10 @@ class Image:
435
436
  # Only get the non-None values in the Image to ensure the hash is consistent
436
437
  # across different SDK versions.
437
438
  # Layers can specify a _compute_identifier optionally, but the default will just stringify
438
- image_dict = asdict(self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None and k != "_layers"})
439
+ image_dict = asdict(
440
+ self,
441
+ dict_factory=lambda x: {k: v for (k, v) in x if v is not None and k not in ("_layers", "python_version")},
442
+ )
439
443
  layers_str_repr = "".join([layer.identifier() for layer in self._layers])
440
444
  image_dict["layers"] = layers_str_repr
441
445
  spec_bytes = image_dict.__str__().encode("utf-8")
flyte/_initialize.py CHANGED
@@ -33,6 +33,7 @@ class CommonInit:
33
33
  project: str | None = None
34
34
  domain: str | None = None
35
35
  batch_size: int = 1000
36
+ source_config_path: Optional[Path] = None # Only used for documentation
36
37
 
37
38
 
38
39
  @dataclass(init=True, kw_only=True, repr=True, eq=True, frozen=True)
@@ -110,6 +111,12 @@ async def _initialize_client(
110
111
  )
111
112
 
112
113
 
114
+ def _initialize_logger(log_level: int | None = None):
115
+ initialize_logger(enable_rich=True)
116
+ if log_level:
117
+ initialize_logger(log_level=log_level, enable_rich=True)
118
+
119
+
113
120
  @syncify
114
121
  async def init(
115
122
  org: str | None = None,
@@ -134,6 +141,7 @@ async def init(
134
141
  storage: Storage | None = None,
135
142
  batch_size: int = 1000,
136
143
  image_builder: ImageBuildEngine.ImageBuilderType = "local",
144
+ source_config_path: Optional[Path] = None,
137
145
  ) -> None:
138
146
  """
139
147
  Initialize the Flyte system with the given configuration. This method should be called before any other Flyte
@@ -169,17 +177,12 @@ async def init(
169
177
  :param batch_size: Optional batch size for operations that use listings, defaults to 1000, so limit larger than
170
178
  batch_size will be split into multiple requests.
171
179
  :param image_builder: Optional image builder configuration, if not provided, the default image builder will be used.
172
-
180
+ :param source_config_path: Optional path to the source configuration file (This is only used for documentation)
173
181
  :return: None
174
182
  """
175
- from flyte._tools import ipython_check
176
183
  from flyte._utils import get_cwd_editable_install, org_from_endpoint, sanitize_endpoint
177
184
 
178
- interactive_mode = ipython_check()
179
-
180
- initialize_logger(enable_rich=interactive_mode)
181
- if log_level:
182
- initialize_logger(log_level=log_level, enable_rich=interactive_mode)
185
+ _initialize_logger(log_level=log_level)
183
186
 
184
187
  global _init_config # noqa: PLW0603
185
188
 
@@ -223,6 +226,7 @@ async def init(
223
226
  org=org or org_from_endpoint(endpoint),
224
227
  batch_size=batch_size,
225
228
  image_builder=image_builder,
229
+ source_config_path=source_config_path,
226
230
  )
227
231
 
228
232
 
@@ -231,6 +235,7 @@ async def init_from_config(
231
235
  path_or_config: str | Path | Config | None = None,
232
236
  root_dir: Path | None = None,
233
237
  log_level: int | None = None,
238
+ storage: Storage | None = None,
234
239
  ) -> None:
235
240
  """
236
241
  Initialize the Flyte system using a configuration file or Config object. This method should be called before any
@@ -243,11 +248,15 @@ async def init_from_config(
243
248
  if not available, the current working directory.
244
249
  :param log_level: Optional logging level for the framework logger,
245
250
  default is set using the default initialization policies
251
+ :param storage: Optional blob store (S3, GCS, Azure) configuration if needed to access (i.e. using Minio)
246
252
  :return: None
247
253
  """
254
+ from rich.highlighter import ReprHighlighter
255
+
248
256
  import flyte.config as config
249
257
 
250
258
  cfg: config.Config
259
+ cfg_path: Optional[Path] = None
251
260
  if path_or_config is None:
252
261
  # If no path is provided, use the default config file
253
262
  cfg = config.auto()
@@ -266,7 +275,9 @@ async def init_from_config(
266
275
  else:
267
276
  cfg = path_or_config
268
277
 
269
- logger.debug(f"Flyte config initialized as {cfg}")
278
+ _initialize_logger(log_level=log_level)
279
+
280
+ logger.info(f"Flyte config initialized as {cfg}", extra={"highlighter": ReprHighlighter()})
270
281
  await init.aio(
271
282
  org=cfg.task.org,
272
283
  project=cfg.task.project,
@@ -283,6 +294,8 @@ async def init_from_config(
283
294
  root_dir=root_dir,
284
295
  log_level=log_level,
285
296
  image_builder=cfg.image.builder,
297
+ storage=storage,
298
+ source_config_path=cfg_path,
286
299
  )
287
300
 
288
301
 
flyte/_interface.py CHANGED
@@ -1,7 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- from typing import Dict, Generator, Tuple, Type, TypeVar, Union, cast, get_args, get_type_hints
4
+ import typing
5
+ from enum import Enum
6
+ from typing import Dict, Generator, Literal, Tuple, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints
7
+
8
+ from flyte._logging import logger
9
+
10
+ LITERAL_ENUM = "LiteralEnum"
5
11
 
6
12
 
7
13
  def default_output_name(index: int = 0) -> str:
@@ -69,7 +75,15 @@ def extract_return_annotation(return_annotation: Union[Type, Tuple, None]) -> Di
69
75
  if len(return_annotation.__args__) == 1: # type: ignore
70
76
  raise TypeError("Tuples should be used to indicate multiple return values, found only one return variable.")
71
77
  ra = get_args(return_annotation)
72
- return dict(zip(list(output_name_generator(len(ra))), ra))
78
+ annotations = {}
79
+ for i, r in enumerate(ra):
80
+ if r is Ellipsis:
81
+ raise TypeError("Variable length tuples are not supported as return types.")
82
+ if get_origin(r) is Literal:
83
+ annotations[default_output_name(i)] = literal_to_enum(cast(Type, r))
84
+ else:
85
+ annotations[default_output_name(i)] = r
86
+ return annotations
73
87
 
74
88
  elif isinstance(return_annotation, tuple):
75
89
  if len(return_annotation) == 1:
@@ -79,4 +93,25 @@ def extract_return_annotation(return_annotation: Union[Type, Tuple, None]) -> Di
79
93
  else:
80
94
  # Handle all other single return types
81
95
  # Task returns unnamed native tuple
96
+ if get_origin(return_annotation) is Literal:
97
+ return {default_output_name(): literal_to_enum(cast(Type, return_annotation))}
82
98
  return {default_output_name(): cast(Type, return_annotation)}
99
+
100
+
101
+ def literal_to_enum(literal_type: Type) -> Type[Enum | typing.Any]:
102
+ """Convert a Literal[...] into Union[str, Enum]."""
103
+
104
+ if get_origin(literal_type) is not Literal:
105
+ raise TypeError(f"{literal_type} is not a Literal")
106
+
107
+ values = get_args(literal_type)
108
+ if not all(isinstance(v, str) for v in values):
109
+ logger.warning(f"Literal type {literal_type} contains non-string values, using Any instead of Enum")
110
+ return typing.Any
111
+ # Deduplicate & keep order
112
+ enum_dict = {str(v).upper(): v for v in values}
113
+
114
+ # Dynamically create an Enum
115
+ literal_enum = Enum(LITERAL_ENUM, enum_dict) # type: ignore
116
+
117
+ return literal_enum # type: ignore
@@ -6,7 +6,7 @@ import tempfile
6
6
  import typing
7
7
  from pathlib import Path
8
8
  from string import Template
9
- from typing import ClassVar, Optional, Protocol, cast
9
+ from typing import ClassVar, List, Optional, Protocol, cast
10
10
 
11
11
  import aiofiles
12
12
  import click
@@ -245,6 +245,9 @@ class UVProjectHandler:
245
245
  else:
246
246
  # Copy the entire project.
247
247
  pyproject_dst = copy_files_to_context(layer.pyproject.parent, context_path)
248
+ if layer.uvlock:
249
+ # Sometimes the uv.lock file is in a different folder, if it's specified, let's copy it there explicitly
250
+ shutil.copy(layer.uvlock, pyproject_dst)
248
251
  delta = UV_LOCK_INSTALL_TEMPLATE.substitute(
249
252
  PYPROJECT_PATH=pyproject_dst.relative_to(context_path),
250
253
  PIP_INSTALL_ARGS=" ".join(layer.get_pip_install_args()),
@@ -263,7 +266,37 @@ class DockerIgnoreHandler:
263
266
 
264
267
  class CopyConfigHandler:
265
268
  @staticmethod
266
- async def handle(layer: CopyConfig, context_path: Path, dockerfile: str) -> str:
269
+ def list_dockerignore(root_path: Optional[Path], docker_ignore_file_path: Optional[Path]) -> List[str]:
270
+ patterns: List[str] = []
271
+ dockerignore_path: Optional[Path] = None
272
+ if root_path:
273
+ dockerignore_path = root_path / ".dockerignore"
274
+ # DockerIgnore layer should be first priority
275
+ if docker_ignore_file_path:
276
+ dockerignore_path = docker_ignore_file_path
277
+
278
+ # Return empty list if no .dockerignore file found
279
+ if not dockerignore_path or not dockerignore_path.exists() or not dockerignore_path.is_file():
280
+ logger.info(f".dockerignore file not found at path: {dockerignore_path}")
281
+ return patterns
282
+
283
+ try:
284
+ with open(dockerignore_path, "r", encoding="utf-8") as f:
285
+ for line in f:
286
+ stripped_line = line.strip()
287
+ # Skip empty lines, whitespace-only lines, and comments
288
+ if not stripped_line or stripped_line.startswith("#"):
289
+ continue
290
+ patterns.append(stripped_line)
291
+ except Exception as e:
292
+ logger.error(f"Failed to read .dockerignore file at {dockerignore_path}: {e}")
293
+ return []
294
+ return patterns
295
+
296
+ @staticmethod
297
+ async def handle(
298
+ layer: CopyConfig, context_path: Path, dockerfile: str, docker_ignore_file_path: Optional[Path]
299
+ ) -> str:
267
300
  # Copy the source config file or directory to the context path
268
301
  if layer.src.is_absolute() or ".." in str(layer.src):
269
302
  dst_path = context_path / str(layer.src.absolute()).replace("/", "./_flyte_abs_context/", 1)
@@ -272,18 +305,26 @@ class CopyConfigHandler:
272
305
 
273
306
  dst_path.parent.mkdir(parents=True, exist_ok=True)
274
307
  abs_path = layer.src.absolute()
308
+
275
309
  if layer.src.is_file():
276
310
  # Copy the file
277
311
  shutil.copy(abs_path, dst_path)
278
312
  elif layer.src.is_dir():
279
313
  # Copy the entire directory
280
- shutil.copytree(abs_path, dst_path, dirs_exist_ok=True)
314
+ from flyte._initialize import _get_init_config
315
+
316
+ init_config = _get_init_config()
317
+ root_path = init_config.root_dir if init_config else None
318
+ docker_ignore_patterns = CopyConfigHandler.list_dockerignore(root_path, docker_ignore_file_path)
319
+ shutil.copytree(
320
+ abs_path, dst_path, dirs_exist_ok=True, ignore=shutil.ignore_patterns(*docker_ignore_patterns)
321
+ )
281
322
  else:
282
- raise ValueError(f"Source path is neither file nor directory: {layer.src}")
323
+ logger.error(f"Source path not exists: {layer.src}")
324
+ return dockerfile
283
325
 
284
326
  # Add a copy command to the dockerfile
285
327
  dockerfile += f"\nCOPY {dst_path.relative_to(context_path)} {layer.dst}\n"
286
-
287
328
  return dockerfile
288
329
 
289
330
 
@@ -349,7 +390,9 @@ def _get_secret_mounts_layer(secrets: typing.Tuple[str | Secret, ...] | None) ->
349
390
  return secret_mounts_layer
350
391
 
351
392
 
352
- async def _process_layer(layer: Layer, context_path: Path, dockerfile: str) -> str:
393
+ async def _process_layer(
394
+ layer: Layer, context_path: Path, dockerfile: str, docker_ignore_file_path: Optional[Path]
395
+ ) -> str:
353
396
  match layer:
354
397
  case PythonWheels():
355
398
  # Handle Python wheels
@@ -385,7 +428,7 @@ async def _process_layer(layer: Layer, context_path: Path, dockerfile: str) -> s
385
428
 
386
429
  case CopyConfig():
387
430
  # Handle local files and folders
388
- dockerfile = await CopyConfigHandler.handle(layer, context_path, dockerfile)
431
+ dockerfile = await CopyConfigHandler.handle(layer, context_path, dockerfile, docker_ignore_file_path)
389
432
 
390
433
  case Commands():
391
434
  # Handle commands
@@ -512,6 +555,15 @@ class DockerImageBuilder(ImageBuilder):
512
555
  else:
513
556
  logger.info("Buildx builder already exists.")
514
557
 
558
+ def get_docker_ignore(self, image: Image) -> Optional[Path]:
559
+ """Get the .dockerignore file path from the image layers."""
560
+ # Look for DockerIgnore layer in the image layers
561
+ for layer in image._layers:
562
+ if isinstance(layer, DockerIgnore) and layer.path.strip():
563
+ return Path(layer.path)
564
+
565
+ return None
566
+
515
567
  async def _build_image(self, image: Image, *, push: bool = True, dry_run: bool = False) -> str:
516
568
  """
517
569
  if default image (only base image and locked), raise an error, don't have a dockerfile
@@ -541,8 +593,9 @@ class DockerImageBuilder(ImageBuilder):
541
593
  PYTHON_VERSION=f"{image.python_version[0]}.{image.python_version[1]}",
542
594
  )
543
595
 
596
+ docker_ignore_file_path = self.get_docker_ignore(image)
544
597
  for layer in image._layers:
545
- dockerfile = await _process_layer(layer, tmp_path, dockerfile)
598
+ dockerfile = await _process_layer(layer, tmp_path, dockerfile, docker_ignore_file_path)
546
599
 
547
600
  dockerfile += DOCKER_FILE_BASE_FOOTER.substitute(F_IMG_ID=image.uri)
548
601
 
@@ -235,7 +235,7 @@ class ImageBuildEngine:
235
235
 
236
236
 
237
237
  class ImageCache(BaseModel):
238
- image_lookup: Dict[str, str]
238
+ image_lookup: Dict[str, Dict[str, str]]
239
239
  serialized_form: str | None = None
240
240
 
241
241
  @property
@@ -273,10 +273,11 @@ class ImageCache(BaseModel):
273
273
  """
274
274
  tuples = []
275
275
  for k, v in self.image_lookup.items():
276
- tuples.append(
277
- [
278
- ("Name", k),
279
- ("image", v),
280
- ]
281
- )
276
+ for py_version, image_uri in v.items():
277
+ tuples.append(
278
+ [
279
+ ("Name", f"{k} (py{py_version})"),
280
+ ("image", image_uri),
281
+ ]
282
+ )
282
283
  return tuples
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Optional, Tuple, cast
10
10
  from uuid import uuid4
11
11
 
12
12
  import aiofiles
13
- import click
14
13
 
15
14
  import flyte
16
15
  import flyte.errors
@@ -42,7 +41,6 @@ if TYPE_CHECKING:
42
41
  from flyte._protos.imagebuilder import definition_pb2 as image_definition_pb2
43
42
 
44
43
  IMAGE_TASK_NAME = "build-image"
45
- OPTIMIZE_TASK_NAME = "optimize-task"
46
44
  IMAGE_TASK_PROJECT = "system"
47
45
  IMAGE_TASK_DOMAIN = "production"
48
46
 
@@ -85,10 +83,10 @@ class RemoteImageChecker(ImageChecker):
85
83
  raise ValueError("remote client should not be None")
86
84
  cls._images_client = image_service_pb2_grpc.ImageServiceStub(cfg.client._channel)
87
85
  resp = await cls._images_client.GetImage(req)
88
- logger.warning(click.style(f"Image {resp.image.fqin} found. Skip building.", fg="blue"))
86
+ logger.warning(f"[blue]Image {resp.image.fqin} found. Skip building.[/blue]")
89
87
  return resp.image.fqin
90
88
  except Exception:
91
- logger.warning(click.style(f"Image {image_name} was not found or has expired.", fg="blue"))
89
+ logger.warning(f"[blue]Image {image_name} was not found or has expired.[/blue]", extra={"highlight": False})
92
90
  return None
93
91
 
94
92
 
@@ -110,37 +108,25 @@ class RemoteImageBuilder(ImageBuilder):
110
108
  domain=IMAGE_TASK_DOMAIN,
111
109
  auto_version="latest",
112
110
  ).override.aio(secrets=_get_build_secrets_from_image(image))
111
+
112
+ logger.warning("[bold blue]🐳 Submitting a new build...[/bold blue]")
113
113
  run = cast(
114
114
  Run,
115
115
  await flyte.with_runcontext(project=IMAGE_TASK_PROJECT, domain=IMAGE_TASK_DOMAIN).run.aio(
116
116
  entity, spec=spec, context=context, target_image=image_name
117
117
  ),
118
118
  )
119
- logger.warning(click.style("🐳 Submitting a new build...", fg="blue", bold=True))
119
+ logger.warning(f" Waiting for build to finish at: [bold cyan link={run.url}]{run.url}[/bold cyan link]")
120
120
 
121
- logger.warning(click.style("⏳ Waiting for build to finish at: " + click.style(run.url, fg="cyan"), bold=True))
122
121
  await run.wait.aio(quiet=True)
123
122
  run_details = await run.details.aio()
124
123
 
125
124
  elapsed = str(datetime.now(timezone.utc) - start).split(".")[0]
126
125
 
127
126
  if run_details.action_details.raw_phase == run_definition_pb2.PHASE_SUCCEEDED:
128
- logger.warning(click.style(f"✅ Build completed in {elapsed}!", bold=True, fg="green"))
129
- try:
130
- entity = remote.Task.get(
131
- name=OPTIMIZE_TASK_NAME,
132
- project=IMAGE_TASK_PROJECT,
133
- domain=IMAGE_TASK_DOMAIN,
134
- auto_version="latest",
135
- )
136
- await flyte.with_runcontext(project=IMAGE_TASK_PROJECT, domain=IMAGE_TASK_DOMAIN).run.aio(
137
- entity, target_image=image_name
138
- )
139
- except Exception as e:
140
- # Ignore the error if optimize is not enabled in the backend.
141
- logger.warning(f"Failed to run optimize task with error: {e}")
127
+ logger.warning(f"[bold green]✅ Build completed in {elapsed}![/bold green]")
142
128
  else:
143
- raise flyte.errors.ImageBuildError(f"❌ Build failed in {elapsed} at {click.style(run.url, fg='cyan')}")
129
+ raise flyte.errors.ImageBuildError(f"❌ Build failed in {elapsed} at [cyan]{run.url}[/cyan]")
144
130
 
145
131
  outputs = await run_details.outputs()
146
132
  return _get_fully_qualified_image_name(outputs)
@@ -183,11 +169,8 @@ async def _validate_configuration(image: Image) -> Tuple[str, Optional[str]]:
183
169
  context_size = tar_path.stat().st_size
184
170
  if context_size > 5 * 1024 * 1024:
185
171
  logger.warning(
186
- click.style(
187
- f"Context size is {context_size / (1024 * 1024):.2f} MB, which is larger than 5 MB. "
188
- "Upload and build speed will be impacted.",
189
- fg="yellow",
190
- )
172
+ f"[yellow]Context size is {context_size / (1024 * 1024):.2f} MB, which is larger than 5 MB. "
173
+ "Upload and build speed will be impacted.[/yellow]",
191
174
  )
192
175
  _, context_url = await remote.upload_file.aio(context_dst)
193
176
  else:
@@ -4,6 +4,7 @@ It includes a Resolver interface for loading tasks, and functions to load classe
4
4
  """
5
5
 
6
6
  import copy
7
+ import sys
7
8
  import typing
8
9
  from datetime import timedelta
9
10
  from typing import Optional, cast
@@ -217,16 +218,25 @@ def _get_urun_container(
217
218
  img_uri = task_template.image.uri
218
219
  elif serialize_context.image_cache and image_id not in serialize_context.image_cache.image_lookup:
219
220
  img_uri = task_template.image.uri
220
- from flyte._version import __version__
221
221
 
222
222
  logger.warning(
223
- f"Image {task_template.image} not found in the image cache: {serialize_context.image_cache.image_lookup}.\n"
224
- f"This typically occurs when the Flyte SDK version (`{__version__}`) used in the task environment "
225
- f"differs from the version used to compile or deploy it.\n"
226
- f"Ensure both environments use the same Flyte SDK version to avoid inconsistencies in image resolution."
223
+ f"Image {task_template.image} not found in the image cache: {serialize_context.image_cache.image_lookup}."
227
224
  )
228
225
  else:
229
- img_uri = serialize_context.image_cache.image_lookup[image_id]
226
+ python_version_str = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
227
+ version_lookup = serialize_context.image_cache.image_lookup[image_id]
228
+ if python_version_str in version_lookup:
229
+ img_uri = version_lookup[python_version_str]
230
+ elif version_lookup:
231
+ # Fallback: try to get any available version
232
+ fallback_py_version, img_uri = next(iter(version_lookup.items()))
233
+ logger.warning(
234
+ f"Image {task_template.image} for python version {python_version_str} "
235
+ f"not found in the image cache: {serialize_context.image_cache.image_lookup}.\n"
236
+ f"Fall back using image {img_uri} for python version {fallback_py_version} ."
237
+ )
238
+ else:
239
+ img_uri = task_template.image.uri
230
240
 
231
241
  return tasks_pb2.Container(
232
242
  image=img_uri,
File without changes