flyte 0.2.0b1__py3-none-any.whl → 2.0.0b46__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 (266) hide show
  1. flyte/__init__.py +83 -30
  2. flyte/_bin/connect.py +61 -0
  3. flyte/_bin/debug.py +38 -0
  4. flyte/_bin/runtime.py +87 -19
  5. flyte/_bin/serve.py +351 -0
  6. flyte/_build.py +3 -2
  7. flyte/_cache/cache.py +6 -5
  8. flyte/_cache/local_cache.py +216 -0
  9. flyte/_code_bundle/_ignore.py +31 -5
  10. flyte/_code_bundle/_packaging.py +42 -11
  11. flyte/_code_bundle/_utils.py +57 -34
  12. flyte/_code_bundle/bundle.py +130 -27
  13. flyte/_constants.py +1 -0
  14. flyte/_context.py +21 -5
  15. flyte/_custom_context.py +73 -0
  16. flyte/_debug/constants.py +37 -0
  17. flyte/_debug/utils.py +17 -0
  18. flyte/_debug/vscode.py +315 -0
  19. flyte/_deploy.py +396 -75
  20. flyte/_deployer.py +109 -0
  21. flyte/_environment.py +94 -11
  22. flyte/_excepthook.py +37 -0
  23. flyte/_group.py +2 -1
  24. flyte/_hash.py +1 -16
  25. flyte/_image.py +544 -231
  26. flyte/_initialize.py +456 -316
  27. flyte/_interface.py +40 -5
  28. flyte/_internal/controllers/__init__.py +22 -8
  29. flyte/_internal/controllers/_local_controller.py +159 -35
  30. flyte/_internal/controllers/_trace.py +18 -10
  31. flyte/_internal/controllers/remote/__init__.py +38 -9
  32. flyte/_internal/controllers/remote/_action.py +82 -12
  33. flyte/_internal/controllers/remote/_client.py +6 -2
  34. flyte/_internal/controllers/remote/_controller.py +290 -64
  35. flyte/_internal/controllers/remote/_core.py +155 -95
  36. flyte/_internal/controllers/remote/_informer.py +40 -20
  37. flyte/_internal/controllers/remote/_service_protocol.py +2 -2
  38. flyte/_internal/imagebuild/__init__.py +2 -10
  39. flyte/_internal/imagebuild/docker_builder.py +391 -84
  40. flyte/_internal/imagebuild/image_builder.py +111 -55
  41. flyte/_internal/imagebuild/remote_builder.py +409 -0
  42. flyte/_internal/imagebuild/utils.py +79 -0
  43. flyte/_internal/resolvers/_app_env_module.py +92 -0
  44. flyte/_internal/resolvers/_task_module.py +5 -38
  45. flyte/_internal/resolvers/app_env.py +26 -0
  46. flyte/_internal/resolvers/common.py +8 -1
  47. flyte/_internal/resolvers/default.py +2 -2
  48. flyte/_internal/runtime/convert.py +319 -36
  49. flyte/_internal/runtime/entrypoints.py +106 -18
  50. flyte/_internal/runtime/io.py +71 -23
  51. flyte/_internal/runtime/resources_serde.py +21 -7
  52. flyte/_internal/runtime/reuse.py +125 -0
  53. flyte/_internal/runtime/rusty.py +196 -0
  54. flyte/_internal/runtime/task_serde.py +239 -66
  55. flyte/_internal/runtime/taskrunner.py +48 -8
  56. flyte/_internal/runtime/trigger_serde.py +162 -0
  57. flyte/_internal/runtime/types_serde.py +7 -16
  58. flyte/_keyring/file.py +115 -0
  59. flyte/_link.py +30 -0
  60. flyte/_logging.py +241 -42
  61. flyte/_map.py +312 -0
  62. flyte/_metrics.py +59 -0
  63. flyte/_module.py +74 -0
  64. flyte/_pod.py +30 -0
  65. flyte/_resources.py +296 -33
  66. flyte/_retry.py +1 -7
  67. flyte/_reusable_environment.py +72 -7
  68. flyte/_run.py +462 -132
  69. flyte/_secret.py +47 -11
  70. flyte/_serve.py +333 -0
  71. flyte/_task.py +245 -56
  72. flyte/_task_environment.py +219 -97
  73. flyte/_task_plugins.py +47 -0
  74. flyte/_tools.py +8 -8
  75. flyte/_trace.py +15 -24
  76. flyte/_trigger.py +1027 -0
  77. flyte/_utils/__init__.py +12 -1
  78. flyte/_utils/asyn.py +3 -1
  79. flyte/_utils/async_cache.py +139 -0
  80. flyte/_utils/coro_management.py +5 -4
  81. flyte/_utils/description_parser.py +19 -0
  82. flyte/_utils/docker_credentials.py +173 -0
  83. flyte/_utils/helpers.py +45 -19
  84. flyte/_utils/module_loader.py +123 -0
  85. flyte/_utils/org_discovery.py +57 -0
  86. flyte/_utils/uv_script_parser.py +8 -1
  87. flyte/_version.py +16 -3
  88. flyte/app/__init__.py +27 -0
  89. flyte/app/_app_environment.py +362 -0
  90. flyte/app/_connector_environment.py +40 -0
  91. flyte/app/_deploy.py +130 -0
  92. flyte/app/_parameter.py +343 -0
  93. flyte/app/_runtime/__init__.py +3 -0
  94. flyte/app/_runtime/app_serde.py +383 -0
  95. flyte/app/_types.py +113 -0
  96. flyte/app/extras/__init__.py +9 -0
  97. flyte/app/extras/_auth_middleware.py +217 -0
  98. flyte/app/extras/_fastapi.py +93 -0
  99. flyte/app/extras/_model_loader/__init__.py +3 -0
  100. flyte/app/extras/_model_loader/config.py +7 -0
  101. flyte/app/extras/_model_loader/loader.py +288 -0
  102. flyte/cli/__init__.py +12 -0
  103. flyte/cli/_abort.py +28 -0
  104. flyte/cli/_build.py +114 -0
  105. flyte/cli/_common.py +493 -0
  106. flyte/cli/_create.py +371 -0
  107. flyte/cli/_delete.py +45 -0
  108. flyte/cli/_deploy.py +401 -0
  109. flyte/cli/_gen.py +316 -0
  110. flyte/cli/_get.py +446 -0
  111. flyte/cli/_option.py +33 -0
  112. flyte/{_cli → cli}/_params.py +57 -17
  113. flyte/cli/_plugins.py +209 -0
  114. flyte/cli/_prefetch.py +292 -0
  115. flyte/cli/_run.py +690 -0
  116. flyte/cli/_serve.py +338 -0
  117. flyte/cli/_update.py +86 -0
  118. flyte/cli/_user.py +20 -0
  119. flyte/cli/main.py +246 -0
  120. flyte/config/__init__.py +2 -167
  121. flyte/config/_config.py +215 -163
  122. flyte/config/_internal.py +10 -1
  123. flyte/config/_reader.py +225 -0
  124. flyte/connectors/__init__.py +11 -0
  125. flyte/connectors/_connector.py +330 -0
  126. flyte/connectors/_server.py +194 -0
  127. flyte/connectors/utils.py +159 -0
  128. flyte/errors.py +134 -2
  129. flyte/extend.py +24 -0
  130. flyte/extras/_container.py +69 -56
  131. flyte/git/__init__.py +3 -0
  132. flyte/git/_config.py +279 -0
  133. flyte/io/__init__.py +8 -1
  134. flyte/io/{structured_dataset → _dataframe}/__init__.py +32 -30
  135. flyte/io/{structured_dataset → _dataframe}/basic_dfs.py +75 -68
  136. flyte/io/{structured_dataset/structured_dataset.py → _dataframe/dataframe.py} +207 -242
  137. flyte/io/_dir.py +575 -113
  138. flyte/io/_file.py +587 -141
  139. flyte/io/_hashing_io.py +342 -0
  140. flyte/io/extend.py +7 -0
  141. flyte/models.py +635 -0
  142. flyte/prefetch/__init__.py +22 -0
  143. flyte/prefetch/_hf_model.py +563 -0
  144. flyte/remote/__init__.py +14 -3
  145. flyte/remote/_action.py +879 -0
  146. flyte/remote/_app.py +346 -0
  147. flyte/remote/_auth_metadata.py +42 -0
  148. flyte/remote/_client/_protocols.py +62 -4
  149. flyte/remote/_client/auth/_auth_utils.py +19 -0
  150. flyte/remote/_client/auth/_authenticators/base.py +8 -2
  151. flyte/remote/_client/auth/_authenticators/device_code.py +4 -5
  152. flyte/remote/_client/auth/_authenticators/factory.py +4 -0
  153. flyte/remote/_client/auth/_authenticators/passthrough.py +79 -0
  154. flyte/remote/_client/auth/_authenticators/pkce.py +17 -18
  155. flyte/remote/_client/auth/_channel.py +47 -18
  156. flyte/remote/_client/auth/_client_config.py +5 -3
  157. flyte/remote/_client/auth/_keyring.py +15 -2
  158. flyte/remote/_client/auth/_token_client.py +3 -3
  159. flyte/remote/_client/controlplane.py +206 -18
  160. flyte/remote/_common.py +66 -0
  161. flyte/remote/_data.py +107 -22
  162. flyte/remote/_logs.py +116 -33
  163. flyte/remote/_project.py +21 -19
  164. flyte/remote/_run.py +164 -631
  165. flyte/remote/_secret.py +72 -29
  166. flyte/remote/_task.py +387 -46
  167. flyte/remote/_trigger.py +368 -0
  168. flyte/remote/_user.py +43 -0
  169. flyte/report/_report.py +10 -6
  170. flyte/storage/__init__.py +13 -1
  171. flyte/storage/_config.py +237 -0
  172. flyte/storage/_parallel_reader.py +289 -0
  173. flyte/storage/_storage.py +268 -59
  174. flyte/syncify/__init__.py +56 -0
  175. flyte/syncify/_api.py +414 -0
  176. flyte/types/__init__.py +39 -0
  177. flyte/types/_interface.py +22 -7
  178. flyte/{io/pickle/transformer.py → types/_pickle.py} +37 -9
  179. flyte/types/_string_literals.py +8 -9
  180. flyte/types/_type_engine.py +226 -126
  181. flyte/types/_utils.py +1 -1
  182. flyte-2.0.0b46.data/scripts/debug.py +38 -0
  183. flyte-2.0.0b46.data/scripts/runtime.py +194 -0
  184. flyte-2.0.0b46.dist-info/METADATA +352 -0
  185. flyte-2.0.0b46.dist-info/RECORD +221 -0
  186. flyte-2.0.0b46.dist-info/entry_points.txt +8 -0
  187. flyte-2.0.0b46.dist-info/licenses/LICENSE +201 -0
  188. flyte/_api_commons.py +0 -3
  189. flyte/_cli/_common.py +0 -299
  190. flyte/_cli/_create.py +0 -42
  191. flyte/_cli/_delete.py +0 -23
  192. flyte/_cli/_deploy.py +0 -140
  193. flyte/_cli/_get.py +0 -235
  194. flyte/_cli/_run.py +0 -174
  195. flyte/_cli/main.py +0 -98
  196. flyte/_datastructures.py +0 -342
  197. flyte/_internal/controllers/pbhash.py +0 -39
  198. flyte/_protos/common/authorization_pb2.py +0 -66
  199. flyte/_protos/common/authorization_pb2.pyi +0 -108
  200. flyte/_protos/common/authorization_pb2_grpc.py +0 -4
  201. flyte/_protos/common/identifier_pb2.py +0 -71
  202. flyte/_protos/common/identifier_pb2.pyi +0 -82
  203. flyte/_protos/common/identifier_pb2_grpc.py +0 -4
  204. flyte/_protos/common/identity_pb2.py +0 -48
  205. flyte/_protos/common/identity_pb2.pyi +0 -72
  206. flyte/_protos/common/identity_pb2_grpc.py +0 -4
  207. flyte/_protos/common/list_pb2.py +0 -36
  208. flyte/_protos/common/list_pb2.pyi +0 -69
  209. flyte/_protos/common/list_pb2_grpc.py +0 -4
  210. flyte/_protos/common/policy_pb2.py +0 -37
  211. flyte/_protos/common/policy_pb2.pyi +0 -27
  212. flyte/_protos/common/policy_pb2_grpc.py +0 -4
  213. flyte/_protos/common/role_pb2.py +0 -37
  214. flyte/_protos/common/role_pb2.pyi +0 -53
  215. flyte/_protos/common/role_pb2_grpc.py +0 -4
  216. flyte/_protos/common/runtime_version_pb2.py +0 -28
  217. flyte/_protos/common/runtime_version_pb2.pyi +0 -24
  218. flyte/_protos/common/runtime_version_pb2_grpc.py +0 -4
  219. flyte/_protos/logs/dataplane/payload_pb2.py +0 -96
  220. flyte/_protos/logs/dataplane/payload_pb2.pyi +0 -168
  221. flyte/_protos/logs/dataplane/payload_pb2_grpc.py +0 -4
  222. flyte/_protos/secret/definition_pb2.py +0 -49
  223. flyte/_protos/secret/definition_pb2.pyi +0 -93
  224. flyte/_protos/secret/definition_pb2_grpc.py +0 -4
  225. flyte/_protos/secret/payload_pb2.py +0 -62
  226. flyte/_protos/secret/payload_pb2.pyi +0 -94
  227. flyte/_protos/secret/payload_pb2_grpc.py +0 -4
  228. flyte/_protos/secret/secret_pb2.py +0 -38
  229. flyte/_protos/secret/secret_pb2.pyi +0 -6
  230. flyte/_protos/secret/secret_pb2_grpc.py +0 -198
  231. flyte/_protos/secret/secret_pb2_grpc_grpc.py +0 -198
  232. flyte/_protos/validate/validate/validate_pb2.py +0 -76
  233. flyte/_protos/workflow/node_execution_service_pb2.py +0 -26
  234. flyte/_protos/workflow/node_execution_service_pb2.pyi +0 -4
  235. flyte/_protos/workflow/node_execution_service_pb2_grpc.py +0 -32
  236. flyte/_protos/workflow/queue_service_pb2.py +0 -106
  237. flyte/_protos/workflow/queue_service_pb2.pyi +0 -141
  238. flyte/_protos/workflow/queue_service_pb2_grpc.py +0 -172
  239. flyte/_protos/workflow/run_definition_pb2.py +0 -128
  240. flyte/_protos/workflow/run_definition_pb2.pyi +0 -310
  241. flyte/_protos/workflow/run_definition_pb2_grpc.py +0 -4
  242. flyte/_protos/workflow/run_logs_service_pb2.py +0 -41
  243. flyte/_protos/workflow/run_logs_service_pb2.pyi +0 -28
  244. flyte/_protos/workflow/run_logs_service_pb2_grpc.py +0 -69
  245. flyte/_protos/workflow/run_service_pb2.py +0 -133
  246. flyte/_protos/workflow/run_service_pb2.pyi +0 -175
  247. flyte/_protos/workflow/run_service_pb2_grpc.py +0 -412
  248. flyte/_protos/workflow/state_service_pb2.py +0 -58
  249. flyte/_protos/workflow/state_service_pb2.pyi +0 -71
  250. flyte/_protos/workflow/state_service_pb2_grpc.py +0 -138
  251. flyte/_protos/workflow/task_definition_pb2.py +0 -72
  252. flyte/_protos/workflow/task_definition_pb2.pyi +0 -65
  253. flyte/_protos/workflow/task_definition_pb2_grpc.py +0 -4
  254. flyte/_protos/workflow/task_service_pb2.py +0 -44
  255. flyte/_protos/workflow/task_service_pb2.pyi +0 -31
  256. flyte/_protos/workflow/task_service_pb2_grpc.py +0 -104
  257. flyte/io/_dataframe.py +0 -0
  258. flyte/io/pickle/__init__.py +0 -0
  259. flyte/remote/_console.py +0 -18
  260. flyte-0.2.0b1.dist-info/METADATA +0 -179
  261. flyte-0.2.0b1.dist-info/RECORD +0 -204
  262. flyte-0.2.0b1.dist-info/entry_points.txt +0 -3
  263. /flyte/{_cli → _debug}/__init__.py +0 -0
  264. /flyte/{_protos → _keyring}/__init__.py +0 -0
  265. {flyte-0.2.0b1.dist-info → flyte-2.0.0b46.dist-info}/WHEEL +0 -0
  266. {flyte-0.2.0b1.dist-info → flyte-2.0.0b46.dist-info}/top_level.txt +0 -0
flyte/_image.py CHANGED
@@ -1,24 +1,32 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
4
3
  import hashlib
4
+ import os.path
5
5
  import sys
6
+ import typing
6
7
  from abc import abstractmethod
7
- from dataclasses import asdict, dataclass, field
8
+ from dataclasses import dataclass, field
8
9
  from functools import cached_property
9
10
  from pathlib import Path
10
- from typing import Callable, ClassVar, Dict, List, Literal, Optional, Tuple, TypeVar, Union
11
+ from typing import TYPE_CHECKING, ClassVar, Dict, List, Literal, Optional, Tuple, TypeVar, Union
11
12
 
12
13
  import rich.repr
14
+ from packaging.version import Version
15
+
16
+ if TYPE_CHECKING:
17
+ from flyte import Secret, SecretRequest
13
18
 
14
19
  # Supported Python versions
15
20
  PYTHON_3_10 = (3, 10)
16
21
  PYTHON_3_11 = (3, 11)
17
22
  PYTHON_3_12 = (3, 12)
18
23
  PYTHON_3_13 = (3, 13)
24
+ PYTHON_3_14 = (3, 14)
19
25
 
20
26
  # 0 is a file, 1 is a directory
21
27
  CopyConfigType = Literal[0, 1]
28
+ SOURCE_ROOT = Path(__file__).parent.parent.parent
29
+ DIST_FOLDER = SOURCE_ROOT / "dist"
22
30
 
23
31
  T = TypeVar("T")
24
32
 
@@ -44,8 +52,6 @@ class Layer:
44
52
  layered images programmatically.
45
53
  """
46
54
 
47
- _compute_identifier: Callable[[Layer], str] = field(default=lambda x: x.__str__(), init=True)
48
-
49
55
  @abstractmethod
50
56
  def update_hash(self, hasher: hashlib._Hash):
51
57
  """
@@ -53,7 +59,6 @@ class Layer:
53
59
 
54
60
  :param hasher: The hash object to update with the layer's data.
55
61
  """
56
- ...
57
62
 
58
63
  def validate(self):
59
64
  """
@@ -64,24 +69,33 @@ class Layer:
64
69
 
65
70
  @rich.repr.auto
66
71
  @dataclass(kw_only=True, frozen=True, repr=True)
67
- class PipPackages(Layer):
68
- packages: Optional[Tuple[str, ...]] = None
72
+ class PipOption:
69
73
  index_url: Optional[str] = None
70
74
  extra_index_urls: Optional[Tuple[str] | Tuple[str, ...] | List[str]] = None
71
75
  pre: bool = False
72
76
  extra_args: Optional[str] = None
77
+ secret_mounts: Optional[Tuple[str | Secret, ...]] = None
78
+
79
+ def get_pip_install_args(self) -> List[str]:
80
+ pip_install_args = []
81
+ if self.index_url:
82
+ pip_install_args.append(f"--index-url {self.index_url}")
83
+
84
+ if self.extra_index_urls:
85
+ pip_install_args.extend([f"--extra-index-url {url}" for url in self.extra_index_urls])
73
86
 
74
- # todo: to be implemented
75
- # secret_mounts: Optional[List[Tuple[str, str]]] = None
87
+ if self.pre:
88
+ pip_install_args.append("--pre")
89
+
90
+ if self.extra_args:
91
+ pip_install_args.append(self.extra_args)
92
+ return pip_install_args
76
93
 
77
94
  def update_hash(self, hasher: hashlib._Hash):
78
95
  """
79
- Update the hash with the pip packages
96
+ Update the hash with the PipOption
80
97
  """
81
98
  hash_input = ""
82
- if self.packages:
83
- for package in self.packages:
84
- hash_input += package
85
99
  if self.index_url:
86
100
  hash_input += self.index_url
87
101
  if self.extra_index_urls:
@@ -91,10 +105,53 @@ class PipPackages(Layer):
91
105
  hash_input += str(self.pre)
92
106
  if self.extra_args:
93
107
  hash_input += self.extra_args
108
+ if self.secret_mounts:
109
+ for secret_mount in self.secret_mounts:
110
+ hash_input += str(secret_mount)
111
+
112
+ hasher.update(hash_input.encode("utf-8"))
113
+
114
+
115
+ @rich.repr.auto
116
+ @dataclass(kw_only=True, frozen=True, repr=True)
117
+ class PipPackages(PipOption, Layer):
118
+ packages: Optional[Tuple[str, ...]] = None
119
+
120
+ def update_hash(self, hasher: hashlib._Hash):
121
+ """
122
+ Update the hash with the pip packages
123
+ """
124
+ super().update_hash(hasher)
125
+ hash_input = ""
126
+ if self.packages:
127
+ for package in self.packages:
128
+ hash_input += package
94
129
 
95
130
  hasher.update(hash_input.encode("utf-8"))
96
131
 
97
132
 
133
+ @rich.repr.auto
134
+ @dataclass(kw_only=True, frozen=True, repr=True)
135
+ class PythonWheels(PipOption, Layer):
136
+ wheel_dir: Path
137
+ wheel_dir_name: str = field(init=False)
138
+ package_name: str
139
+
140
+ def __post_init__(self):
141
+ object.__setattr__(self, "wheel_dir_name", self.wheel_dir.name)
142
+
143
+ def update_hash(self, hasher: hashlib._Hash):
144
+ super().update_hash(hasher)
145
+ from ._utils import filehash_update
146
+
147
+ # Iterate through all the wheel files in the directory and update the hash
148
+ for wheel_file in self.wheel_dir.glob("*.whl"):
149
+ if not wheel_file.is_file():
150
+ # Skip if it's not a file (e.g., directory or symlink)
151
+ continue
152
+ filehash_update(wheel_file, hasher)
153
+
154
+
98
155
  @rich.repr.auto
99
156
  @dataclass(kw_only=True, frozen=True, repr=True)
100
157
  class Requirements(PipPackages):
@@ -107,22 +164,134 @@ class Requirements(PipPackages):
107
164
  filehash_update(self.file, hasher)
108
165
 
109
166
 
167
+ @rich.repr.auto
168
+ @dataclass(frozen=True, repr=True)
169
+ class UVProject(PipOption, Layer):
170
+ pyproject: Path
171
+ uvlock: Path
172
+ project_install_mode: typing.Literal["dependencies_only", "install_project"] = "dependencies_only"
173
+
174
+ def validate(self):
175
+ if not self.pyproject.exists():
176
+ raise FileNotFoundError(f"pyproject.toml file {self.pyproject.resolve()} does not exist")
177
+ if not self.pyproject.is_file():
178
+ raise ValueError(f"Pyproject file {self.pyproject.resolve()} is not a file")
179
+ if not self.uvlock.exists():
180
+ raise ValueError(f"UVLock file {self.uvlock.resolve()} does not exist")
181
+ super().validate()
182
+
183
+ def update_hash(self, hasher: hashlib._Hash):
184
+ from ._utils import filehash_update, update_hasher_for_source
185
+
186
+ super().update_hash(hasher)
187
+ if self.project_install_mode == "dependencies_only":
188
+ filehash_update(self.uvlock, hasher)
189
+ filehash_update(self.pyproject, hasher)
190
+ else:
191
+ update_hasher_for_source(self.pyproject.parent, hasher)
192
+
193
+
194
+ @rich.repr.auto
195
+ @dataclass(frozen=True, repr=True)
196
+ class PoetryProject(Layer):
197
+ """
198
+ Poetry does not use pip options, so the PoetryProject class do not inherits PipOption class
199
+ """
200
+
201
+ pyproject: Path
202
+ poetry_lock: Path
203
+ extra_args: Optional[str] = None
204
+ project_install_mode: typing.Literal["dependencies_only", "install_project"] = "dependencies_only"
205
+ secret_mounts: Optional[Tuple[str | Secret, ...]] = None
206
+
207
+ def validate(self):
208
+ if not self.pyproject.exists():
209
+ raise FileNotFoundError(f"pyproject.toml file {self.pyproject} does not exist")
210
+ if not self.pyproject.is_file():
211
+ raise ValueError(f"Pyproject file {self.pyproject} is not a file")
212
+ if not self.poetry_lock.exists():
213
+ raise ValueError(f"poetry.lock file {self.poetry_lock} does not exist")
214
+ super().validate()
215
+
216
+ def update_hash(self, hasher: hashlib._Hash):
217
+ from ._utils import filehash_update, update_hasher_for_source
218
+
219
+ hash_input = ""
220
+ if self.extra_args:
221
+ hash_input += self.extra_args
222
+ if self.secret_mounts:
223
+ for secret_mount in self.secret_mounts:
224
+ hash_input += str(secret_mount)
225
+ hasher.update(hash_input.encode("utf-8"))
226
+
227
+ if self.project_install_mode == "dependencies_only":
228
+ filehash_update(self.poetry_lock, hasher)
229
+ filehash_update(self.pyproject, hasher)
230
+ else:
231
+ update_hasher_for_source(self.pyproject.parent, hasher)
232
+
233
+
234
+ @rich.repr.auto
235
+ @dataclass(frozen=True, repr=True)
236
+ class UVScript(PipOption, Layer):
237
+ script: Path
238
+ script_name: str = field(init=False)
239
+
240
+ def __post_init__(self):
241
+ object.__setattr__(self, "script_name", self.script.name)
242
+
243
+ def validate(self):
244
+ if not self.script.exists():
245
+ raise FileNotFoundError(f"UV script {self.script} does not exist")
246
+ if not self.script.is_file():
247
+ raise ValueError(f"UV script {self.script} is not a file")
248
+ if not self.script.suffix == ".py":
249
+ raise ValueError(f"UV script {self.script} must have a .py extension")
250
+ super().validate()
251
+
252
+ def update_hash(self, hasher: hashlib._Hash):
253
+ from ._utils import parse_uv_script_file
254
+
255
+ header = parse_uv_script_file(self.script)
256
+ h_tuple = _ensure_tuple(header)
257
+ if h_tuple:
258
+ hasher.update(h_tuple.__str__().encode("utf-8"))
259
+ super().update_hash(hasher)
260
+ if header.pyprojects:
261
+ for pyproject in header.pyprojects:
262
+ UVProject(
263
+ Path(pyproject) / "pyproject.toml", Path(pyproject) / "uv.lock", "install_project"
264
+ ).update_hash(hasher)
265
+
266
+
110
267
  @rich.repr.auto
111
268
  @dataclass(frozen=True, repr=True)
112
269
  class AptPackages(Layer):
113
270
  packages: Tuple[str, ...]
271
+ secret_mounts: Optional[Tuple[str | Secret, ...]] = None
114
272
 
115
273
  def update_hash(self, hasher: hashlib._Hash):
116
- hasher.update("".join(self.packages).encode("utf-8"))
274
+ hash_input = "".join(self.packages)
275
+
276
+ if self.secret_mounts:
277
+ for secret_mount in self.secret_mounts:
278
+ hash_input += str(secret_mount)
279
+ hasher.update(hash_input.encode("utf-8"))
117
280
 
118
281
 
119
282
  @rich.repr.auto
120
283
  @dataclass(frozen=True, repr=True)
121
284
  class Commands(Layer):
122
285
  commands: Tuple[str, ...]
286
+ secret_mounts: Optional[Tuple[str | Secret, ...]] = None
123
287
 
124
288
  def update_hash(self, hasher: hashlib._Hash):
125
- hasher.update("".join(self.commands).encode("utf-8"))
289
+ hash_input = "".join(self.commands)
290
+
291
+ if self.secret_mounts:
292
+ for secret_mount in self.secret_mounts:
293
+ hash_input += str(secret_mount)
294
+ hasher.update(hash_input.encode("utf-8"))
126
295
 
127
296
 
128
297
  @rich.repr.auto
@@ -136,37 +305,38 @@ class WorkDir(Layer):
136
305
 
137
306
  @rich.repr.auto
138
307
  @dataclass(frozen=True, repr=True)
139
- class CopyConfig(Layer):
140
- path_type: CopyConfigType
141
- context_source: Path
142
- image_dest: str = "."
143
-
144
- def validate(self):
145
- if not self.context_source.exists():
146
- raise ValueError(f"Source folder {self.context_source.absolute()} does not exist")
147
- if not self.context_source.is_dir() and self.path_type == 1:
148
- raise ValueError(f"Source folder {self.context_source.absolute()} is not a directory")
149
- if not self.context_source.is_file() and self.path_type == 0:
150
- raise ValueError(f"Source file {self.context_source.absolute()} is not a file")
308
+ class DockerIgnore(Layer):
309
+ path: str
151
310
 
152
311
  def update_hash(self, hasher: hashlib._Hash):
153
- from ._utils import update_hasher_for_source
154
-
155
- update_hasher_for_source(self.context_source, hasher)
156
- if self.image_dest:
157
- hasher.update(self.image_dest.encode("utf-8"))
312
+ hasher.update(self.path.encode("utf-8"))
158
313
 
159
314
 
160
315
  @rich.repr.auto
161
316
  @dataclass(frozen=True, repr=True)
162
- class UVProject(Layer):
163
- pyproject: Path
164
- uvlock: Path
317
+ class CopyConfig(Layer):
318
+ path_type: CopyConfigType
319
+ src: Path
320
+ dst: str
321
+
322
+ def __post_init__(self):
323
+ if self.path_type not in (0, 1):
324
+ raise ValueError(f"Invalid path_type {self.path_type}, must be 0 (file) or 1 (directory)")
325
+
326
+ def validate(self):
327
+ if not self.src.exists():
328
+ raise ValueError(f"Source folder {self.src.absolute()} does not exist")
329
+ if not self.src.is_dir() and self.path_type == 1:
330
+ raise ValueError(f"Source folder {self.src.absolute()} is not a directory")
331
+ if not self.src.is_file() and self.path_type == 0:
332
+ raise ValueError(f"Source file {self.src.absolute()} is not a file")
165
333
 
166
334
  def update_hash(self, hasher: hashlib._Hash):
167
- from ._utils import filehash_update
335
+ from ._utils import update_hasher_for_source
168
336
 
169
- filehash_update(self.uvlock, hasher)
337
+ update_hasher_for_source(self.src, hasher)
338
+ if self.dst:
339
+ hasher.update(self.dst.encode("utf-8"))
170
340
 
171
341
 
172
342
  @rich.repr.auto
@@ -204,8 +374,9 @@ class Env(Layer):
204
374
 
205
375
  Architecture = Literal["linux/amd64", "linux/arm64"]
206
376
 
207
- _BASE_REGISTRY = "ghcr.io/unionai-oss"
377
+ _BASE_REGISTRY = "ghcr.io/flyteorg"
208
378
  _DEFAULT_IMAGE_NAME = "flyte"
379
+ _DEFAULT_IMAGE_REF_NAME = "default"
209
380
 
210
381
 
211
382
  def _detect_python_version() -> Tuple[int, int]:
@@ -238,77 +409,82 @@ class Image:
238
409
  registry: Optional[str] = field(default=None)
239
410
  name: Optional[str] = field(default=None)
240
411
  platform: Tuple[Architecture, ...] = field(default=("linux/amd64",))
241
- tag: Optional[str] = field(default=None)
242
412
  python_version: Tuple[int, int] = field(default_factory=_detect_python_version)
243
-
244
- # For .auto() images. Don't compute an actual identifier.
245
- _identifier_override: Optional[str] = field(default=None, init=False)
246
- # This is set on default images. These images are built from the base Dockerfile in this library and shouldn't be
247
- # modified with additional layers.
248
- is_final: bool = field(default=False)
413
+ # Refer to the image_refs (name:image-uri) set in CLI or config
414
+ _ref_name: Optional[str] = field(default=None)
249
415
 
250
416
  # Layers to be added to the image. In init, because frozen, but users shouldn't access, so underscore.
251
417
  _layers: Tuple[Layer, ...] = field(default_factory=tuple)
252
418
 
419
+ # Only settable internally.
420
+ _tag: Optional[str] = field(default=None, init=False)
421
+
253
422
  _DEFAULT_IMAGE_PREFIXES: ClassVar = {
254
423
  PYTHON_3_10: "py3.10-",
255
424
  PYTHON_3_11: "py3.11-",
256
425
  PYTHON_3_12: "py3.12-",
257
426
  PYTHON_3_13: "py3.13-",
427
+ PYTHON_3_14: "py3.14-",
258
428
  }
259
429
 
260
- @cached_property
261
- def identifier(self) -> str:
262
- """
263
- This identifier is a hash of the layers and properties of the image. It is used to look up previously built
264
- images. Why is this useful? For example, if a user has Image.from_uv_base().with_source_file("a/local/file"),
265
- it's not necessarily the case that that file exists within the image (further commands may have removed/changed
266
- it), and certainly not the case that the path to the file, inside the image (which is used as part of the layer
267
- hash computation), is the same. That is, inside the image when a task runs, as we come across the same Image
268
- declaration, we need a way of identifying the image and its uri, without hashing all the layers again. This
269
- is what this identifier is for. See the ImageCache object for additional information.
270
-
271
- :return: A unique identifier of the Image
272
- """
273
- if self._identifier_override:
274
- return self._identifier_override
275
-
276
- # Only get the non-None values in the ImageSpec to ensure the hash is consistent
277
- # across different SDK versions.
278
- # Can potentially add a second hashing function to the Layer protocol, but relying on just asdict/str
279
- # representation for now.
280
- image_dict = asdict(self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None and k != "_layers"})
281
- layers_str_repr = "".join([layer._compute_identifier(layer) for layer in self._layers])
282
- image_dict["layers"] = layers_str_repr
283
- spec_bytes = image_dict.__str__().encode("utf-8")
284
- return base64.urlsafe_b64encode(hashlib.md5(spec_bytes).digest()).decode("ascii").rstrip("=")
430
+ # class-level token not included in __init__
431
+ _token: ClassVar[object] = object()
432
+
433
+ # Underscore cuz we may rename in the future, don't expose for now,
434
+ _image_registry_secret: Optional[Secret] = None
435
+
436
+ # check for the guard that we put in place
437
+ def __post_init__(self):
438
+ if object.__getattribute__(self, "__dict__").pop("_guard", None) is not Image._token:
439
+ raise TypeError(
440
+ "Direct instantiation of Image not allowed, please use one of the various from_...() methods instead"
441
+ )
442
+
443
+ # Private constructor for internal use only
444
+ @classmethod
445
+ def _new(cls, **kwargs) -> Image:
446
+ # call the normal __init__, injecting a private keyword that users won't know
447
+ obj = cls.__new__(cls) # allocate
448
+ # set guard to prevent direct construction
449
+ object.__setattr__(obj, "_guard", cls._token)
450
+ cls.__init__(obj, **kwargs) # run dataclass generated __init__
451
+ return obj
285
452
 
286
453
  def validate(self):
287
454
  for layer in self._layers:
288
455
  layer.validate()
289
456
 
290
457
  @classmethod
291
- def _get_default_image_for(cls, python_version: Tuple[int, int], flyte_version: Optional[str] = None) -> Image:
458
+ def _get_default_image_for(
459
+ cls,
460
+ python_version: Tuple[int, int],
461
+ flyte_version: Optional[str] = None,
462
+ install_flyte: bool = True,
463
+ platform: Optional[Tuple[Architecture, ...]] = None,
464
+ ) -> Image:
292
465
  # Would love a way to move this outside of this class (but still needs to be accessible via Image.auto())
293
466
  # this default image definition may need to be updated once there is a released pypi version
294
467
  from flyte._version import __version__
295
468
 
296
- dev_mode = cls._is_editable_install() or (__version__ and "dev" in __version__)
297
- if flyte_version is None:
298
- flyte_version = __version__.replace("+", "-")
299
- preset_tag = flyte_version if flyte_version.startswith("v") else f"v{flyte_version}"
300
- preset_tag = f"py{python_version[0]}.{python_version[1]}-{preset_tag}"
301
- image = Image(
469
+ dev_mode = (__version__ and "dev" in __version__) and not flyte_version and install_flyte
470
+ if install_flyte is False:
471
+ preset_tag = f"py{python_version[0]}.{python_version[1]}"
472
+ else:
473
+ if flyte_version is None:
474
+ flyte_version = __version__.replace("+", "-")
475
+ suffix = flyte_version if flyte_version.startswith("v") else f"v{flyte_version}"
476
+ preset_tag = f"py{python_version[0]}.{python_version[1]}-{suffix}"
477
+ image = Image._new(
302
478
  base_image=f"python:{python_version[0]}.{python_version[1]}-slim-bookworm",
303
479
  registry=_BASE_REGISTRY,
304
480
  name=_DEFAULT_IMAGE_NAME,
305
- tag=preset_tag,
306
- platform=("linux/amd64", "linux/arm64"),
481
+ python_version=python_version,
482
+ platform=("linux/amd64", "linux/arm64") if platform is None else platform,
307
483
  )
308
484
  labels_and_user = _DockerLines(
309
485
  (
310
- "LABEL org.opencontainers.image.authors='Union.AI <sales@union.ai>'",
311
- "LABEL org.opencontainers.image.source=https://github.com/unionai/unionv2",
486
+ "LABEL org.opencontainers.image.authors='Union.AI <info@union.ai>'",
487
+ "LABEL org.opencontainers.image.source=https://github.com/flyteorg/flyte",
312
488
  "RUN useradd --create-home --shell /bin/bash flytekit &&"
313
489
  " chown -R flytekit /root && chown -R flytekit /home",
314
490
  "WORKDIR /root",
@@ -323,99 +499,80 @@ class Image:
323
499
  "UV_LINK_MODE": "copy",
324
500
  }
325
501
  )
326
- image = image.with_apt_packages(["build-essential", "ca-certificates"])
327
-
328
- base_packages = ["kubernetes", "msgpack", "mashumaro"]
329
-
330
- # Add in flyte library
331
- if dev_mode:
332
- image = image.with_pip_packages(base_packages)
333
- image = image.with_local_v2()
334
- else:
335
- base_packages.append(f"flyte=={flyte_version}")
336
- image = image.with_pip_packages(base_packages)
502
+ image = image.with_apt_packages("build-essential", "ca-certificates")
503
+
504
+ if install_flyte:
505
+ if dev_mode:
506
+ if os.path.exists(DIST_FOLDER):
507
+ image = image.with_local_v2()
508
+ else:
509
+ flyte_version = typing.cast(str, flyte_version)
510
+ if Version(flyte_version).is_devrelease or Version(flyte_version).is_prerelease:
511
+ image = image.with_pip_packages(f"flyte=={flyte_version}", pre=True)
512
+ else:
513
+ image = image.with_pip_packages(f"flyte=={flyte_version}")
514
+ if not dev_mode:
515
+ object.__setattr__(image, "_tag", preset_tag)
337
516
 
338
517
  return image
339
518
 
340
- @staticmethod
341
- def _is_editable_install():
342
- """Internal hacky function to see if the current install is editable or not."""
343
- curr = Path(__file__)
344
- pyproject = curr.parent.parent.parent / "pyproject.toml"
345
- return pyproject.exists()
346
-
347
- @classmethod
348
- def from_uv_debian(
349
- cls,
350
- registry: str,
351
- name: str,
352
- tag: Optional[str] = None,
353
- python_version: Optional[Tuple[int, int]] = None,
354
- arch: Union[Architecture, Tuple[Architecture, ...]] = "linux/amd64",
355
- ) -> Image:
356
- """
357
- This creates a new debian-based base image.
358
- If using the Union or docker builders, image will have uv available and a virtualenv created at /opt/venv.
359
-
360
- :param registry: Registry to use for the image
361
- :param name: Name of the image
362
- :param tag: Tag to use for the image
363
- :param python_version: Python version to use for the image
364
- :param arch: Architecture to use for the image, default is linux/amd64
365
- :return: Image
366
- """
367
- base_image = "debian:bookworm-slim"
368
- plat = arch if isinstance(arch, tuple) else (arch,)
369
- if python_version is None:
370
- python_version = _detect_python_version()
371
- img = cls(
372
- base_image=base_image, name=name, registry=registry, tag=tag, platform=plat, python_version=python_version
373
- )
374
- return img
375
-
376
519
  @classmethod
377
- def auto(
520
+ def from_debian_base(
378
521
  cls,
379
522
  python_version: Optional[Tuple[int, int]] = None,
380
523
  flyte_version: Optional[str] = None,
524
+ install_flyte: bool = True,
381
525
  registry: Optional[str] = None,
526
+ registry_secret: Optional[str | Secret] = None,
382
527
  name: Optional[str] = None,
528
+ platform: Optional[Tuple[Architecture, ...]] = None,
383
529
  ) -> Image:
384
530
  """
385
531
  Use this method to start using the default base image, built from this library's base Dockerfile
386
532
  Default images are multi-arch amd/arm64
387
533
 
388
534
  :param python_version: If not specified, will use the current Python version
389
- :param flyte_version: Union version to use
535
+ :param flyte_version: Flyte version to use
536
+ :param install_flyte: If True, will install the flyte library in the image
390
537
  :param registry: Registry to use for the image
538
+ :param registry_secret: Secret to use to pull/push the private image.
391
539
  :param name: Name of the image if you want to override the default name
540
+ :param platform: Platform to use for the image, default is linux/amd64, use tuple for multiple values
541
+ Example: ("linux/amd64", "linux/arm64")
392
542
 
393
543
  :return: Image
394
544
  """
395
545
  if python_version is None:
396
546
  python_version = _detect_python_version()
397
547
 
398
- base_image = cls._get_default_image_for(python_version=python_version, flyte_version=flyte_version)
399
- if name is not None and registry is None:
400
- raise ValueError("Both name and registry must be specified to override the default image name.")
548
+ base_image = cls._get_default_image_for(
549
+ python_version=python_version,
550
+ flyte_version=flyte_version,
551
+ install_flyte=install_flyte,
552
+ platform=platform,
553
+ )
401
554
 
402
- if registry and name:
403
- return base_image.clone(registry=registry, name=name)
555
+ if registry or name:
556
+ return base_image.clone(registry=registry, name=name, registry_secret=registry_secret)
404
557
 
405
- # Set this to auto for all auto images because the meaning of "auto" can change (based on logic inside
406
- # _get_default_image_for, acts differently in a running task container) so let's make sure it stays auto.
407
- object.__setattr__(base_image, "_identifier_override", "auto")
408
558
  return base_image
409
559
 
410
560
  @classmethod
411
- def from_prebuilt(cls, image_uri: str) -> Image:
561
+ def from_base(cls, image_uri: str) -> Image:
412
562
  """
413
563
  Use this method to start with a pre-built base image. This image must already exist in the registry of course.
414
564
 
415
565
  :param image_uri: The full URI of the image, in the format <registry>/<name>:<tag>
416
566
  :return:
417
567
  """
418
- img = cls(base_image=image_uri)
568
+ img = cls._new(base_image=image_uri)
569
+ return img
570
+
571
+ @classmethod
572
+ def from_ref_name(cls, name: str = _DEFAULT_IMAGE_REF_NAME) -> Image:
573
+ # NOTE: set image name as _ref_name to enable adding additional layers.
574
+ # See: https://github.com/flyteorg/flyte-sdk/blob/14de802701aab7b8615ffb99c650a36305ef01f7/src/flyte/_image.py#L642
575
+ img = cls._new(name=name, _ref_name=name)
419
576
  return img
420
577
 
421
578
  @classmethod
@@ -425,8 +582,14 @@ class Image:
425
582
  *,
426
583
  name: str,
427
584
  registry: str | None = None,
585
+ registry_secret: Optional[str | Secret] = None,
428
586
  python_version: Optional[Tuple[int, int]] = None,
429
- arch: Union[Architecture, Tuple[Architecture, ...]] = "linux/amd64",
587
+ index_url: Optional[str] = None,
588
+ extra_index_urls: Union[str, List[str], Tuple[str, ...], None] = None,
589
+ pre: bool = False,
590
+ extra_args: Optional[str] = None,
591
+ platform: Optional[Tuple[Architecture, ...]] = None,
592
+ secret_mounts: Optional[SecretRequest] = None,
430
593
  ) -> Image:
431
594
  """
432
595
  Use this method to create a new image with the specified uv script.
@@ -444,78 +607,127 @@ class Image:
444
607
  ```
445
608
 
446
609
  For more information on the uv script format, see the documentation:
447
- <href="https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies">
448
- UV: Declaring script dependencies</href>
610
+ [UV: Declaring script dependencies](https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies)
449
611
 
450
612
  :param name: name of the image
451
613
  :param registry: registry to use for the image
614
+ :param registry_secret: Secret to use to pull/push the private image.
615
+ :param python_version: Python version to use for the image, if not specified, will use the current Python
616
+ version
452
617
  :param script: path to the uv script
453
- :param arch: architecture to use for the image, default is linux/amd64, use tuple for multiple values
618
+ :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values
619
+ :param python_version: Python version for the image, if not specified, will use the current Python version
620
+ :param index_url: index url to use for pip install, default is None
621
+ :param extra_index_urls: extra index urls to use for pip install, default is True
622
+ :param pre: whether to allow pre-release versions, default is False
623
+ :param extra_args: extra arguments to pass to pip install, default is None
624
+ :param secret_mounts: Secret mounts to use for the image, default is None.
454
625
 
455
626
  :return: Image
627
+
628
+ Args:
629
+ secret_mounts:
456
630
  """
457
- from ._utils import parse_uv_script_file
631
+ ll = UVScript(
632
+ script=Path(script),
633
+ index_url=index_url,
634
+ extra_index_urls=_ensure_tuple(extra_index_urls) if extra_index_urls else None,
635
+ pre=pre,
636
+ extra_args=extra_args,
637
+ secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
638
+ )
458
639
 
459
- if isinstance(script, str):
460
- script = Path(script)
461
- if not script.exists():
462
- raise FileNotFoundError(f"UV script {script} does not exist")
463
- if not script.is_file():
464
- raise ValueError(f"UV script {script} is not a file")
465
- if not script.suffix == ".py":
466
- raise ValueError(f"UV script {script} must have a .py extension")
467
- header = parse_uv_script_file(script)
468
- if registry is None:
469
- raise ValueError("registry must be specified")
470
- img = cls.from_uv_debian(registry=registry, name=name, arch=arch, python_version=python_version)
471
- if header.dependencies:
472
- return img.with_pip_packages(header.dependencies)
473
- # todo: override the _identifier_override to be the script name or a hash of the script contents
474
- # This is needed because inside the image, the identifier will be computed to be something different.
475
- return img
640
+ img = cls.from_debian_base(
641
+ registry=registry,
642
+ registry_secret=registry_secret,
643
+ install_flyte=False,
644
+ name=name,
645
+ python_version=python_version,
646
+ platform=platform,
647
+ )
648
+
649
+ return img.clone(addl_layer=ll)
476
650
 
477
651
  def clone(
478
- self, registry: Optional[str] = None, name: Optional[str] = None, addl_layer: Optional[Layer] = None
652
+ self,
653
+ registry: Optional[str] = None,
654
+ registry_secret: Optional[str | Secret] = None,
655
+ name: Optional[str] = None,
656
+ base_image: Optional[str] = None,
657
+ python_version: Optional[Tuple[int, int]] = None,
658
+ addl_layer: Optional[Layer] = None,
479
659
  ) -> Image:
480
660
  """
481
661
  Use this method to clone the current image and change the registry and name
482
662
 
483
663
  :param registry: Registry to use for the image
664
+ :param registry_secret: Secret to use to pull/push the private image.
484
665
  :param name: Name of the image
485
-
666
+ :param python_version: Python version for the image, if not specified, will use the current Python version
667
+ :param addl_layer: Additional layer to add to the image. This will be added to the end of the layers.
486
668
  :return:
487
669
  """
670
+ from flyte import Secret
671
+
672
+ if addl_layer and self.dockerfile:
673
+ # We don't know how to inspect dockerfiles to know what kind it is (OS, python version, uv vs poetry, etc)
674
+ # so there's no guarantee any of the layering logic will work.
675
+ raise ValueError(
676
+ "Flyte current cannot add additional layers to a Dockerfile-based Image."
677
+ " Please amend the dockerfile directly."
678
+ )
488
679
  registry = registry if registry else self.registry
489
680
  name = name if name else self.name
681
+ registry_secret = registry_secret if registry_secret else self._image_registry_secret
682
+ base_image = base_image if base_image else self.base_image
683
+ if addl_layer and (not name):
684
+ raise ValueError(
685
+ f"Cannot add additional layer {addl_layer} to an image without name. Please first clone()."
686
+ )
490
687
  new_layers = (*self._layers, addl_layer) if addl_layer else self._layers
491
- img = Image(
492
- base_image=self.base_image,
688
+ img = Image._new(
689
+ base_image=base_image,
493
690
  dockerfile=self.dockerfile,
494
691
  registry=registry,
495
692
  name=name,
496
- tag=self.tag,
497
693
  platform=self.platform,
498
- python_version=self.python_version,
499
- is_final=self.is_final,
694
+ python_version=python_version or self.python_version,
500
695
  _layers=new_layers,
696
+ _image_registry_secret=Secret(key=registry_secret) if isinstance(registry_secret, str) else registry_secret,
697
+ _ref_name=self._ref_name,
501
698
  )
502
699
 
503
700
  return img
504
701
 
505
702
  @classmethod
506
- def from_dockerfile(cls, file: Path, registry: str, name: str, tag: Optional[str] = None) -> Image:
703
+ def from_dockerfile(
704
+ cls, file: Path, registry: str, name: str, platform: Union[Architecture, Tuple[Architecture, ...], None] = None
705
+ ) -> Image:
507
706
  """
508
- Use this method to create a new image with the specified dockerfile
707
+ Use this method to create a new image with the specified dockerfile. Note you cannot use additional layers
708
+ after this, as the system doesn't attempt to parse/understand the Dockerfile, and what kind of setup it has
709
+ (python version, uv vs poetry, etc), so please put all logic into the dockerfile itself.
710
+
711
+ Also since Python sees paths as from the calling directory, please use Path objects with absolute paths. The
712
+ context for the builder will be the directory where the dockerfile is located.
509
713
 
510
714
  :param file: path to the dockerfile
511
715
  :param name: name of the image
512
716
  :param registry: registry to use for the image
513
- :param tag: tag to use for the image
717
+ :param platform: architecture to use for the image, default is linux/amd64, use tuple for multiple values
718
+ Example: ("linux/amd64", "linux/arm64")
514
719
 
515
720
  :return:
516
721
  """
517
- tag = tag or "latest"
518
- img = cls(dockerfile=file, registry=registry, name=name, tag=tag)
722
+ platform = _ensure_tuple(platform) if platform else None
723
+ kwargs = {
724
+ "dockerfile": file,
725
+ "registry": registry,
726
+ "name": name,
727
+ }
728
+ if platform:
729
+ kwargs["platform"] = platform
730
+ img = cls._new(**kwargs)
519
731
 
520
732
  return img
521
733
 
@@ -528,6 +740,8 @@ class Image:
528
740
  from ._utils import filehash_update
529
741
 
530
742
  hasher = hashlib.md5()
743
+ if self.base_image:
744
+ hasher.update(self.base_image.encode("utf-8"))
531
745
  if self.dockerfile:
532
746
  # Note the location of the dockerfile shouldn't matter, only the contents
533
747
  filehash_update(self.dockerfile, hasher)
@@ -538,7 +752,7 @@ class Image:
538
752
 
539
753
  @property
540
754
  def _final_tag(self) -> str:
541
- t = self.tag if self.tag else self._get_hash_digest()
755
+ t = self._tag if self._tag else self._get_hash_digest()
542
756
  return t or "latest"
543
757
 
544
758
  @cached_property
@@ -549,6 +763,9 @@ class Image:
549
763
  if self.registry and self.name:
550
764
  tag = self._final_tag
551
765
  return f"{self.registry}/{self.name}:{tag}"
766
+ elif self._ref_name and len(self._layers) == 0:
767
+ assert self.base_image is not None, f"Base image is not set for image ref name {self._ref_name}"
768
+ return self.base_image
552
769
  elif self.name:
553
770
  return f"{self.name}:{self._final_tag}"
554
771
  elif self.base_image:
@@ -567,30 +784,36 @@ class Image:
567
784
  new_image = self.clone(addl_layer=WorkDir(workdir=workdir))
568
785
  return new_image
569
786
 
570
- def with_requirements(self, file: Path) -> Image:
787
+ def with_requirements(
788
+ self,
789
+ file: str | Path,
790
+ secret_mounts: Optional[SecretRequest] = None,
791
+ ) -> Image:
571
792
  """
572
793
  Use this method to create a new image with the specified requirements file layered on top of the current image
573
794
  Cannot be used in conjunction with conda
574
795
 
575
796
  :param file: path to the requirements file, must be a .txt file
797
+ :param secret_mounts: list of secret to mount for the build process.
576
798
  :return:
577
799
  """
578
- if not file.exists():
579
- raise FileNotFoundError(f"Requirements file {file} does not exist")
580
- if not file.is_file():
581
- raise ValueError(f"Requirements file {file} is not a file")
800
+ if isinstance(file, str):
801
+ file = Path(file)
582
802
  if file.suffix != ".txt":
583
803
  raise ValueError(f"Requirements file {file} must have a .txt extension")
584
- new_image = self.clone(addl_layer=Requirements(file=file))
804
+ new_image = self.clone(
805
+ addl_layer=Requirements(file=file, secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None)
806
+ )
585
807
  return new_image
586
808
 
587
809
  def with_pip_packages(
588
810
  self,
589
- packages: Union[str, List[str], Tuple[str, ...]],
811
+ *packages: str,
590
812
  index_url: Optional[str] = None,
591
813
  extra_index_urls: Union[str, List[str], Tuple[str, ...], None] = None,
592
814
  pre: bool = False,
593
815
  extra_args: Optional[str] = None,
816
+ secret_mounts: Optional[SecretRequest] = None,
594
817
  ) -> Image:
595
818
  """
596
819
  Use this method to create a new image with the specified pip packages layered on top of the current image
@@ -598,9 +821,24 @@ class Image:
598
821
 
599
822
  Example:
600
823
  ```python
601
- @flyte.task(image=(flyte.Image
602
- .ubuntu_python()
603
- .with_pip_packages(["requests", "numpy"])))
824
+ @flyte.task(image=(flyte.Image.from_debian_base().with_pip_packages("requests", "numpy")))
825
+ def my_task(x: int) -> int:
826
+ import numpy as np
827
+ return np.sum([x, 1])
828
+ ```
829
+
830
+ To mount secrets during the build process to download private packages, you can use the `secret_mounts`.
831
+ In the below example, "GITHUB_PAT" will be mounted as env var "GITHUB_PAT",
832
+ and "apt-secret" will be mounted at /etc/apt/apt-secret.
833
+ Example:
834
+ ```python
835
+ private_package = "git+https://$GITHUB_PAT@github.com/flyteorg/flytex.git@2e20a2acebfc3877d84af643fdd768edea41d533"
836
+ @flyte.task(
837
+ image=(
838
+ flyte.Image.from_debian_base()
839
+ .with_pip_packages("private_package", secret_mounts=[Secret(key="GITHUB_PAT")])
840
+ .with_apt_packages("git", secret_mounts=[Secret(key="apt-secret", mount="/etc/apt/apt-secret")])
841
+ )
604
842
  def my_task(x: int) -> int:
605
843
  import numpy as np
606
844
  return np.sum([x, 1])
@@ -611,12 +849,10 @@ class Image:
611
849
  :param extra_index_urls: extra index urls to use for pip install, default is None
612
850
  :param pre: whether to allow pre-release versions, default is False
613
851
  :param extra_args: extra arguments to pass to pip install, default is None
614
- # :param secret_mounts: todo
615
- :param extra_args: extra arguments to pass to pip install, default is None
852
+ :param secret_mounts: list of secret to mount for the build process.
616
853
  :return: Image
617
854
  """
618
-
619
- new_packages: Optional[Tuple] = _ensure_tuple(packages) if packages else None
855
+ new_packages: Optional[Tuple] = packages or None
620
856
  new_extra_index_urls: Optional[Tuple] = _ensure_tuple(extra_index_urls) if extra_index_urls else None
621
857
 
622
858
  ll = PipPackages(
@@ -625,6 +861,7 @@ class Image:
625
861
  extra_index_urls=new_extra_index_urls,
626
862
  pre=pre,
627
863
  extra_args=extra_args,
864
+ secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
628
865
  )
629
866
  new_image = self.clone(addl_layer=ll)
630
867
  return new_image
@@ -640,73 +877,161 @@ class Image:
640
877
  new_image = self.clone(addl_layer=Env.from_dict(env_vars))
641
878
  return new_image
642
879
 
643
- def with_source_folder(self, context_source: Path, image_dest: Optional[str] = None) -> Image:
880
+ def with_source_folder(self, src: Path, dst: str = ".", copy_contents_only: bool = False) -> Image:
644
881
  """
645
882
  Use this method to create a new image with the specified local directory layered on top of the current image.
646
883
  If dest is not specified, it will be copied to the working directory of the image
647
884
 
648
- :param context_source: root folder of the source code from the build context to be copied
649
- :param image_dest: destination folder in the image
885
+ :param src: root folder of the source code from the build context to be copied
886
+ :param dst: destination folder in the image
887
+ :param copy_contents_only: If True, will copy the contents of the source folder to the destination folder,
888
+ instead of the folder itself. Default is False.
650
889
  :return: Image
651
890
  """
652
- image_dest = image_dest if image_dest else "."
653
- new_image = self.clone(addl_layer=CopyConfig(path_type=1, context_source=context_source, image_dest=image_dest))
891
+ if not copy_contents_only:
892
+ dst = str("./" + src.name) if dst == "." else dst
893
+ new_image = self.clone(addl_layer=CopyConfig(path_type=1, src=src, dst=dst))
654
894
  return new_image
655
895
 
656
- def with_source_file(self, context_source: Path, image_dest: Optional[str] = None) -> Image:
896
+ def with_source_file(self, src: Path, dst: str = ".") -> Image:
657
897
  """
658
898
  Use this method to create a new image with the specified local file layered on top of the current image.
659
899
  If dest is not specified, it will be copied to the working directory of the image
660
900
 
661
- :param context_source: root folder of the source code from the build context to be copied
662
- :param image_dest: destination folder in the image
901
+ :param src: root folder of the source code from the build context to be copied
902
+ :param dst: destination folder in the image
663
903
  :return: Image
664
904
  """
665
- image_dest = image_dest if image_dest else "."
666
- new_image = self.clone(addl_layer=CopyConfig(path_type=0, context_source=context_source, image_dest=image_dest))
905
+ new_image = self.clone(addl_layer=CopyConfig(path_type=0, src=src, dst=dst))
667
906
  return new_image
668
907
 
669
- def with_uv_project(self, pyproject_file: Path) -> Image:
908
+ def with_dockerignore(self, path: Path) -> Image:
909
+ new_image = self.clone(addl_layer=DockerIgnore(path=str(path)))
910
+ return new_image
911
+
912
+ def with_uv_project(
913
+ self,
914
+ pyproject_file: str | Path,
915
+ uvlock: Path | None = None,
916
+ index_url: Optional[str] = None,
917
+ extra_index_urls: Union[List[str], Tuple[str, ...], None] = None,
918
+ pre: bool = False,
919
+ extra_args: Optional[str] = None,
920
+ secret_mounts: Optional[SecretRequest] = None,
921
+ project_install_mode: typing.Literal["dependencies_only", "install_project"] = "dependencies_only",
922
+ ) -> Image:
670
923
  """
671
924
  Use this method to create a new image with the specified uv.lock file layered on top of the current image
672
925
  Must have a corresponding pyproject.toml file in the same directory
673
926
  Cannot be used in conjunction with conda
674
- In the Union builders, using this will change the virtual env to /root/.venv
927
+
928
+ By default, this method copies the pyproject.toml and uv.lock files into the image.
929
+
930
+ If `project_install_mode` is "install_project", it will also copy directory
931
+ where the pyproject.toml file is located into the image.
675
932
 
676
933
  :param pyproject_file: path to the pyproject.toml file, needs to have a corresponding uv.lock file
677
- :return:
934
+ :param uvlock: path to the uv.lock file, if not specified, will use the default uv.lock file in the same
935
+ directory as the pyproject.toml file. (pyproject.parent / uv.lock)
936
+ :param index_url: index url to use for pip install, default is None
937
+ :param extra_index_urls: extra index urls to use for pip install, default is None
938
+ :param pre: whether to allow pre-release versions, default is False
939
+ :param extra_args: extra arguments to pass to pip install, default is None
940
+ :param secret_mounts: list of secret mounts to use for the build process.
941
+ :param project_install_mode: whether to install the project as a package or
942
+ only dependencies, default is "dependencies_only"
943
+ :return: Image
678
944
  """
679
- if not pyproject_file.exists():
680
- raise FileNotFoundError(f"UVLock file {pyproject_file} does not exist")
681
- if not pyproject_file.is_file():
682
- raise ValueError(f"UVLock file {pyproject_file} is not a file")
683
- lock = pyproject_file.parent / "uv.lock"
684
- if not lock.exists():
685
- raise ValueError(f"UVLock file {lock} does not exist")
686
- new_image = self.clone(addl_layer=UVProject(pyproject=pyproject_file, uvlock=lock))
945
+ if isinstance(pyproject_file, str):
946
+ pyproject_file = Path(pyproject_file)
947
+ new_image = self.clone(
948
+ addl_layer=UVProject(
949
+ pyproject=pyproject_file,
950
+ uvlock=uvlock or (pyproject_file.parent / "uv.lock"),
951
+ index_url=index_url,
952
+ extra_index_urls=extra_index_urls,
953
+ pre=pre,
954
+ extra_args=extra_args,
955
+ secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
956
+ project_install_mode=project_install_mode,
957
+ )
958
+ )
687
959
  return new_image
688
960
 
689
- def with_apt_packages(self, packages: Union[str, List[str], Tuple[str, ...]]) -> Image:
961
+ def with_poetry_project(
962
+ self,
963
+ pyproject_file: str | Path,
964
+ poetry_lock: Path | None = None,
965
+ extra_args: Optional[str] = None,
966
+ secret_mounts: Optional[SecretRequest] = None,
967
+ project_install_mode: typing.Literal["dependencies_only", "install_project"] = "dependencies_only",
968
+ ):
969
+ """
970
+ Use this method to create a new image with the specified pyproject.toml layered on top of the current image.
971
+ Must have a corresponding pyproject.toml file in the same directory.
972
+ Cannot be used in conjunction with conda.
973
+
974
+ By default, this method copies the entire project into the image,
975
+ including files such as pyproject.toml, poetry.lock, and the src/ directory.
976
+
977
+ If you prefer not to install the current project, you can pass through `extra_args`
978
+ `--no-root`. In this case, the image builder will only copy pyproject.toml and poetry.lock
979
+ into the image.
980
+
981
+ :param pyproject_file: Path to the pyproject.toml file. A poetry.lock file must exist in the same directory
982
+ unless `poetry_lock` is explicitly provided.
983
+ :param poetry_lock: Path to the poetry.lock file. If not specified, the default is the file named
984
+ 'poetry.lock' in the same directory as `pyproject_file` (pyproject.parent / "poetry.lock").
985
+ :param extra_args: Extra arguments to pass through to the package installer/resolver, default is None.
986
+ :param secret_mounts: Secrets to make available during dependency resolution/build (e.g., private indexes).
987
+ :param project_install_mode: whether to install the project as a package or
988
+ only dependencies, default is "dependencies_only"
989
+ :return: Image
990
+ """
991
+ if isinstance(pyproject_file, str):
992
+ pyproject_file = Path(pyproject_file)
993
+ new_image = self.clone(
994
+ addl_layer=PoetryProject(
995
+ pyproject=pyproject_file,
996
+ poetry_lock=poetry_lock or (pyproject_file.parent / "poetry.lock"),
997
+ extra_args=extra_args,
998
+ secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
999
+ project_install_mode=project_install_mode,
1000
+ )
1001
+ )
1002
+ return new_image
1003
+
1004
+ def with_apt_packages(self, *packages: str, secret_mounts: Optional[SecretRequest] = None) -> Image:
690
1005
  """
691
1006
  Use this method to create a new image with the specified apt packages layered on top of the current image
692
1007
 
693
1008
  :param packages: list of apt packages to install
1009
+ :param secret_mounts: list of secret mounts to use for the build process.
694
1010
  :return: Image
695
1011
  """
696
- pkgs = _ensure_tuple(packages)
697
- new_image = self.clone(addl_layer=AptPackages(packages=pkgs))
1012
+ new_image = self.clone(
1013
+ addl_layer=AptPackages(
1014
+ packages=packages,
1015
+ secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None,
1016
+ )
1017
+ )
698
1018
  return new_image
699
1019
 
700
- def with_commands(self, commands: List[str]) -> Image:
1020
+ def with_commands(self, commands: List[str], secret_mounts: Optional[SecretRequest] = None) -> Image:
701
1021
  """
702
1022
  Use this method to create a new image with the specified commands layered on top of the current image
703
1023
  Be sure not to use RUN in your command.
704
1024
 
705
1025
  :param commands: list of commands to run
1026
+ :param secret_mounts: list of secret mounts to use for the build process.
706
1027
  :return: Image
707
1028
  """
708
1029
  new_commands: Tuple = _ensure_tuple(commands)
709
- new_image = self.clone(addl_layer=Commands(commands=new_commands))
1030
+ new_image = self.clone(
1031
+ addl_layer=Commands(
1032
+ commands=new_commands, secret_mounts=_ensure_tuple(secret_mounts) if secret_mounts else None
1033
+ )
1034
+ )
710
1035
  return new_image
711
1036
 
712
1037
  def with_local_v2(self) -> Image:
@@ -716,23 +1041,11 @@ class Image:
716
1041
 
717
1042
  :return: Image
718
1043
  """
719
- dist_folder = Path(__file__).parent.parent.parent / "dist"
720
- # Manually declare the CopyConfig instead of using with_source_folder so we can set the hashing
1044
+ # Manually declare the PythonWheel so we can set the hashing
721
1045
  # used to compute the identifier. Can remove if we ever decide to expose the lambda in with_ commands
722
- with_dist = self.clone(
723
- addl_layer=CopyConfig(
724
- path_type=1, context_source=dist_folder, image_dest=".", _compute_identifier=lambda x: "/dist"
725
- )
726
- )
1046
+ with_dist = self.clone(addl_layer=PythonWheels(wheel_dir=DIST_FOLDER, package_name="flyte", pre=True))
727
1047
 
728
- return with_dist.with_commands(
729
- [
730
- "--mount=type=cache,sharing=locked,mode=0777,target=/root/.cache/uv,id=uv"
731
- " --mount=from=uv,source=/uv,target=/usr/bin/uv"
732
- " --mount=source=dist,target=/dist,type=bind"
733
- " uv pip install --python $VIRTUALENV $(ls /dist/*whl)"
734
- ]
735
- )
1048
+ return with_dist
736
1049
 
737
1050
  def __img_str__(self) -> str:
738
1051
  """