flyte 2.0.0b22__py3-none-any.whl → 2.0.0b23__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.

Potentially problematic release.


This version of flyte might be problematic. Click here for more details.

Files changed (88) hide show
  1. flyte/__init__.py +5 -0
  2. flyte/_bin/runtime.py +35 -5
  3. flyte/_cache/cache.py +4 -2
  4. flyte/_cache/local_cache.py +215 -0
  5. flyte/_code_bundle/bundle.py +1 -0
  6. flyte/_debug/constants.py +0 -1
  7. flyte/_debug/vscode.py +6 -1
  8. flyte/_deploy.py +193 -52
  9. flyte/_environment.py +5 -0
  10. flyte/_excepthook.py +1 -1
  11. flyte/_image.py +101 -72
  12. flyte/_initialize.py +23 -0
  13. flyte/_internal/controllers/_local_controller.py +64 -24
  14. flyte/_internal/controllers/remote/_action.py +4 -1
  15. flyte/_internal/controllers/remote/_controller.py +5 -2
  16. flyte/_internal/controllers/remote/_core.py +6 -3
  17. flyte/_internal/controllers/remote/_informer.py +1 -1
  18. flyte/_internal/imagebuild/docker_builder.py +92 -28
  19. flyte/_internal/imagebuild/image_builder.py +7 -13
  20. flyte/_internal/imagebuild/remote_builder.py +6 -1
  21. flyte/_internal/runtime/io.py +13 -1
  22. flyte/_internal/runtime/rusty.py +17 -2
  23. flyte/_internal/runtime/task_serde.py +14 -20
  24. flyte/_internal/runtime/taskrunner.py +1 -1
  25. flyte/_internal/runtime/trigger_serde.py +153 -0
  26. flyte/_logging.py +1 -1
  27. flyte/_protos/common/identifier_pb2.py +19 -1
  28. flyte/_protos/common/identifier_pb2.pyi +22 -0
  29. flyte/_protos/workflow/common_pb2.py +14 -3
  30. flyte/_protos/workflow/common_pb2.pyi +49 -0
  31. flyte/_protos/workflow/queue_service_pb2.py +41 -35
  32. flyte/_protos/workflow/queue_service_pb2.pyi +26 -12
  33. flyte/_protos/workflow/queue_service_pb2_grpc.py +34 -0
  34. flyte/_protos/workflow/run_definition_pb2.py +38 -38
  35. flyte/_protos/workflow/run_definition_pb2.pyi +4 -2
  36. flyte/_protos/workflow/run_service_pb2.py +60 -50
  37. flyte/_protos/workflow/run_service_pb2.pyi +24 -6
  38. flyte/_protos/workflow/run_service_pb2_grpc.py +34 -0
  39. flyte/_protos/workflow/task_definition_pb2.py +15 -11
  40. flyte/_protos/workflow/task_definition_pb2.pyi +19 -2
  41. flyte/_protos/workflow/task_service_pb2.py +18 -17
  42. flyte/_protos/workflow/task_service_pb2.pyi +5 -2
  43. flyte/_protos/workflow/trigger_definition_pb2.py +66 -0
  44. flyte/_protos/workflow/trigger_definition_pb2.pyi +117 -0
  45. flyte/_protos/workflow/trigger_definition_pb2_grpc.py +4 -0
  46. flyte/_protos/workflow/trigger_service_pb2.py +96 -0
  47. flyte/_protos/workflow/trigger_service_pb2.pyi +110 -0
  48. flyte/_protos/workflow/trigger_service_pb2_grpc.py +281 -0
  49. flyte/_run.py +42 -15
  50. flyte/_task.py +35 -4
  51. flyte/_task_environment.py +60 -15
  52. flyte/_trigger.py +382 -0
  53. flyte/_version.py +3 -3
  54. flyte/cli/_abort.py +3 -3
  55. flyte/cli/_build.py +1 -3
  56. flyte/cli/_common.py +15 -2
  57. flyte/cli/_create.py +74 -0
  58. flyte/cli/_delete.py +23 -1
  59. flyte/cli/_deploy.py +5 -9
  60. flyte/cli/_get.py +75 -34
  61. flyte/cli/_params.py +4 -2
  62. flyte/cli/_run.py +12 -3
  63. flyte/cli/_update.py +36 -0
  64. flyte/cli/_user.py +17 -0
  65. flyte/cli/main.py +9 -1
  66. flyte/errors.py +9 -0
  67. flyte/io/_dir.py +513 -115
  68. flyte/io/_file.py +495 -135
  69. flyte/models.py +32 -0
  70. flyte/remote/__init__.py +6 -1
  71. flyte/remote/_client/_protocols.py +36 -2
  72. flyte/remote/_client/controlplane.py +19 -3
  73. flyte/remote/_run.py +42 -2
  74. flyte/remote/_task.py +14 -1
  75. flyte/remote/_trigger.py +308 -0
  76. flyte/remote/_user.py +33 -0
  77. flyte/storage/__init__.py +6 -1
  78. flyte/storage/_storage.py +119 -101
  79. flyte/types/_pickle.py +16 -3
  80. {flyte-2.0.0b22.data → flyte-2.0.0b23.data}/scripts/runtime.py +35 -5
  81. {flyte-2.0.0b22.dist-info → flyte-2.0.0b23.dist-info}/METADATA +3 -1
  82. {flyte-2.0.0b22.dist-info → flyte-2.0.0b23.dist-info}/RECORD +87 -75
  83. flyte/_protos/secret/secret_pb2_grpc_grpc.py +0 -198
  84. {flyte-2.0.0b22.data → flyte-2.0.0b23.data}/scripts/debug.py +0 -0
  85. {flyte-2.0.0b22.dist-info → flyte-2.0.0b23.dist-info}/WHEEL +0 -0
  86. {flyte-2.0.0b22.dist-info → flyte-2.0.0b23.dist-info}/entry_points.txt +0 -0
  87. {flyte-2.0.0b22.dist-info → flyte-2.0.0b23.dist-info}/licenses/LICENSE +0 -0
  88. {flyte-2.0.0b22.dist-info → flyte-2.0.0b23.dist-info}/top_level.txt +0 -0
flyte/_deploy.py CHANGED
@@ -2,10 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import hashlib
5
- import sys
6
- import typing
7
5
  from dataclasses import dataclass
8
- from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
6
+ from typing import TYPE_CHECKING, Dict, List, Optional, Protocol, Set, Tuple, Type
9
7
 
10
8
  import cloudpickle
11
9
  import rich.repr
@@ -22,7 +20,7 @@ from ._task import TaskTemplate
22
20
  from ._task_environment import TaskEnvironment
23
21
 
24
22
  if TYPE_CHECKING:
25
- from flyte._protos.workflow import task_definition_pb2
23
+ from flyte._protos.workflow import task_definition_pb2, trigger_definition_pb2
26
24
 
27
25
  from ._code_bundle import CopyFiles
28
26
  from ._internal.imagebuild.image_builder import ImageCache
@@ -37,53 +35,110 @@ class DeploymentPlan:
37
35
 
38
36
  @rich.repr.auto
39
37
  @dataclass
38
+ class DeploymentContext:
39
+ """
40
+ Context for deployment operations.
41
+ """
42
+
43
+ environment: Environment | TaskEnvironment
44
+ serialization_context: SerializationContext
45
+ dryrun: bool = False
46
+
47
+
48
+ @rich.repr.auto
49
+ @dataclass
50
+ class DeployedTask:
51
+ deployed_task: task_definition_pb2.TaskSpec
52
+ deployed_triggers: List[trigger_definition_pb2.TaskTrigger]
53
+
54
+ def summary_repr(self) -> str:
55
+ """
56
+ Returns a summary representation of the deployed task.
57
+ """
58
+ return (
59
+ f"DeployedTask(name={self.deployed_task.task_template.id.name}, "
60
+ f"version={self.deployed_task.task_template.id.version})"
61
+ )
62
+
63
+ def table_repr(self) -> List[Tuple[str, ...]]:
64
+ """
65
+ Returns a table representation of the deployed task.
66
+ """
67
+ return [
68
+ ("name", self.deployed_task.task_template.id.name),
69
+ ("version", self.deployed_task.task_template.id.version),
70
+ ("triggers", ",".join([t.name for t in self.deployed_triggers])),
71
+ ]
72
+
73
+
74
+ @rich.repr.auto
75
+ @dataclass
76
+ class DeployedEnv:
77
+ env: Environment
78
+ deployed_entities: List[DeployedTask]
79
+
80
+ def summary_repr(self) -> str:
81
+ """
82
+ Returns a summary representation of the deployment.
83
+ """
84
+ entities = ", ".join(f"{e.summary_repr()}" for e in self.deployed_entities or [])
85
+ return f"Deployment(env=[{self.env.name}], entities=[{entities}])"
86
+
87
+ def table_repr(self) -> List[List[Tuple[str, ...]]]:
88
+ """
89
+ Returns a detailed representation of the deployed tasks.
90
+ """
91
+ tuples = []
92
+ if self.deployed_entities:
93
+ for e in self.deployed_entities:
94
+ tuples.append(e.table_repr())
95
+ return tuples
96
+
97
+ def env_repr(self) -> List[Tuple[str, ...]]:
98
+ """
99
+ Returns a detailed representation of the deployed environments.
100
+ """
101
+ env = self.env
102
+ return [
103
+ ("environment", env.name),
104
+ ("image", env.image.uri if isinstance(env.image, Image) else env.image or ""),
105
+ ]
106
+
107
+
108
+ @rich.repr.auto
109
+ @dataclass(frozen=True)
40
110
  class Deployment:
41
- envs: Dict[str, Environment]
42
- deployed_tasks: List[task_definition_pb2.TaskSpec] | None = None
111
+ envs: Dict[str, DeployedEnv]
43
112
 
44
113
  def summary_repr(self) -> str:
45
114
  """
46
115
  Returns a summary representation of the deployment.
47
116
  """
48
- env_names = ", ".join(self.envs.keys())
49
- task_names_versions = ", ".join(
50
- f"{task.task_template.id.name} (v{task.task_template.id.version})" for task in self.deployed_tasks or []
51
- )
52
- return f"Deployment(envs=[{env_names}], tasks=[{task_names_versions}])"
117
+ envs = ", ".join(f"{e.summary_repr()}" for e in self.envs.values() or [])
118
+ return f"Deployment(envs=[{envs}])"
53
119
 
54
- def task_repr(self) -> List[List[Tuple[str, str]]]:
120
+ def table_repr(self) -> List[List[Tuple[str, ...]]]:
55
121
  """
56
122
  Returns a detailed representation of the deployed tasks.
57
123
  """
58
124
  tuples = []
59
- if self.deployed_tasks:
60
- for task in self.deployed_tasks:
61
- tuples.append(
62
- [
63
- ("name", task.task_template.id.name),
64
- ("version", task.task_template.id.version),
65
- ]
66
- )
125
+ for d in self.envs.values():
126
+ tuples.extend(d.table_repr())
67
127
  return tuples
68
128
 
69
- def env_repr(self) -> List[List[Tuple[str, str]]]:
129
+ def env_repr(self) -> List[List[Tuple[str, ...]]]:
70
130
  """
71
131
  Returns a detailed representation of the deployed environments.
72
132
  """
73
133
  tuples = []
74
- for env_name, env in self.envs.items():
75
- tuples.append(
76
- [
77
- ("environment", env_name),
78
- ("image", env.image.uri if isinstance(env.image, Image) else env.image or ""),
79
- ]
80
- )
134
+ for d in self.envs.values():
135
+ tuples.append(d.env_repr())
81
136
  return tuples
82
137
 
83
138
 
84
139
  async def _deploy_task(
85
140
  task: TaskTemplate, serialization_context: SerializationContext, dryrun: bool = False
86
- ) -> task_definition_pb2.TaskSpec:
141
+ ) -> DeployedTask:
87
142
  """
88
143
  Deploy the given task.
89
144
  """
@@ -92,13 +147,14 @@ async def _deploy_task(
92
147
 
93
148
  from ._internal.runtime.convert import convert_upload_default_inputs
94
149
  from ._internal.runtime.task_serde import translate_task_to_wire
150
+ from ._internal.runtime.trigger_serde import to_task_trigger
95
151
  from ._protos.workflow import task_definition_pb2, task_service_pb2
96
152
 
97
153
  image_uri = task.image.uri if isinstance(task.image, Image) else task.image
98
154
 
99
155
  try:
100
156
  if dryrun:
101
- return translate_task_to_wire(task, serialization_context)
157
+ return DeployedTask(translate_task_to_wire(task, serialization_context), [])
102
158
 
103
159
  default_inputs = await convert_upload_default_inputs(task.interface)
104
160
  spec = translate_task_to_wire(task, serialization_context, default_inputs=default_inputs)
@@ -115,15 +171,31 @@ async def _deploy_task(
115
171
  name=spec.task_template.id.name,
116
172
  )
117
173
 
174
+ deployable_triggers_coros = []
175
+ for t in task.triggers:
176
+ inputs = spec.task_template.interface.inputs
177
+ default_inputs = spec.default_inputs
178
+ deployable_triggers_coros.append(
179
+ to_task_trigger(t=t, task_name=task.name, task_inputs=inputs, task_default_inputs=list(default_inputs))
180
+ )
181
+
182
+ deployable_triggers = await asyncio.gather(*deployable_triggers_coros)
118
183
  try:
119
- await get_client().task_service.DeployTask(task_service_pb2.DeployTaskRequest(task_id=task_id, spec=spec))
184
+ await get_client().task_service.DeployTask(
185
+ task_service_pb2.DeployTaskRequest(
186
+ task_id=task_id,
187
+ spec=spec,
188
+ triggers=deployable_triggers,
189
+ )
190
+ )
120
191
  logger.info(f"Deployed task {task.name} with version {task_id.version}")
121
192
  except grpc.aio.AioRpcError as e:
122
193
  if e.code() == grpc.StatusCode.ALREADY_EXISTS:
123
194
  logger.info(f"Task {task.name} with image {image_uri} already exists, skipping deployment.")
124
- return spec
195
+ return DeployedTask(spec, deployable_triggers)
125
196
  raise
126
- return spec
197
+
198
+ return DeployedTask(spec, deployable_triggers)
127
199
  except Exception as e:
128
200
  logger.error(f"Failed to deploy task {task.name} with image {image_uri}: {e}")
129
201
  raise flyte.errors.DeploymentError(
@@ -162,16 +234,80 @@ async def _build_images(deployment: DeploymentPlan) -> ImageCache:
162
234
  for env_name, image_uri in final_images:
163
235
  logger.warning(f"Built Image for environment {env_name}, image: {image_uri}")
164
236
  env = deployment.envs[env_name]
165
- if isinstance(env.image, Image):
166
- py_version = "{}.{}".format(*env.image.python_version)
167
- image_identifier_map[env.image.identifier] = {py_version: image_uri}
168
- elif env.image == "auto":
169
- py_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
170
- image_identifier_map["auto"] = {py_version: image_uri}
237
+ image_identifier_map[env_name] = image_uri
171
238
 
172
239
  return ImageCache(image_lookup=image_identifier_map)
173
240
 
174
241
 
242
+ class Deployer(Protocol):
243
+ """
244
+ Protocol for deployment callables.
245
+ """
246
+
247
+ async def __call__(self, context: DeploymentContext) -> DeployedEnv:
248
+ """
249
+ Deploy the environment described in the context.
250
+
251
+ Args:
252
+ context: Deployment context containing environment, serialization context, and dryrun flag
253
+
254
+ Returns:
255
+ Deployment result
256
+ """
257
+ ...
258
+
259
+
260
+ async def _deploy_task_env(context: DeploymentContext) -> DeployedEnv:
261
+ """
262
+ Deploy the given task environment.
263
+ """
264
+ ensure_client()
265
+ env = context.environment
266
+ if not isinstance(env, TaskEnvironment):
267
+ raise ValueError(f"Expected TaskEnvironment, got {type(env)}")
268
+
269
+ task_coros = []
270
+ for task in env.tasks.values():
271
+ task_coros.append(_deploy_task(task, context.serialization_context, dryrun=context.dryrun))
272
+ deployed_task_vals = await asyncio.gather(*task_coros)
273
+ deployed_tasks = []
274
+ for t in deployed_task_vals:
275
+ deployed_tasks.append(t)
276
+ return DeployedEnv(env=env, deployed_entities=deployed_tasks)
277
+
278
+
279
+ _ENVTYPE_REGISTRY: Dict[Type[Environment | TaskEnvironment], Deployer] = {
280
+ TaskEnvironment: _deploy_task_env,
281
+ }
282
+
283
+
284
+ def register_deployer(env_type: Type[Environment | TaskEnvironment], deployer: Deployer) -> None:
285
+ """
286
+ Register a deployer for a specific environment type.
287
+
288
+ Args:
289
+ env_type: Type of environment this deployer handles
290
+ deployer: Deployment callable that conforms to the Deployer protocol
291
+ """
292
+ _ENVTYPE_REGISTRY[env_type] = deployer
293
+
294
+
295
+ def get_deployer(env_type: Type[Environment | TaskEnvironment]) -> Deployer:
296
+ """
297
+ Get the registered deployer for an environment type.
298
+
299
+ Args:
300
+ env_type: Type of environment to get deployer for
301
+
302
+ Returns:
303
+ Deployer for the environment type, defaults to task environment deployer
304
+ """
305
+ v = _ENVTYPE_REGISTRY.get(env_type)
306
+ if v is None:
307
+ raise ValueError(f"No deployer registered for environment type {env_type}")
308
+ return v
309
+
310
+
175
311
  @requires_initialization
176
312
  async def apply(deployment_plan: DeploymentPlan, copy_style: CopyFiles, dryrun: bool = False) -> Deployment:
177
313
  from ._code_bundle import build_code_bundle
@@ -203,15 +339,18 @@ async def apply(deployment_plan: DeploymentPlan, copy_style: CopyFiles, dryrun:
203
339
  root_dir=cfg.root_dir,
204
340
  )
205
341
 
206
- tasks = []
207
-
342
+ deployment_coros = []
208
343
  for env_name, env in deployment_plan.envs.items():
209
344
  logger.info(f"Deploying environment {env_name}")
210
- # TODO Make this pluggable based on the environment type
211
- if isinstance(env, TaskEnvironment):
212
- for task in env.tasks.values():
213
- tasks.append(_deploy_task(task, dryrun=dryrun, serialization_context=sc))
214
- return Deployment(envs=deployment_plan.envs, deployed_tasks=await asyncio.gather(*tasks))
345
+ deployer = get_deployer(type(env))
346
+ context = DeploymentContext(environment=env, serialization_context=sc, dryrun=dryrun)
347
+ deployment_coros.append(deployer(context))
348
+ deployed_envs = await asyncio.gather(*deployment_coros)
349
+ envs = {}
350
+ for d in deployed_envs:
351
+ envs[d.env.name] = d
352
+
353
+ return Deployment(envs)
215
354
 
216
355
 
217
356
  def _recursive_discover(planned_envs: Dict[str, Environment], env: Environment) -> Dict[str, Environment]:
@@ -219,14 +358,16 @@ def _recursive_discover(planned_envs: Dict[str, Environment], env: Environment)
219
358
  Recursively deploy the environment and its dependencies, if not already deployed (present in env_tasks) and
220
359
  return the updated env_tasks.
221
360
  """
222
- # Skip if the environment is already planned
223
361
  if env.name in planned_envs:
224
- return planned_envs
362
+ if planned_envs[env.name] is not env:
363
+ # Raise error if different TaskEnvironment objects have the same name
364
+ raise ValueError(f"Duplicate environment name '{env.name}' found")
365
+ # Add the environment to the existing envs
366
+ planned_envs[env.name] = env
367
+
225
368
  # Recursively discover dependent environments
226
369
  for dependent_env in env.depends_on:
227
370
  _recursive_discover(planned_envs, dependent_env)
228
- # Add the environment to the existing envs
229
- planned_envs[env.name] = env
230
371
  return planned_envs
231
372
 
232
373
 
@@ -234,10 +375,10 @@ def plan_deploy(*envs: Environment, version: Optional[str] = None) -> List[Deplo
234
375
  if envs is None:
235
376
  return [DeploymentPlan({})]
236
377
  deployment_plans = []
237
- visited_envs: typing.Set[str] = set()
378
+ visited_envs: Set[str] = set()
238
379
  for env in envs:
239
380
  if env.name in visited_envs:
240
- continue
381
+ raise ValueError(f"Duplicate environment name '{env.name}' found")
241
382
  planned_envs = _recursive_discover({}, env)
242
383
  deployment_plans.append(DeploymentPlan(planned_envs, version=version))
243
384
  visited_envs.update(planned_envs.keys())
flyte/_environment.py CHANGED
@@ -36,6 +36,9 @@ class Environment:
36
36
  :param resources: Resources to allocate for the environment.
37
37
  :param env_vars: Environment variables to set for the environment.
38
38
  :param secrets: Secrets to inject into the environment.
39
+ :param pod_template: Pod template to use for the environment.
40
+ :param description: Description of the environment.
41
+ :param interruptible: Whether the environment is interruptible and can be scheduled on spot/preemptible instances
39
42
  :param depends_on: Environment dependencies to hint, so when you deploy the environment, the dependencies are
40
43
  also deployed. This is useful when you have a set of environments that depend on each other.
41
44
  """
@@ -47,6 +50,7 @@ class Environment:
47
50
  secrets: Optional[SecretRequest] = None
48
51
  env_vars: Optional[Dict[str, str]] = None
49
52
  resources: Optional[Resources] = None
53
+ interruptible: bool = False
50
54
  image: Union[str, Image, Literal["auto"]] = "auto"
51
55
 
52
56
  def __post_init__(self):
@@ -87,6 +91,7 @@ class Environment:
87
91
  env_vars: Optional[Dict[str, str]] = None,
88
92
  secrets: Optional[SecretRequest] = None,
89
93
  depends_on: Optional[List[Environment]] = None,
94
+ description: Optional[str] = None,
90
95
  **kwargs: Any,
91
96
  ) -> Environment:
92
97
  raise NotImplementedError
flyte/_excepthook.py CHANGED
@@ -33,5 +33,5 @@ def custom_excepthook(exc_type, exc_value, exc_tb):
33
33
  filtered_tb = [frame for frame in tb_list if should_include_frame(frame)]
34
34
  # Print the filtered version (custom format)
35
35
  print("Filtered traceback (most recent call last):")
36
- print("".join(traceback.format_list(filtered_tb)))
36
+ traceback.print_tb(filtered_tb)
37
37
  print(f"{exc_type.__name__}: {exc_value}\n")
flyte/_image.py CHANGED
@@ -1,11 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
4
3
  import hashlib
5
4
  import sys
6
5
  import typing
7
6
  from abc import abstractmethod
8
- from dataclasses import asdict, dataclass, field, fields
7
+ from dataclasses import dataclass, field
9
8
  from functools import cached_property
10
9
  from pathlib import Path
11
10
  from typing import TYPE_CHECKING, ClassVar, Dict, List, Literal, Optional, Tuple, TypeVar, Union
@@ -13,6 +12,8 @@ from typing import TYPE_CHECKING, ClassVar, Dict, List, Literal, Optional, Tuple
13
12
  import rich.repr
14
13
  from packaging.version import Version
15
14
 
15
+ from flyte._utils import update_hasher_for_source
16
+
16
17
  if TYPE_CHECKING:
17
18
  from flyte import Secret, SecretRequest
18
19
 
@@ -56,7 +57,6 @@ class Layer:
56
57
 
57
58
  :param hasher: The hash object to update with the layer's data.
58
59
  """
59
- print("hash hash")
60
60
 
61
61
  def validate(self):
62
62
  """
@@ -64,27 +64,6 @@ class Layer:
64
64
  :return:
65
65
  """
66
66
 
67
- def identifier(self) -> str:
68
- """
69
- This method computes a unique identifier for the layer based on its properties.
70
- It is used to identify the layer in the image cache.
71
-
72
- It is also used to compute a unique identifier for the image itself, which is a combination of all the layers.
73
- This identifier is used to look up previously built images in the image cache. So having a consistent
74
- identifier is important for the image cache to work correctly.
75
-
76
- :return: A unique identifier for the layer.
77
- """
78
- ignore_fields: list[str] = []
79
- for f in fields(self):
80
- if f.metadata.get("identifier", True) is False:
81
- ignore_fields.append(f.name)
82
- d = asdict(self)
83
- for v in ignore_fields:
84
- d.pop(v)
85
-
86
- return str(d)
87
-
88
67
 
89
68
  @rich.repr.auto
90
69
  @dataclass(kw_only=True, frozen=True, repr=True)
@@ -152,7 +131,7 @@ class PipPackages(PipOption, Layer):
152
131
  @rich.repr.auto
153
132
  @dataclass(kw_only=True, frozen=True, repr=True)
154
133
  class PythonWheels(PipOption, Layer):
155
- wheel_dir: Path = field(metadata={"identifier": False})
134
+ wheel_dir: Path
156
135
  wheel_dir_name: str = field(init=False)
157
136
  package_name: str
158
137
 
@@ -202,14 +181,56 @@ class UVProject(PipOption, Layer):
202
181
  from ._utils import filehash_update
203
182
 
204
183
  super().update_hash(hasher)
205
- filehash_update(self.uvlock, hasher)
206
- filehash_update(self.pyproject, hasher)
184
+ if self.extra_args and "--no-install-project" in self.extra_args:
185
+ filehash_update(self.uvlock, hasher)
186
+ filehash_update(self.pyproject, hasher)
187
+ else:
188
+ update_hasher_for_source(self.pyproject.parent, hasher)
189
+
190
+
191
+ @rich.repr.auto
192
+ @dataclass(frozen=True, repr=True)
193
+ class PoetryProject(Layer):
194
+ """
195
+ Poetry does not use pip options, so the PoetryProject class do not inherits PipOption class
196
+ """
197
+
198
+ pyproject: Path
199
+ poetry_lock: Path
200
+ extra_args: Optional[str] = None
201
+ secret_mounts: Optional[Tuple[str | Secret, ...]] = None
202
+
203
+ def validate(self):
204
+ if not self.pyproject.exists():
205
+ raise FileNotFoundError(f"pyproject.toml file {self.pyproject} does not exist")
206
+ if not self.pyproject.is_file():
207
+ raise ValueError(f"Pyproject file {self.pyproject} is not a file")
208
+ if not self.poetry_lock.exists():
209
+ raise ValueError(f"poetry.lock file {self.poetry_lock} does not exist")
210
+ super().validate()
211
+
212
+ def update_hash(self, hasher: hashlib._Hash):
213
+ from ._utils import filehash_update
214
+
215
+ hash_input = ""
216
+ if self.extra_args:
217
+ hash_input += self.extra_args
218
+ if self.secret_mounts:
219
+ for secret_mount in self.secret_mounts:
220
+ hash_input += str(secret_mount)
221
+ hasher.update(hash_input.encode("utf-8"))
222
+
223
+ if self.extra_args and "--no-root" in self.extra_args:
224
+ filehash_update(self.poetry_lock, hasher)
225
+ filehash_update(self.pyproject, hasher)
226
+ else:
227
+ update_hasher_for_source(self.pyproject.parent, hasher)
207
228
 
208
229
 
209
230
  @rich.repr.auto
210
231
  @dataclass(frozen=True, repr=True)
211
232
  class UVScript(PipOption, Layer):
212
- script: Path = field(metadata={"identifier": False})
233
+ script: Path
213
234
  script_name: str = field(init=False)
214
235
 
215
236
  def __post_init__(self):
@@ -285,15 +306,14 @@ class DockerIgnore(Layer):
285
306
  @rich.repr.auto
286
307
  @dataclass(frozen=True, repr=True)
287
308
  class CopyConfig(Layer):
288
- path_type: CopyConfigType = field(metadata={"identifier": True})
289
- src: Path = field(metadata={"identifier": False})
309
+ path_type: CopyConfigType
310
+ src: Path
290
311
  dst: str
291
- src_name: str = field(init=False)
312
+ src_name: str
292
313
 
293
314
  def __post_init__(self):
294
315
  if self.path_type not in (0, 1):
295
316
  raise ValueError(f"Invalid path_type {self.path_type}, must be 0 (file) or 1 (directory)")
296
- object.__setattr__(self, "src_name", self.src.name)
297
317
 
298
318
  def validate(self):
299
319
  if not self.src.exists():
@@ -382,9 +402,6 @@ class Image:
382
402
  platform: Tuple[Architecture, ...] = field(default=("linux/amd64",))
383
403
  python_version: Tuple[int, int] = field(default_factory=_detect_python_version)
384
404
 
385
- # For .auto() images. Don't compute an actual identifier.
386
- _identifier_override: Optional[str] = field(default=None, init=False)
387
-
388
405
  # Layers to be added to the image. In init, because frozen, but users shouldn't access, so underscore.
389
406
  _layers: Tuple[Layer, ...] = field(default_factory=tuple)
390
407
 
@@ -417,34 +434,6 @@ class Image:
417
434
  cls.__init__(obj, **kwargs) # run dataclass generated __init__
418
435
  return obj
419
436
 
420
- @cached_property
421
- def identifier(self) -> str:
422
- """
423
- This identifier is a hash of the layers and properties of the image. It is used to look up previously built
424
- images. Why is this useful? For example, if a user has Image.from_uv_base().with_source_file("a/local/file"),
425
- it's not necessarily the case that that file exists within the image (further commands may have removed/changed
426
- it), and certainly not the case that the path to the file, inside the image (which is used as part of the layer
427
- hash computation), is the same. That is, inside the image when a task runs, as we come across the same Image
428
- declaration, we need a way of identifying the image and its uri, without hashing all the layers again. This
429
- is what this identifier is for. See the ImageCache object for additional information.
430
-
431
- :return: A unique identifier of the Image
432
- """
433
- if self._identifier_override:
434
- return self._identifier_override
435
-
436
- # Only get the non-None values in the Image to ensure the hash is consistent
437
- # across different SDK versions.
438
- # Layers can specify a _compute_identifier optionally, but the default will just stringify
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
- )
443
- layers_str_repr = "".join([layer.identifier() for layer in self._layers])
444
- image_dict["layers"] = layers_str_repr
445
- spec_bytes = image_dict.__str__().encode("utf-8")
446
- return base64.urlsafe_b64encode(hashlib.md5(spec_bytes).digest()).decode("ascii").rstrip("=")
447
-
448
437
  def validate(self):
449
438
  for layer in self._layers:
450
439
  layer.validate()
@@ -507,9 +496,6 @@ class Image:
507
496
  image = image.with_pip_packages(f"flyte=={flyte_version}")
508
497
  if not dev_mode:
509
498
  object.__setattr__(image, "_tag", preset_tag)
510
- # Set this to auto for all auto images because the meaning of "auto" can change (based on logic inside
511
- # _get_default_image_for, acts differently in a running task container) so let's make sure it stays auto.
512
- object.__setattr__(image, "_identifier_override", "auto")
513
499
 
514
500
  return image
515
501
 
@@ -550,9 +536,6 @@ class Image:
550
536
  if registry or name:
551
537
  return base_image.clone(registry=registry, name=name)
552
538
 
553
- # # Set this to auto for all auto images because the meaning of "auto" can change (based on logic inside
554
- # # _get_default_image_for, acts differently in a running task container) so let's make sure it stays auto.
555
- # object.__setattr__(base_image, "_identifier_override", "auto")
556
539
  return base_image
557
540
 
558
541
  @classmethod
@@ -849,16 +832,23 @@ class Image:
849
832
  new_image = self.clone(addl_layer=Env.from_dict(env_vars))
850
833
  return new_image
851
834
 
852
- def with_source_folder(self, src: Path, dst: str = ".") -> Image:
835
+ def with_source_folder(self, src: Path, dst: str = ".", copy_contents_only: bool = False) -> Image:
853
836
  """
854
837
  Use this method to create a new image with the specified local directory layered on top of the current image.
855
838
  If dest is not specified, it will be copied to the working directory of the image
856
839
 
857
840
  :param src: root folder of the source code from the build context to be copied
858
841
  :param dst: destination folder in the image
842
+ :param copy_contents_only: If True, will copy the contents of the source folder to the destination folder,
843
+ instead of the folder itself. Default is False.
859
844
  :return: Image
860
845
  """
861
- new_image = self.clone(addl_layer=CopyConfig(path_type=1, src=src, dst=dst))
846
+ src_name = src.name
847
+ if copy_contents_only:
848
+ src_name = "."
849
+ else:
850
+ dst = str("./" + src_name)
851
+ new_image = self.clone(addl_layer=CopyConfig(path_type=1, src=src, dst=dst, src_name=src_name))
862
852
  return new_image
863
853
 
864
854
  def with_source_file(self, src: Path, dst: str = ".") -> Image:
@@ -870,7 +860,7 @@ class Image:
870
860
  :param dst: destination folder in the image
871
861
  :return: Image
872
862
  """
873
- new_image = self.clone(addl_layer=CopyConfig(path_type=0, src=src, dst=dst))
863
+ new_image = self.clone(addl_layer=CopyConfig(path_type=0, src=src, dst=dst, src_name=src.name))
874
864
  return new_image
875
865
 
876
866
  def with_dockerignore(self, path: Path) -> Image:
@@ -923,6 +913,45 @@ class Image:
923
913
  )
924
914
  return new_image
925
915
 
916
+ def with_poetry_project(
917
+ self,
918
+ pyproject_file: str | Path,
919
+ poetry_lock: Path | None = None,
920
+ extra_args: Optional[str] = None,
921
+ secret_mounts: Optional[SecretRequest] = None,
922
+ ):
923
+ """
924
+ Use this method to create a new image with the specified pyproject.toml layered on top of the current image.
925
+ Must have a corresponding pyproject.toml file in the same directory.
926
+ Cannot be used in conjunction with conda.
927
+
928
+ By default, this method copies the entire project into the image,
929
+ including files such as pyproject.toml, poetry.lock, and the src/ directory.
930
+
931
+ If you prefer not to install the current project, you can pass through `extra_args`
932
+ `--no-root`. In this case, the image builder will only copy pyproject.toml and poetry.lock
933
+ into the image.
934
+
935
+ :param pyproject_file: Path to the pyproject.toml file. A poetry.lock file must exist in the same directory
936
+ unless `poetry_lock` is explicitly provided.
937
+ :param poetry_lock: Path to the poetry.lock file. If not specified, the default is the file named
938
+ 'poetry.lock' in the same directory as `pyproject_file` (pyproject.parent / "poetry.lock").
939
+ :param extra_args: Extra arguments to pass through to the package installer/resolver, default is None.
940
+ :param secret_mounts: Secrets to make available during dependency resolution/build (e.g., private indexes).
941
+ :return: Image
942
+ """
943
+ if isinstance(pyproject_file, str):
944
+ pyproject_file = Path(pyproject_file)
945
+ new_image = self.clone(
946
+ addl_layer=PoetryProject(
947
+ pyproject=pyproject_file,
948
+ poetry_lock=poetry_lock or (pyproject_file.parent / "poetry.lock"),
949
+ extra_args=extra_args,
950
+ secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
951
+ )
952
+ )
953
+ return new_image
954
+
926
955
  def with_apt_packages(self, *packages: str, secret_mounts: Optional[SecretRequest] = None) -> Image:
927
956
  """
928
957
  Use this method to create a new image with the specified apt packages layered on top of the current image