ob-metaflow-extensions 1.1.130__py2.py3-none-any.whl → 1.5.1__py2.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 ob-metaflow-extensions might be problematic. Click here for more details.

Files changed (105) hide show
  1. metaflow_extensions/outerbounds/__init__.py +1 -1
  2. metaflow_extensions/outerbounds/plugins/__init__.py +34 -4
  3. metaflow_extensions/outerbounds/plugins/apps/__init__.py +0 -0
  4. metaflow_extensions/outerbounds/plugins/apps/app_cli.py +0 -0
  5. metaflow_extensions/outerbounds/plugins/apps/app_utils.py +187 -0
  6. metaflow_extensions/outerbounds/plugins/apps/consts.py +3 -0
  7. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +15 -0
  8. metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +506 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/__init__.py +0 -0
  10. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/__init__.py +4 -0
  11. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/spinners.py +478 -0
  12. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +128 -0
  13. metaflow_extensions/outerbounds/plugins/apps/core/app_deploy_decorator.py +330 -0
  14. metaflow_extensions/outerbounds/plugins/apps/core/artifacts.py +0 -0
  15. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +958 -0
  16. metaflow_extensions/outerbounds/plugins/apps/core/click_importer.py +24 -0
  17. metaflow_extensions/outerbounds/plugins/apps/core/code_package/__init__.py +3 -0
  18. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +618 -0
  19. metaflow_extensions/outerbounds/plugins/apps/core/code_package/examples.py +125 -0
  20. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +15 -0
  21. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +165 -0
  22. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +966 -0
  23. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +299 -0
  24. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +233 -0
  25. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +537 -0
  26. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +1125 -0
  27. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +337 -0
  28. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +115 -0
  29. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +959 -0
  30. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +89 -0
  31. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +87 -0
  32. metaflow_extensions/outerbounds/plugins/apps/core/secrets.py +164 -0
  33. metaflow_extensions/outerbounds/plugins/apps/core/utils.py +233 -0
  34. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +17 -0
  35. metaflow_extensions/outerbounds/plugins/apps/deploy_decorator.py +201 -0
  36. metaflow_extensions/outerbounds/plugins/apps/supervisord_utils.py +243 -0
  37. metaflow_extensions/outerbounds/plugins/aws/__init__.py +4 -0
  38. metaflow_extensions/outerbounds/plugins/aws/assume_role.py +3 -0
  39. metaflow_extensions/outerbounds/plugins/aws/assume_role_decorator.py +118 -0
  40. metaflow_extensions/outerbounds/plugins/card_utilities/injector.py +1 -1
  41. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/__init__.py +2 -0
  42. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/coreweave.py +71 -0
  43. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/external_chckpt.py +85 -0
  44. metaflow_extensions/outerbounds/plugins/checkpoint_datastores/nebius.py +73 -0
  45. metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +110 -0
  46. metaflow_extensions/outerbounds/plugins/fast_bakery/docker_environment.py +43 -9
  47. metaflow_extensions/outerbounds/plugins/fast_bakery/fast_bakery.py +12 -0
  48. metaflow_extensions/outerbounds/plugins/kubernetes/kubernetes_client.py +18 -44
  49. metaflow_extensions/outerbounds/plugins/kubernetes/pod_killer.py +374 -0
  50. metaflow_extensions/outerbounds/plugins/nim/card.py +2 -16
  51. metaflow_extensions/outerbounds/plugins/nim/{__init__.py → nim_decorator.py} +13 -49
  52. metaflow_extensions/outerbounds/plugins/nim/nim_manager.py +294 -233
  53. metaflow_extensions/outerbounds/plugins/nim/utils.py +36 -0
  54. metaflow_extensions/outerbounds/plugins/nvcf/constants.py +2 -2
  55. metaflow_extensions/outerbounds/plugins/nvcf/nvcf.py +100 -19
  56. metaflow_extensions/outerbounds/plugins/nvcf/nvcf_decorator.py +6 -1
  57. metaflow_extensions/outerbounds/plugins/nvct/__init__.py +0 -0
  58. metaflow_extensions/outerbounds/plugins/nvct/exceptions.py +71 -0
  59. metaflow_extensions/outerbounds/plugins/nvct/nvct.py +131 -0
  60. metaflow_extensions/outerbounds/plugins/nvct/nvct_cli.py +289 -0
  61. metaflow_extensions/outerbounds/plugins/nvct/nvct_decorator.py +286 -0
  62. metaflow_extensions/outerbounds/plugins/nvct/nvct_runner.py +218 -0
  63. metaflow_extensions/outerbounds/plugins/nvct/utils.py +29 -0
  64. metaflow_extensions/outerbounds/plugins/ollama/__init__.py +225 -0
  65. metaflow_extensions/outerbounds/plugins/ollama/constants.py +1 -0
  66. metaflow_extensions/outerbounds/plugins/ollama/exceptions.py +22 -0
  67. metaflow_extensions/outerbounds/plugins/ollama/ollama.py +1924 -0
  68. metaflow_extensions/outerbounds/plugins/ollama/status_card.py +292 -0
  69. metaflow_extensions/outerbounds/plugins/optuna/__init__.py +48 -0
  70. metaflow_extensions/outerbounds/plugins/profilers/simple_card_decorator.py +96 -0
  71. metaflow_extensions/outerbounds/plugins/s3_proxy/__init__.py +7 -0
  72. metaflow_extensions/outerbounds/plugins/s3_proxy/binary_caller.py +132 -0
  73. metaflow_extensions/outerbounds/plugins/s3_proxy/constants.py +11 -0
  74. metaflow_extensions/outerbounds/plugins/s3_proxy/exceptions.py +13 -0
  75. metaflow_extensions/outerbounds/plugins/s3_proxy/proxy_bootstrap.py +59 -0
  76. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_api.py +93 -0
  77. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_decorator.py +250 -0
  78. metaflow_extensions/outerbounds/plugins/s3_proxy/s3_proxy_manager.py +225 -0
  79. metaflow_extensions/outerbounds/plugins/secrets/secrets.py +38 -2
  80. metaflow_extensions/outerbounds/plugins/snowflake/snowflake.py +81 -11
  81. metaflow_extensions/outerbounds/plugins/snowpark/snowpark.py +18 -8
  82. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_cli.py +6 -0
  83. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_client.py +45 -18
  84. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_decorator.py +18 -9
  85. metaflow_extensions/outerbounds/plugins/snowpark/snowpark_job.py +10 -4
  86. metaflow_extensions/outerbounds/plugins/torchtune/__init__.py +163 -0
  87. metaflow_extensions/outerbounds/plugins/vllm/__init__.py +255 -0
  88. metaflow_extensions/outerbounds/plugins/vllm/constants.py +1 -0
  89. metaflow_extensions/outerbounds/plugins/vllm/exceptions.py +1 -0
  90. metaflow_extensions/outerbounds/plugins/vllm/status_card.py +352 -0
  91. metaflow_extensions/outerbounds/plugins/vllm/vllm_manager.py +621 -0
  92. metaflow_extensions/outerbounds/remote_config.py +46 -9
  93. metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +94 -2
  94. metaflow_extensions/outerbounds/toplevel/ob_internal.py +4 -0
  95. metaflow_extensions/outerbounds/toplevel/plugins/ollama/__init__.py +1 -0
  96. metaflow_extensions/outerbounds/toplevel/plugins/optuna/__init__.py +1 -0
  97. metaflow_extensions/outerbounds/toplevel/plugins/torchtune/__init__.py +1 -0
  98. metaflow_extensions/outerbounds/toplevel/plugins/vllm/__init__.py +1 -0
  99. metaflow_extensions/outerbounds/toplevel/s3_proxy.py +88 -0
  100. {ob_metaflow_extensions-1.1.130.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/METADATA +2 -2
  101. ob_metaflow_extensions-1.5.1.dist-info/RECORD +133 -0
  102. metaflow_extensions/outerbounds/plugins/nim/utilities.py +0 -5
  103. ob_metaflow_extensions-1.1.130.dist-info/RECORD +0 -56
  104. {ob_metaflow_extensions-1.1.130.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/WHEEL +0 -0
  105. {ob_metaflow_extensions-1.1.130.dist-info → ob_metaflow_extensions-1.5.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,959 @@
1
+ from .config import TypedCoreConfig, TypedDict
2
+ from .perimeters import PerimeterExtractor
3
+ from .capsule import CapsuleApi
4
+ import time
5
+ import os
6
+ import tempfile
7
+ from ._state_machine import DEPLOYMENT_READY_CONDITIONS, LogLine
8
+ from .app_config import AppConfig, AppConfigError
9
+ from .code_package import CodePackager
10
+ from .config import PackagedCode, BakedImage
11
+ from .app_config import CODE_PACKAGE_PREFIX, AuthType
12
+ from .capsule import CapsuleDeployer, list_and_filter_capsules, _format_url_string
13
+ from .dependencies import ImageBakingException
14
+ from functools import partial
15
+ import sys
16
+ from typing import Dict, List, Optional, Callable, Any
17
+ from datetime import datetime
18
+
19
+
20
+ def bake_image(
21
+ pypi: Optional[Dict[str, str]] = None,
22
+ conda: Optional[Dict[str, str]] = None,
23
+ requirements_file: Optional[str] = None,
24
+ pyproject_toml: Optional[str] = None,
25
+ base_image: Optional[str] = None,
26
+ python: Optional[str] = None,
27
+ logger: Optional[Callable[[str], Any]] = None,
28
+ cache_name: Optional[str] = None,
29
+ ) -> BakedImage:
30
+ """
31
+ Bake a Docker image with the specified dependencies.
32
+
33
+ This is a composable building block that can be used standalone or
34
+ combined with AppDeployer to deploy apps with custom images.
35
+
36
+ Parameters
37
+ ----------
38
+ pypi : Dict[str, str], optional
39
+ Dictionary of PyPI packages to install. Keys are package names,
40
+ values are version specifiers. Example: {"flask": ">=2.0", "requests": ""}
41
+ Mutually exclusive with requirements_file and pyproject_toml.
42
+ conda : Dict[str, str], optional
43
+ Dictionary of Conda packages to install.
44
+ requirements_file : str, optional
45
+ Path to a requirements.txt file.
46
+ Mutually exclusive with pypi and pyproject_toml.
47
+ pyproject_toml : str, optional
48
+ Path to a pyproject.toml file.
49
+ Mutually exclusive with pypi and requirements_file.
50
+ base_image : str, optional
51
+ Base Docker image to build from. Defaults to the platform default image.
52
+ python : str, optional
53
+ Python version to use (e.g., "3.11.0"). If None (default), uses the Python
54
+ already present in the base_image and installs dependencies into it. If a
55
+ version is specified, a new Python environment at that version is created
56
+ inside the base image, and all dependencies are installed into it.
57
+ logger : Callable, optional
58
+ Logger function for progress messages.
59
+
60
+ Returns
61
+ -------
62
+ BakedImage
63
+ Named tuple containing:
64
+ - image: The baked Docker image URL
65
+ - python_path: Path to Python executable in the image
66
+
67
+ Raises
68
+ ------
69
+ ImageBakingException
70
+ If baking fails or if invalid parameters are provided.
71
+
72
+ Examples
73
+ --------
74
+ Bake with PyPI packages:
75
+
76
+ ```python
77
+ result = bake_image(pypi={"flask": ">=2.0", "requests": ""})
78
+ print(result.image)
79
+ ```
80
+
81
+ Bake from requirements.txt:
82
+
83
+ ```python
84
+ result = bake_image(requirements_file="./requirements.txt")
85
+ ```
86
+
87
+ Bake from pyproject.toml:
88
+
89
+ ```python
90
+ result = bake_image(pyproject_toml="./pyproject.toml")
91
+ ```
92
+
93
+ Combine with AppDeployer:
94
+
95
+ ```python
96
+ baked = bake_image(pypi={"flask": ">=2.0"})
97
+ deployer = AppDeployer(name="my-app", port=8080, image=baked.image)
98
+ deployed = deployer.deploy()
99
+ ```
100
+ """
101
+ from metaflow.ob_internal import internal_bake_image as _internal_bake # type: ignore
102
+ from metaflow.plugins.pypi.parsers import (
103
+ requirements_txt_parser,
104
+ pyproject_toml_parser,
105
+ )
106
+
107
+ from metaflow.metaflow_config import (
108
+ DEFAULT_DATASTORE,
109
+ get_pinned_conda_libs,
110
+ KUBERNETES_CONTAINER_IMAGE,
111
+ )
112
+
113
+ # Count how many dependency sources are provided
114
+ dep_sources = sum(
115
+ [
116
+ pypi is not None,
117
+ requirements_file is not None,
118
+ pyproject_toml is not None,
119
+ ]
120
+ )
121
+
122
+ if dep_sources > 1:
123
+ raise ImageBakingException(
124
+ "Only one of pypi, requirements_file, or pyproject_toml can be specified."
125
+ )
126
+
127
+ # Set defaults
128
+ _base_image = base_image or KUBERNETES_CONTAINER_IMAGE
129
+ _python_version = python # Keep it None to use image python
130
+ _logger = logger or (lambda x: None)
131
+ _cache_name = cache_name or "default"
132
+
133
+ # Set up cache directory (internal - not exposed to users)
134
+ cache_dir = os.path.join(tempfile.gettempdir(), f"ob-bake-{_cache_name}")
135
+ os.makedirs(cache_dir, exist_ok=True)
136
+ cache_file_path = os.path.join(cache_dir, "image_cache")
137
+
138
+ # Collect packages
139
+ pypi_packages: Dict[str, str] = {}
140
+ conda_packages: Dict[str, str] = {}
141
+
142
+ # Parse from file if provided
143
+ if requirements_file:
144
+ if not os.path.exists(requirements_file):
145
+ raise ImageBakingException(
146
+ f"Requirements file not found: {requirements_file}"
147
+ )
148
+ with open(requirements_file, "r") as f:
149
+ parsed = requirements_txt_parser(f.read())
150
+ pypi_packages = parsed.get("packages", {})
151
+ _python_version = parsed.get("python_version", _python_version)
152
+ _logger(f"📦 Parsed {len(pypi_packages)} packages from {requirements_file}")
153
+
154
+ elif pyproject_toml:
155
+ if not os.path.exists(pyproject_toml):
156
+ raise ImageBakingException(f"pyproject.toml not found: {pyproject_toml}")
157
+ with open(pyproject_toml, "r") as f:
158
+ parsed = pyproject_toml_parser(f.read())
159
+ pypi_packages = parsed.get("packages", {})
160
+ _python_version = parsed.get("python_version", _python_version)
161
+ _logger(f"📦 Parsed {len(pypi_packages)} packages from {pyproject_toml}")
162
+
163
+ elif pypi:
164
+ pypi_packages = pypi.copy()
165
+
166
+ if conda:
167
+ conda_packages = conda.copy()
168
+
169
+ # Check if there are any packages to bake
170
+ if not pypi_packages and not conda_packages:
171
+ _logger("⚠️ No packages to bake. Returning base image.")
172
+ return BakedImage(image=_base_image, python_path="python")
173
+
174
+ # Add pinned conda libs required by the platform
175
+ pinned_libs = get_pinned_conda_libs(_python_version, DEFAULT_DATASTORE)
176
+ pypi_packages.update(pinned_libs)
177
+
178
+ _logger(f"🍞 Baking image with {len(pypi_packages)} PyPI packages...")
179
+
180
+ # Call the internal bake function
181
+ fb_response = _internal_bake(
182
+ cache_file_path=cache_file_path,
183
+ pypi_packages=pypi_packages,
184
+ conda_packages=conda_packages,
185
+ ref=_cache_name,
186
+ python=_python_version,
187
+ base_image=_base_image,
188
+ logger=_logger,
189
+ )
190
+
191
+ if fb_response.failure:
192
+ raise ImageBakingException(f"Failed to bake image: {fb_response.response}")
193
+
194
+ _logger(f"🐳 Baked image: {fb_response.container_image}")
195
+
196
+ return BakedImage(
197
+ image=fb_response.container_image,
198
+ python_path=fb_response.python_path,
199
+ )
200
+
201
+
202
+ class CodePackagingException(Exception):
203
+ """Exception raised when code packaging fails."""
204
+
205
+ pass
206
+
207
+
208
+ def package_code(
209
+ src_paths: List[str],
210
+ suffixes: Optional[List[str]] = None,
211
+ logger: Optional[Callable[[str], Any]] = None,
212
+ ) -> PackagedCode:
213
+ """
214
+ Package code for deployment to the Outerbounds Platform.
215
+
216
+ This is a composable building block that can be used standalone or
217
+ combined with AppDeployer to deploy apps with custom code packages.
218
+
219
+ Parameters
220
+ ----------
221
+ src_paths : List[str]
222
+ List of directories to include in the package. All paths must exist
223
+ and be directories.
224
+ suffixes : List[str], optional
225
+ File extensions to include (e.g., [".py", ".json", ".yaml"]).
226
+ If None, uses default suffixes: .py, .txt, .yaml, .yml, .json,
227
+ .html, .css, .js, .jsx, .ts, .tsx, .md, .rst
228
+ logger : Callable, optional
229
+ Logger function for progress messages. Receives a single string argument.
230
+
231
+ Returns
232
+ -------
233
+ PackagedCode
234
+ Named tuple containing:
235
+ - url: The package URL in object storage
236
+ - key: Unique content-addressed key identifying this package
237
+
238
+ Raises
239
+ ------
240
+ CodePackagingException
241
+ If packaging fails or if invalid paths are provided.
242
+
243
+ Examples
244
+ --------
245
+ Package a directory:
246
+
247
+ ```python
248
+ pkg = package_code(src_paths=["./src"])
249
+ print(pkg.url)
250
+ ```
251
+
252
+ Package multiple directories:
253
+
254
+ ```python
255
+ pkg = package_code(src_paths=["./src", "./configs"])
256
+ ```
257
+
258
+ Package with specific file types:
259
+
260
+ ```python
261
+ pkg = package_code(
262
+ src_paths=["./app"],
263
+ suffixes=[".py", ".yaml", ".json"]
264
+ )
265
+ ```
266
+ """
267
+ from metaflow.metaflow_config import DEFAULT_DATASTORE
268
+
269
+ _logger = logger or (lambda x: None)
270
+
271
+ # Validate paths
272
+ for path in src_paths:
273
+ if not os.path.exists(path):
274
+ raise CodePackagingException(f"Source path does not exist: {path}")
275
+ if not os.path.isdir(path):
276
+ raise CodePackagingException(f"Source path is not a directory: {path}")
277
+
278
+ _logger(f"📦 Packaging {len(src_paths)} directory(ies)...")
279
+
280
+ # Create packager and store
281
+ packager = CodePackager(
282
+ datastore_type=DEFAULT_DATASTORE,
283
+ code_package_prefix=CODE_PACKAGE_PREFIX,
284
+ )
285
+
286
+ try:
287
+ package_url, package_key = packager.store(
288
+ paths_to_include=src_paths,
289
+ file_suffixes=suffixes, # None uses defaults in CodePackager
290
+ )
291
+ except Exception as e:
292
+ raise CodePackagingException(f"Failed to package code: {e}") from e
293
+
294
+ _logger(f"📦 Code package stored: {package_url}")
295
+
296
+ return PackagedCode(url=package_url, key=package_key)
297
+
298
+
299
+ class AppDeployer(TypedCoreConfig):
300
+
301
+ __examples__ = """
302
+ Examples
303
+ --------
304
+ Basic deployment with bake_image and package_code:
305
+
306
+ ```python
307
+ from metaflow import bake_image, package_code, AppDeployer
308
+
309
+ # Step 1: Bake dependencies into an image
310
+ baked = bake_image(pypi={"flask": ">=2.0", "requests": ""})
311
+
312
+ # Step 2: Package your application code
313
+ pkg = package_code(src_paths=["./src"])
314
+
315
+ # Step 3: Create deployer and deploy
316
+ deployer = AppDeployer(
317
+ name="my-flask-app",
318
+ port=8000,
319
+ image=baked.image,
320
+ code_package=pkg,
321
+ commands=["python server.py"],
322
+ replicas={"min": 1, "max": 3},
323
+ resources={"cpu": "1", "memory": "2048Mi"},
324
+ )
325
+ deployed = deployer.deploy()
326
+ print(deployed.public_url)
327
+ ```
328
+
329
+ Deployment with API authentication:
330
+
331
+ ```python
332
+ deployer = AppDeployer(
333
+ name="my-api",
334
+ port=8000,
335
+ image=baked.image,
336
+ code_package=pkg,
337
+ commands=["python api.py"],
338
+ auth={"type": "API"},
339
+ )
340
+ deployed = deployer.deploy()
341
+ ```
342
+
343
+ Deployment with environment variables and secrets:
344
+
345
+ ```python
346
+ deployer = AppDeployer(
347
+ name="my-app",
348
+ port=8000,
349
+ image=baked.image,
350
+ code_package=pkg,
351
+ commands=["python app.py"],
352
+ environment={"DEBUG": "false", "LOG_LEVEL": "info"},
353
+ secrets=["my-api-keys"],
354
+ )
355
+ deployed = deployer.deploy()
356
+ ```
357
+
358
+ Interacting with a deployed app:
359
+
360
+ ```python
361
+ # Get app info
362
+ info = deployed.info()
363
+
364
+ # Get logs from all workers
365
+ logs = deployed.logs()
366
+
367
+ # Scale to zero workers
368
+ deployed.scale_to_zero()
369
+
370
+ # Delete the app
371
+ deployed.delete()
372
+ ```
373
+ """
374
+
375
+ __doc__ = (
376
+ """Programmatic API For deploying Outerbounds Apps.\n"""
377
+ + TypedCoreConfig.__doc__
378
+ + __examples__
379
+ )
380
+
381
+ __init__ = TypedCoreConfig.__init__
382
+
383
+ _app_config: AppConfig
384
+
385
+ # What is `_state` ?
386
+ # `_state` is a dictionary that will hold all information that might need
387
+ # to be passed down without the user explicity setting them.
388
+ # Setting `_state` will ensure that values are explicity passed down from
389
+ # top level when the class is used under different context.
390
+ # So for example if we need to set some things like project/branches etc
391
+ # during metaflow context, we can do so easily. We also like to set state like
392
+ # perimeters at class level since users current cannot also switch perimeters within
393
+ # the same interpreter.
394
+ _state = {}
395
+
396
+ __state_items = [
397
+ # perimeter and api_url come from config setups
398
+ # need to happen before AppDeployer and need to
399
+ # come from _set_state
400
+ "perimeter",
401
+ "api_url",
402
+ # code package URL / code package key
403
+ # can come from CodePackager so its fine
404
+ # if its in _set_state
405
+ "code_package_url",
406
+ "code_package_key",
407
+ # Image can be explicitly set by the user
408
+ # or requre some external fast-bakery API
409
+ "image",
410
+ # project/branch have to come from _set_state
411
+ # if users do this through current. Otherwise
412
+ # can come from the
413
+ "project",
414
+ "branch",
415
+ ]
416
+
417
+ def _init(self):
418
+ perimeter, api_url = PerimeterExtractor.during_programmatic_access()
419
+ self._set_state("perimeter", perimeter)
420
+ self._set_state("api_url", api_url)
421
+
422
+ @property
423
+ def _deploy_config(self) -> AppConfig:
424
+ if not hasattr(self, "_app_config"):
425
+ self._app_config = AppConfig(self._config)
426
+ return self._app_config
427
+
428
+ # Things that need to be set before deploy
429
+ @classmethod
430
+ def _set_state(cls, key, value):
431
+ cls._state[key] = value
432
+
433
+ def deploy(
434
+ self,
435
+ readiness_condition: str = DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
436
+ max_wait_time=600,
437
+ readiness_wait_time=10,
438
+ logger_fn=partial(print, file=sys.stderr),
439
+ **kwargs,
440
+ ) -> "DeployedApp":
441
+ """
442
+ Deploy the app to the Outerbounds Platform.
443
+
444
+ This method packages and deploys the configured app, waiting for it to reach
445
+ the specified readiness condition before returning.
446
+
447
+ Parameters
448
+ ----------
449
+ readiness_condition : str, optional
450
+ The condition that must be met for the deployment to be considered ready.
451
+ Default is ATLEAST_ONE_RUNNING.
452
+
453
+ Deployment ready conditions define what is considered a successful completion
454
+ of the current deployment instance. This allows users or platform designers
455
+ to configure the criteria for deployment readiness.
456
+
457
+ Why do we need deployment readiness conditions?
458
+ - Deployments might be taking place from a CI/CD-esque environment.
459
+ In these setups, the downstream build triggers might be depending on
460
+ a specific criteria for deployment completion. Having readiness conditions
461
+ allows the CI/CD systems to get a signal of when the deployment is ready.
462
+ - Users might be calling the deployment API under different conditions:
463
+ - Some users might want a cluster of workers ready before serving
464
+ traffic while others might want just one worker ready to start
465
+ serving traffic.
466
+
467
+ Available readiness conditions:
468
+
469
+ ATLEAST_ONE_RUNNING ("at_least_one_running")
470
+ At least min(min_replicas, 1) workers of the current deployment
471
+ instance's version have started running.
472
+ Usecase: Some endpoints may be deployed ephemerally and are considered
473
+ ready when at least one instance is running; additional instances are
474
+ for load management.
475
+
476
+ ALL_RUNNING ("all_running")
477
+ At least min_replicas number of workers are running for the deployment
478
+ to be considered ready.
479
+ Usecase: Operators may require that all replicas are available before
480
+ traffic is routed. Needed when inference endpoints may be under some
481
+ SLA or require a larger load.
482
+
483
+ FULLY_FINISHED ("fully_finished")
484
+ At least min_replicas number of workers are running for the deployment
485
+ and there are no pending or crashlooping workers from previous versions
486
+ lying around.
487
+ Usecase: Ensuring endpoint is fully available and no other versions are
488
+ running or endpoint has been fully scaled down.
489
+
490
+ ASYNC ("async")
491
+ The deployment will be assumed ready as soon as the server acknowledges
492
+ it has registered the app in the backend.
493
+ Usecase: Operators may only care that the URL is minted for the deployment
494
+ or the operator wants the deployment to eventually scale down to 0.
495
+
496
+ max_wait_time : int, optional
497
+ Maximum time in seconds to wait for the deployment to reach readiness.
498
+ Default is 600 (10 minutes).
499
+
500
+ readiness_wait_time : int, optional
501
+ Time in seconds to wait between readiness checks. Default is 10.
502
+
503
+ logger_fn : Callable, optional
504
+ Function to use for logging progress messages. Default prints to stderr.
505
+
506
+ Returns
507
+ -------
508
+ DeployedApp
509
+ An object representing the deployed app with methods to interact with it
510
+ (logs, info, scale_to_zero, delete, etc.) and properties like public_url.
511
+
512
+ Raises
513
+ ------
514
+ CodePackagingException
515
+ If code_package is not provided or is not a valid PackagedCode instance.
516
+
517
+ AppConfigError
518
+ If the app configuration is invalid or if an upgrade is already in progress
519
+ (unless force_upgrade is set).
520
+
521
+ Examples
522
+ --------
523
+ Basic deployment:
524
+
525
+ ```python
526
+ from metaflow import bake_image, package_code, AppDeployer
527
+ baked = bake_image(pypi={"flask": ">=2.0"})
528
+ pkg = package_code(src_paths=["./src"])
529
+ deployer = AppDeployer(
530
+ name="my-app",
531
+ port=8000,
532
+ image=baked.image,
533
+ code_package=pkg,
534
+ commands=["python server.py"],
535
+ )
536
+ deployed = deployer.deploy()
537
+ print(deployed.public_url)
538
+ ```
539
+
540
+ Wait for all replicas to be ready:
541
+
542
+ ```python
543
+ deployed = deployer.deploy(
544
+ readiness_condition="all_running"
545
+ )
546
+ ```
547
+
548
+ Async deployment (don't wait for workers):
549
+
550
+ ```python
551
+ deployed = deployer.deploy(
552
+ readiness_condition="async"
553
+ )
554
+ ```
555
+ """
556
+ if len(self._state.get("default_tags", [])) > 0:
557
+ self._deploy_config._core_config.tags = (
558
+ self._deploy_config._core_config.tags or []
559
+ ) + self._state["default_tags"]
560
+
561
+ # Handle code_package if provided - extract url and key to state
562
+ code_package = getattr(self._deploy_config._core_config, "code_package", None)
563
+ if code_package is not None:
564
+ # Validate that code_package is a PackagedCode namedtuple
565
+ if not isinstance(code_package, PackagedCode):
566
+ raise CodePackagingException(
567
+ f"code_package must be a PackagedCode instance returned by package_code(). "
568
+ f"Got {type(code_package).__name__} instead.\n\n"
569
+ "Use package_code() to create a valid code package:\n\n"
570
+ " from metaflow import package_code, AppDeployer\n\n"
571
+ " pkg = package_code(src_paths=['./src'])\n"
572
+ " deployer = AppDeployer(..., code_package=pkg)\n"
573
+ )
574
+ self._set_state("code_package_url", code_package.url)
575
+ self._set_state("code_package_key", code_package.key)
576
+ # Clear the code_package field to avoid serialization issues
577
+ self._deploy_config._core_config.code_package = None
578
+
579
+ # Verify code_package is present (either from code_package param or from state)
580
+ if (
581
+ self._state.get("code_package_url") is None
582
+ and self._deploy_config.get_state("code_package_url") is None
583
+ ):
584
+ raise CodePackagingException(
585
+ "code_package is required for deployment. "
586
+ "Use package_code() to create a code package:\n\n"
587
+ " from metaflow import package_code, AppDeployer\n\n"
588
+ " pkg = package_code(src_paths=['./src'])\n"
589
+ " deployer = AppDeployer(..., code_package=pkg)\n"
590
+ )
591
+
592
+ self._deploy_config.commit()
593
+ # Set any state that might have been passed down from the top level
594
+ for k in self.__state_items:
595
+ if self._deploy_config.get_state(k) is None and (
596
+ k in self._state and self._state[k] is not None
597
+ ):
598
+ self._deploy_config.set_state(k, self._state[k])
599
+
600
+ capsule = CapsuleDeployer(
601
+ self._deploy_config,
602
+ self._state["api_url"],
603
+ create_timeout=max_wait_time,
604
+ debug_dir=None,
605
+ success_terminal_state_condition=readiness_condition,
606
+ readiness_wait_time=readiness_wait_time,
607
+ logger_fn=logger_fn,
608
+ )
609
+
610
+ currently_present_capsules = list_and_filter_capsules(
611
+ capsule.capsule_api,
612
+ None,
613
+ None,
614
+ capsule.name,
615
+ None,
616
+ None,
617
+ None,
618
+ )
619
+
620
+ force_upgrade = self._deploy_config.get_state("force_upgrade", False)
621
+
622
+ if len(currently_present_capsules) > 0:
623
+ # Only update the capsule if there is no upgrade in progress
624
+ # Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
625
+ _curr_cap = currently_present_capsules[0]
626
+ this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
627
+ "updateInProgress", False
628
+ )
629
+
630
+ if this_capsule_is_being_updated and not force_upgrade:
631
+ _upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
632
+ message = f"{capsule.capsule_type} is currently being upgraded"
633
+ if _upgrader:
634
+ message = (
635
+ f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
636
+ "If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
637
+ )
638
+ raise AppConfigError(message)
639
+
640
+ logger_fn(
641
+ f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
642
+ )
643
+ else:
644
+ logger_fn(
645
+ f"🚀 Deploying {capsule.capsule_type.lower()} `{capsule.name}`....",
646
+ )
647
+
648
+ capsule.create()
649
+ final_status = capsule.wait_for_terminal_state()
650
+ return DeployedApp(
651
+ final_status["id"],
652
+ final_status["auth_type"],
653
+ _format_url_string(final_status["public_url"]),
654
+ final_status["name"],
655
+ final_status["deployed_version"],
656
+ final_status["deployed_at"],
657
+ )
658
+
659
+ @classmethod
660
+ def list_deployments(
661
+ cls,
662
+ name: str = None,
663
+ project: str = None,
664
+ branch: str = None,
665
+ tags: List[Dict[str, str]] = None,
666
+ ) -> List["DeployedApp"]:
667
+ """
668
+ List deployed apps, optionally filtered by name, project, branch, or tags.
669
+
670
+ Parameters
671
+ ----------
672
+ name : str, optional
673
+ Filter by app name.
674
+ project : str, optional
675
+ Filter by project name.
676
+ branch : str, optional
677
+ Filter by branch name.
678
+ tags : List[Dict[str, str]], optional
679
+ Filter by tags. Each tag is a dict with a single key-value pair,
680
+ e.g., [{"env": "prod"}] or [{"team": "ml"}, {"version": "v2"}].
681
+ Apps must have all specified tags to match.
682
+
683
+ Returns
684
+ -------
685
+ List[DeployedApp]
686
+ List of deployed apps matching the filters.
687
+
688
+ Examples
689
+ --------
690
+ List all apps:
691
+
692
+ ```python
693
+ apps = AppDeployer.list_deployments()
694
+ ```
695
+
696
+ Filter by name:
697
+
698
+ ```python
699
+ apps = AppDeployer.list_deployments(name="my-app")
700
+ ```
701
+
702
+ Filter by project and branch:
703
+
704
+ ```python
705
+ apps = AppDeployer.list_deployments(project="ml-pipeline", branch="main")
706
+ ```
707
+
708
+ Filter by a single tag:
709
+
710
+ ```python
711
+ apps = AppDeployer.list_deployments(tags=[{"env": "prod"}])
712
+ ```
713
+
714
+ Filter by multiple tags (AND logic - must match all):
715
+
716
+ ```python
717
+ apps = AppDeployer.list_deployments(tags=[{"env": "prod"}, {"team": "ml"}])
718
+ ```
719
+
720
+ Combine filters:
721
+
722
+ ```python
723
+ apps = AppDeployer.list_deployments(
724
+ project="recommendations",
725
+ tags=[{"env": "staging"}]
726
+ )
727
+ ```
728
+ """
729
+ # Transform tags from {key: value} to {"key": key, "value": value}
730
+ transformed_tags = None
731
+ if tags:
732
+ transformed_tags = [
733
+ {"key": k, "value": v} for tag in tags for k, v in tag.items()
734
+ ]
735
+
736
+ capsule_api = DeployedApp._get_capsule_api()
737
+ list_of_capsules = list_and_filter_capsules(
738
+ capsule_api,
739
+ project=project,
740
+ branch=branch,
741
+ name=name,
742
+ tags=transformed_tags,
743
+ auth_type=None,
744
+ capsule_id=None,
745
+ )
746
+ apps = []
747
+ for cap in list_of_capsules:
748
+ apps.append(DeployedApp._from_capsule(cap))
749
+ return apps
750
+
751
+
752
+ class TTLCachedObject:
753
+ """
754
+ Caches a value with a time-to-live (TTL) per instance.
755
+ Returns None if accessed after TTL has expired.
756
+ """
757
+
758
+ def __init__(self, ttl_seconds: float):
759
+ self._ttl = ttl_seconds
760
+ self._attr_name = None
761
+
762
+ def __set_name__(self, owner, name):
763
+ self._attr_name = f"_ttl_cache_{name}"
764
+
765
+ def __get__(self, instance, owner):
766
+ if instance is None:
767
+ return self
768
+ cache = getattr(instance, self._attr_name, None)
769
+ if cache is None:
770
+ return None
771
+ value, last_set = cache
772
+ if (time.time() - last_set) > self._ttl:
773
+ return None
774
+ return value
775
+
776
+ def __set__(self, instance, val):
777
+ setattr(instance, self._attr_name, (val, time.time()))
778
+
779
+ def __delete__(self, instance):
780
+ if hasattr(instance, self._attr_name):
781
+ delattr(instance, self._attr_name)
782
+
783
+
784
+ class DeployedApp:
785
+
786
+ # Keep a 3ish second TTL so that we can be gentler
787
+ # the backend API.
788
+ _capsule_info_cached = TTLCachedObject(3)
789
+
790
+ def __init__(
791
+ self,
792
+ _id: str,
793
+ capsule_type: str,
794
+ public_url: str,
795
+ name: str,
796
+ deployed_version: str,
797
+ deployed_at: str,
798
+ ):
799
+ self._id = _id
800
+ self._capsule_type = capsule_type
801
+ self._public_url = public_url
802
+ self._name = name
803
+ self._deployed_version = deployed_version
804
+ self._deployed_at = deployed_at
805
+
806
+ @classmethod
807
+ def _get_capsule_api(cls) -> CapsuleApi:
808
+ perimeter, api_server = PerimeterExtractor.during_programmatic_access()
809
+ return CapsuleApi(api_server, perimeter)
810
+
811
+ @property
812
+ def _capsule_info(self):
813
+ # self._capsule_info_cached will be None every 3ish seconds
814
+ if self._capsule_info_cached is not None:
815
+ return self._capsule_info_cached
816
+ self._capsule_info_cached = self.info()
817
+ return self._capsule_info_cached
818
+
819
+ @classmethod
820
+ def _from_capsule(cls, capsule: dict) -> "DeployedApp":
821
+ capsule_id = capsule.get("id")
822
+ capsule_type = (
823
+ capsule.get("spec", {}).get("authConfig", {}).get("authType", None)
824
+ )
825
+ public_url = (
826
+ capsule.get("status", {}).get("accessInfo", {}).get("outOfClusterURL", None)
827
+ )
828
+ name = capsule.get("spec", {}).get(
829
+ "displayName",
830
+ )
831
+ deployed_version = capsule.get(
832
+ "version",
833
+ )
834
+ deployed_at = capsule.get("metadata", {}).get(
835
+ "lastModifiedAt",
836
+ capsule.get("metadata", {}).get(
837
+ "createdAt",
838
+ ),
839
+ )
840
+ if any(i is None for i in [capsule_type, public_url, name]):
841
+ raise ValueError(f"Invalid capsule id: {capsule_id}")
842
+ cpsule = cls(
843
+ capsule_id,
844
+ capsule_type,
845
+ _format_url_string(public_url),
846
+ name,
847
+ deployed_version,
848
+ deployed_at,
849
+ )
850
+
851
+ cpsule._capsule_info_cached = capsule
852
+ return cpsule
853
+
854
+ @classmethod
855
+ def _from_capsule_id(cls, capsule_id: str) -> "DeployedApp":
856
+ capsule_api = cls._get_capsule_api()
857
+ capsule = capsule_api.get(capsule_id)
858
+ return cls._from_capsule(capsule)
859
+
860
+ def logs(self, previous=False) -> Dict[str, List[LogLine]]:
861
+ """
862
+ Returns a dictionary of worker_id to logs.
863
+ If `previous` is True, it will return the logs from the previous execution of the workers.
864
+ Useful when debugging a crashlooping worker.
865
+ """
866
+ capsule_api = self._get_capsule_api()
867
+ # extract workers from capsule
868
+ workers = capsule_api.get_workers(self._id)
869
+ # get logs from workers
870
+ logs = {
871
+ # worker_id: logs
872
+ }
873
+ for worker in workers:
874
+ # TODO: Handle exceptions better over here.
875
+ logs[worker["workerId"]] = capsule_api.logs(
876
+ self._id, worker["workerId"], previous=previous
877
+ )
878
+ return logs
879
+
880
+ def info(self) -> dict:
881
+ """
882
+ Returns a dictionary representing the deployed app.
883
+ """
884
+ capsule_api = self._get_capsule_api()
885
+ capsule = capsule_api.get(self._id)
886
+ return capsule
887
+
888
+ def replicas(self):
889
+ capsule_api = self._get_capsule_api()
890
+ return capsule_api.get_workers(self._id)
891
+
892
+ def scale_to_zero(self):
893
+ """
894
+ Scales the DeployedApp to 0 replicas.
895
+ """
896
+ capsule_api = self._get_capsule_api()
897
+ return capsule_api.patch(
898
+ self._id,
899
+ {
900
+ "autoscalingConfig": {
901
+ "minReplicas": 0,
902
+ "maxReplicas": 0,
903
+ }
904
+ },
905
+ )
906
+
907
+ def delete(self):
908
+ """
909
+ Deletes the DeployedApp.
910
+ """
911
+ capsule_api = self._get_capsule_api()
912
+ return capsule_api.delete(self._id)
913
+
914
+ def auth(self):
915
+ if self.auth_type != AuthType.API:
916
+ raise ValueError(
917
+ "Only API auth style is supported for accessing auth headers"
918
+ )
919
+ from metaflow.metaflow_config import SERVICE_HEADERS
920
+
921
+ return SERVICE_HEADERS
922
+
923
+ @property
924
+ def id(self) -> str:
925
+ return self._id
926
+
927
+ @property
928
+ def auth_type(self) -> str:
929
+ return self._capsule_type
930
+
931
+ @property
932
+ def public_url(self) -> str:
933
+ return self._public_url
934
+
935
+ @property
936
+ def name(self) -> str:
937
+ return self._name
938
+
939
+ @property
940
+ def deployed_version(self) -> str:
941
+ return self._deployed_version
942
+
943
+ @property
944
+ def deployed_at(self) -> datetime:
945
+ return datetime.fromisoformat(self._deployed_at)
946
+
947
+ @property
948
+ def tags(self) -> List[str]:
949
+ """Tags associated with this app."""
950
+ capsule_info = self._capsule_info
951
+ return capsule_info.get("spec", {}).get("tags", [])
952
+
953
+ def __repr__(self) -> str:
954
+ return (
955
+ f"DeployedApp(id='{self._id}', "
956
+ f"name='{self._name}', "
957
+ f"public_url='{self._public_url}', "
958
+ f"deployed_version='{self._deployed_version}')"
959
+ )