ob-metaflow-extensions 1.1.175rc2__py2.py3-none-any.whl → 1.1.175rc4__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 (21) hide show
  1. metaflow_extensions/outerbounds/plugins/__init__.py +1 -2
  2. metaflow_extensions/outerbounds/plugins/apps/app_cli.py +0 -3
  3. metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +112 -0
  4. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +9 -0
  5. metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +41 -15
  6. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +6 -6
  7. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +25 -9
  8. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +1 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +110 -50
  10. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +131 -0
  11. metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +353 -0
  12. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +176 -50
  13. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +4 -4
  14. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +132 -0
  15. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +44 -2
  16. metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +1 -0
  17. metaflow_extensions/outerbounds/toplevel/ob_internal.py +1 -0
  18. {ob_metaflow_extensions-1.1.175rc2.dist-info → ob_metaflow_extensions-1.1.175rc4.dist-info}/METADATA +1 -1
  19. {ob_metaflow_extensions-1.1.175rc2.dist-info → ob_metaflow_extensions-1.1.175rc4.dist-info}/RECORD +21 -18
  20. {ob_metaflow_extensions-1.1.175rc2.dist-info → ob_metaflow_extensions-1.1.175rc4.dist-info}/WHEEL +0 -0
  21. {ob_metaflow_extensions-1.1.175rc2.dist-info → ob_metaflow_extensions-1.1.175rc4.dist-info}/top_level.txt +0 -0
@@ -12,7 +12,7 @@ No external dependencies required - uses only Python standard library.
12
12
  import os
13
13
  import json
14
14
  from typing import Any, Dict, List, Optional, Union, Type
15
-
15
+ import re
16
16
 
17
17
  from .config_utils import (
18
18
  ConfigField,
@@ -41,9 +41,142 @@ class AuthType:
41
41
  return [cls.BROWSER, cls.API]
42
42
 
43
43
 
44
+ class UnitParser:
45
+ UNIT_FREE_REGEX = r"^\d+$"
46
+
47
+ metrics = {
48
+ "memory": {
49
+ "default_unit": "Mi",
50
+ "requires_unit": True, # if a Unit free value is provided then we will add the default unit to it.
51
+ # Regex to match values with units (e.g., "512Mi", "4Gi", "1024Ki")
52
+ "unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
53
+ },
54
+ "cpu": {
55
+ "default_unit": None,
56
+ "requires_unit": False, # if a Unit free value is provided then we will not add the default unit to it.
57
+ # Accepts values like 400m, 4, 0.4, 1000n, etc.
58
+ # Regex to match values with units (e.g., "400m", "1000n", "2", "0.5")
59
+ "unit_regex": r"^(\d+(\.\d+)?(m|n)?|\d+(\.\d+)?)$",
60
+ },
61
+ "disk": {
62
+ "default_unit": "Mi",
63
+ "requires_unit": True, # if a Unit free value is provided then we will add the default unit to it.
64
+ # Regex to match values with units (e.g., "100Mi", "1Gi", "500Ki")
65
+ "unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
66
+ },
67
+ "gpu": {
68
+ "default_unit": None,
69
+ "requires_unit": False,
70
+ # Regex to match values with units (usually just integer count, e.g., "1", "2")
71
+ "unit_regex": r"^\d+$",
72
+ },
73
+ }
74
+
75
+ def __init__(self, metric_name: str):
76
+ self.metric_name = metric_name
77
+
78
+ def validate(self, value: str):
79
+ if self.metrics[self.metric_name]["requires_unit"]:
80
+ if not re.match(self.metrics[self.metric_name]["unit_regex"], value):
81
+ return False
82
+ return True
83
+
84
+ def process(self, value: str):
85
+ value = str(value)
86
+ if self.metrics[self.metric_name]["requires_unit"]:
87
+ if re.match(self.UNIT_FREE_REGEX, value):
88
+ # This means the value is unit free and we need to add the default unit to it.
89
+ value = "%s%s" % (
90
+ value.strip(),
91
+ self.metrics[self.metric_name]["default_unit"],
92
+ )
93
+ return value
94
+
95
+ if re.match(self.metrics[self.metric_name]["unit_regex"], value):
96
+ return value
97
+
98
+ return value
99
+
100
+ def parse(self, value: str):
101
+ if value is None:
102
+ return None
103
+ return self.process(value)
104
+
105
+ @staticmethod
106
+ def validation_wrapper_fn(
107
+ metric_name: str,
108
+ ):
109
+ def validation_fn(value: str):
110
+ if value is None:
111
+ return True
112
+ field_info = ResourceConfig._get_field(ResourceConfig, metric_name) # type: ignore
113
+ parser = UnitParser(metric_name)
114
+ validation = parser.validate(value)
115
+ if not validation:
116
+ raise ConfigValidationFailedException(
117
+ field_name=metric_name,
118
+ field_info=field_info,
119
+ current_value=value,
120
+ message=f"Invalid value for `{metric_name}`. Must be of the format {parser.metrics[metric_name]['unit_regex']}.",
121
+ )
122
+ return validation
123
+
124
+ return validation_fn
125
+
126
+
127
+ class BasicValidations:
128
+ def __init__(self, config_meta_class, field_name):
129
+ self.config_meta_class = config_meta_class
130
+ self.field_name = field_name
131
+
132
+ def _get_field(self):
133
+ return self.config_meta_class._get_field(self.config_meta_class, self.field_name) # type: ignore
134
+
135
+ def enum_validation(self, enums: List[str], current_value):
136
+ if current_value not in enums:
137
+ raise ConfigValidationFailedException(
138
+ field_name=self.field_name,
139
+ field_info=self._get_field(),
140
+ current_value=current_value,
141
+ message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be one of: {' '.join(enums)}",
142
+ )
143
+ return True
144
+
145
+ def range_validation(self, min_value, max_value, current_value):
146
+ if current_value < min_value or current_value > max_value:
147
+ raise ConfigValidationFailedException(
148
+ field_name=self.field_name,
149
+ field_info=self._get_field(),
150
+ current_value=current_value,
151
+ message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be between {min_value} and {max_value}",
152
+ )
153
+ return True
154
+
155
+ def length_validation(self, max_length, current_value):
156
+ if len(current_value) > max_length:
157
+ raise ConfigValidationFailedException(
158
+ field_name=self.field_name,
159
+ field_info=self._get_field(),
160
+ current_value=current_value,
161
+ message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be less than {max_length}",
162
+ )
163
+ return True
164
+
165
+ def regex_validation(self, regex, current_value):
166
+ if not re.match(regex, current_value):
167
+ raise ConfigValidationFailedException(
168
+ field_name=self.field_name,
169
+ field_info=self._get_field(),
170
+ current_value=current_value,
171
+ message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must match regex {regex}",
172
+ )
173
+ return True
174
+
175
+
44
176
  class ResourceConfig(metaclass=ConfigMeta):
45
177
  """Resource configuration for the app."""
46
178
 
179
+ # TODO: Add Unit Validation/Parsing Support for the Fields.
47
180
  cpu = ConfigField(
48
181
  default="1",
49
182
  cli_meta=CLIOption(
@@ -53,6 +186,8 @@ class ResourceConfig(metaclass=ConfigMeta):
53
186
  ),
54
187
  field_type=str,
55
188
  example="500m",
189
+ validation_fn=UnitParser.validation_wrapper_fn("cpu"),
190
+ parsing_fn=UnitParser("cpu").parse,
56
191
  )
57
192
  memory = ConfigField(
58
193
  default="4Gi",
@@ -63,6 +198,8 @@ class ResourceConfig(metaclass=ConfigMeta):
63
198
  ),
64
199
  field_type=str,
65
200
  example="512Mi",
201
+ validation_fn=UnitParser.validation_wrapper_fn("memory"),
202
+ parsing_fn=UnitParser("memory").parse,
66
203
  )
67
204
  gpu = ConfigField(
68
205
  cli_meta=CLIOption(
@@ -72,6 +209,8 @@ class ResourceConfig(metaclass=ConfigMeta):
72
209
  ),
73
210
  field_type=str,
74
211
  example="1",
212
+ validation_fn=UnitParser.validation_wrapper_fn("gpu"),
213
+ parsing_fn=UnitParser("gpu").parse,
75
214
  )
76
215
  disk = ConfigField(
77
216
  default="20Gi",
@@ -82,6 +221,8 @@ class ResourceConfig(metaclass=ConfigMeta):
82
221
  ),
83
222
  field_type=str,
84
223
  example="1Gi",
224
+ validation_fn=UnitParser.validation_wrapper_fn("disk"),
225
+ parsing_fn=UnitParser("disk").parse,
85
226
  )
86
227
 
87
228
 
@@ -156,14 +297,11 @@ class AuthConfig(metaclass=ConfigMeta):
156
297
 
157
298
  @staticmethod
158
299
  def validate(auth_config: "AuthConfig"):
159
- if auth_config.type is not None and auth_config.type not in AuthType.choices():
160
- raise ConfigValidationFailedException(
161
- field_name="type",
162
- field_info=auth_config._get_field("type"),
163
- current_value=auth_config.type,
164
- message=f"Invalid auth type: {auth_config.type}. Must be one of {AuthType.choices()}",
165
- )
166
- return True
300
+ if auth_config.type is None:
301
+ return True
302
+ return BasicValidations(AuthConfig, "type").enum_validation(
303
+ AuthType.choices(), auth_config.type
304
+ )
167
305
 
168
306
 
169
307
  class ReplicaConfig(metaclass=ConfigMeta):
@@ -207,12 +345,14 @@ class ReplicaConfig(metaclass=ConfigMeta):
207
345
  replica_config.fixed is None,
208
346
  ]
209
347
  ):
348
+ # if nothing is set then set
210
349
  replica_config.fixed = 1
350
+ elif replica_config.min is not None and replica_config.max is None:
351
+ replica_config.max = replica_config.min
211
352
  return
212
353
 
213
354
  @staticmethod
214
355
  def validate(replica_config: "ReplicaConfig"):
215
- # TODO: Have a better validation story.
216
356
  both_min_max_set = (
217
357
  replica_config.min is not None and replica_config.max is not None
218
358
  )
@@ -226,17 +366,17 @@ class ReplicaConfig(metaclass=ConfigMeta):
226
366
  def _greater_than_equals_zero(x):
227
367
  return x is not None and x >= 0
228
368
 
229
- if both_min_max_set and replica_config.min > replica_config.max:
369
+ if both_min_max_set and replica_config.min > replica_config.max: # type: ignore
230
370
  raise ConfigValidationFailedException(
231
371
  field_name="min",
232
- field_info=replica_config._get_field("min"),
372
+ field_info=replica_config._get_field("min"), # type: ignore
233
373
  current_value=replica_config.min,
234
374
  message="Min replicas cannot be greater than max replicas",
235
375
  )
236
376
  if fixed_set and any_min_max_set:
237
377
  raise ConfigValidationFailedException(
238
378
  field_name="fixed",
239
- field_info=replica_config._get_field("fixed"),
379
+ field_info=replica_config._get_field("fixed"), # type: ignore
240
380
  current_value=replica_config.fixed,
241
381
  message="Fixed replicas cannot be set when min or max replicas are set",
242
382
  )
@@ -244,15 +384,15 @@ class ReplicaConfig(metaclass=ConfigMeta):
244
384
  if max_is_set and not min_is_set:
245
385
  raise ConfigValidationFailedException(
246
386
  field_name="min",
247
- field_info=replica_config._get_field("min"),
387
+ field_info=replica_config._get_field("min"), # type: ignore
248
388
  current_value=replica_config.min,
249
389
  message="If max replicas is set then min replicas must be set too.",
250
390
  )
251
391
 
252
- if fixed_set and replica_config.fixed < 0:
392
+ if fixed_set and replica_config.fixed < 0: # type: ignore
253
393
  raise ConfigValidationFailedException(
254
394
  field_name="fixed",
255
- field_info=replica_config._get_field("fixed"),
395
+ field_info=replica_config._get_field("fixed"), # type: ignore
256
396
  current_value=replica_config.fixed,
257
397
  message="Fixed replicas cannot be less than 0",
258
398
  )
@@ -260,7 +400,7 @@ class ReplicaConfig(metaclass=ConfigMeta):
260
400
  if min_is_set and not _greater_than_equals_zero(replica_config.min):
261
401
  raise ConfigValidationFailedException(
262
402
  field_name="min",
263
- field_info=replica_config._get_field("min"),
403
+ field_info=replica_config._get_field("min"), # type: ignore
264
404
  current_value=replica_config.min,
265
405
  message="Min replicas cannot be less than 0",
266
406
  )
@@ -268,7 +408,7 @@ class ReplicaConfig(metaclass=ConfigMeta):
268
408
  if max_is_set and not _greater_than_equals_zero(replica_config.max):
269
409
  raise ConfigValidationFailedException(
270
410
  field_name="max",
271
- field_info=replica_config._get_field("max"),
411
+ field_info=replica_config._get_field("max"), # type: ignore
272
412
  current_value=replica_config.max,
273
413
  message="Max replicas cannot be less than 0",
274
414
  )
@@ -346,7 +486,7 @@ class DependencyConfig(metaclass=ConfigMeta):
346
486
  ):
347
487
  raise ConfigValidationFailedException(
348
488
  field_name="from_requirements_file",
349
- field_info=dependency_config._get_field("from_requirements_file"),
489
+ field_info=dependency_config._get_field("from_requirements_file"), # type: ignore
350
490
  current_value=dependency_config.from_requirements_file,
351
491
  message="Cannot set from_requirements_file and from_pyproject_toml at the same time",
352
492
  )
@@ -358,7 +498,7 @@ class DependencyConfig(metaclass=ConfigMeta):
358
498
  ):
359
499
  raise ConfigValidationFailedException(
360
500
  field_name="pypi" if dependency_config.pypi else "conda",
361
- field_info=dependency_config._get_field(
501
+ field_info=dependency_config._get_field( # type: ignore
362
502
  "pypi" if dependency_config.pypi else "conda"
363
503
  ),
364
504
  current_value=dependency_config.pypi or dependency_config.conda,
@@ -395,27 +535,17 @@ class BasicAppValidations:
395
535
  def name(name):
396
536
  if name is None:
397
537
  return True
398
- if len(name) > 128:
399
- raise ConfigValidationFailedException(
400
- field_name="name",
401
- field_info=CoreConfig._get_field(CoreConfig, "name"),
402
- current_value=name,
403
- message="Name cannot be longer than 128 characters",
404
- )
405
- return True
538
+ regex = r"^[a-z0-9-]+$" # Only allow lowercase letters, numbers, and hyphens
539
+ validator = BasicValidations(CoreConfig, "name")
540
+ return validator.length_validation(20, name) and validator.regex_validation(
541
+ regex, name
542
+ )
406
543
 
407
544
  @staticmethod
408
545
  def port(port):
409
546
  if port is None:
410
547
  return True
411
- if port < 1 or port > 65535:
412
- raise ConfigValidationFailedException(
413
- field_name="port",
414
- field_info=CoreConfig._get_field(CoreConfig, "port"),
415
- current_value=port,
416
- message="Port must be between 1 and 65535",
417
- )
418
- return True
548
+ return BasicValidations(CoreConfig, "port").range_validation(1, 65535, port)
419
549
 
420
550
  @staticmethod
421
551
  def tags(tags):
@@ -424,7 +554,7 @@ class BasicAppValidations:
424
554
  if not all(isinstance(tag, dict) and len(tag) == 1 for tag in tags):
425
555
  raise ConfigValidationFailedException(
426
556
  field_name="tags",
427
- field_info=CoreConfig._get_field(CoreConfig, "tags"),
557
+ field_info=CoreConfig._get_field(CoreConfig, "tags"), # type: ignore
428
558
  current_value=tags,
429
559
  message="Tags must be a list of dictionaries with one key. Currently they are set to %s "
430
560
  % (str(tags)),
@@ -439,9 +569,10 @@ class BasicAppValidations:
439
569
  if not isinstance(secrets, list):
440
570
  raise ConfigValidationFailedException(
441
571
  field_name="secrets",
442
- field_info=CoreConfig._get_field(CoreConfig, "secrets"),
572
+ field_info=CoreConfig._get_field(CoreConfig, "secrets"), # type: ignore
443
573
  current_value=secrets,
444
- message="Secrets must be a list of strings",
574
+ message="Secrets must be a list of strings. Currently they are set to %s "
575
+ % (str(secrets)),
445
576
  )
446
577
  from ..validations import secrets_validator
447
578
 
@@ -450,7 +581,7 @@ class BasicAppValidations:
450
581
  except Exception as e:
451
582
  raise ConfigValidationFailedException(
452
583
  field_name="secrets",
453
- field_info=CoreConfig._get_field(CoreConfig, "secrets"),
584
+ field_info=CoreConfig._get_field(CoreConfig, "secrets"), # type: ignore
454
585
  current_value=secrets,
455
586
  message=f"Secrets validation failed, {e}",
456
587
  )
@@ -460,14 +591,9 @@ class BasicAppValidations:
460
591
  def persistence(persistence):
461
592
  if persistence is None:
462
593
  return True
463
- if persistence not in ["none", "postgres"]:
464
- raise ConfigValidationFailedException(
465
- field_name="persistence",
466
- field_info=CoreConfig._get_field(CoreConfig, "persistence"),
467
- current_value=persistence,
468
- message=f"Persistence must be one of: {['none', 'postgres']}",
469
- )
470
- return True
594
+ return BasicValidations(CoreConfig, "persistence").enum_validation(
595
+ ["none", "postgres"], persistence
596
+ )
471
597
 
472
598
 
473
599
  class CoreConfig(metaclass=ConfigMeta):
@@ -787,7 +913,7 @@ How to read this schema:
787
913
  merged_config = CoreConfig()
788
914
 
789
915
  # Process each field according to its behavior
790
- for field_name, field_info in CoreConfig._fields.items():
916
+ for field_name, field_info in CoreConfig._fields.items(): # type: ignore
791
917
  base_value = getattr(base_config, field_name, None)
792
918
  override_value = getattr(override_config, field_name, None)
793
919
 
@@ -852,7 +978,7 @@ How to read this schema:
852
978
  for v in value:
853
979
  _env_dict.update(v)
854
980
  return _env_dict
855
- if type(value) == tuple:
981
+ if type(value) == tuple or type(value) == list:
856
982
  obj = list(x for x in source_data[key])
857
983
  if len(obj) == 0:
858
984
  return None # Dont return Empty Lists so that we can set Nones
@@ -94,24 +94,24 @@ properties:
94
94
  required: []
95
95
  properties:
96
96
  cpu:
97
- description: CPU resource request and limit.
97
+ description: CPU resource request and limit. (validation applied)
98
98
  type: string
99
99
  default: '1'
100
100
  example: 500m
101
101
  mutation_behavior: union
102
102
  memory:
103
- description: Memory resource request and limit.
103
+ description: Memory resource request and limit. (validation applied)
104
104
  type: string
105
105
  default: 4Gi
106
106
  example: 512Mi
107
107
  mutation_behavior: union
108
108
  gpu:
109
- description: GPU resource request and limit.
109
+ description: GPU resource request and limit. (validation applied)
110
110
  type: string
111
111
  example: '1'
112
112
  mutation_behavior: union
113
113
  disk:
114
- description: Storage resource request and limit.
114
+ description: Storage resource request and limit. (validation applied)
115
115
  type: string
116
116
  default: 20Gi
117
117
  example: 1Gi
@@ -0,0 +1,132 @@
1
+ from .config import TypedCoreConfig
2
+
3
+ from ._state_machine import DEPLOYMENT_READY_CONDITIONS
4
+ from .app_config import AppConfig, AppConfigError
5
+ from .capsule import CapsuleDeployer, list_and_filter_capsules
6
+ from functools import partial
7
+ import sys
8
+ from typing import Type
9
+
10
+
11
+ class AppDeployer(TypedCoreConfig):
12
+ """ """
13
+
14
+ __init__ = TypedCoreConfig.__init__
15
+
16
+ _app_config: AppConfig
17
+
18
+ _state = {}
19
+
20
+ @property
21
+ def app_config(self) -> AppConfig:
22
+ if not hasattr(self, "_app_config"):
23
+ self._app_config = AppConfig(self._config)
24
+ return self._app_config
25
+
26
+ # Things that need to be set before deploy
27
+ @classmethod
28
+ def _set_state(
29
+ cls,
30
+ perimeter: str,
31
+ api_url: str,
32
+ code_package_url: str = None,
33
+ code_package_key: str = None,
34
+ name: str = None,
35
+ image: str = None,
36
+ ):
37
+ cls._state["perimeter"] = perimeter
38
+ cls._state["api_url"] = api_url
39
+ cls._state["code_package_url"] = code_package_url
40
+ cls._state["code_package_key"] = code_package_key
41
+ cls._state["name"] = name
42
+ cls._state["image"] = image
43
+
44
+ def deploy(
45
+ self,
46
+ readiness_condition=DEPLOYMENT_READY_CONDITIONS.ATLEAST_ONE_RUNNING,
47
+ max_wait_time=600,
48
+ readiness_wait_time=10,
49
+ logger_fn=partial(print, file=sys.stderr),
50
+ status_file=None,
51
+ no_loader=False,
52
+ **kwargs,
53
+ ):
54
+ # Name setting from top level if none is set in the code
55
+ if self.app_config._core_config.name is None:
56
+ self.app_config._core_config.name = self._state["name"]
57
+
58
+ self.app_config.commit()
59
+
60
+ # Set any state that might have been passed down from the top level
61
+ for k, v in self._state.items():
62
+ if self.app_config.get_state(k) is None:
63
+ self.app_config.set_state(k, v)
64
+
65
+ capsule = CapsuleDeployer(
66
+ self.app_config,
67
+ self._state["api_url"],
68
+ create_timeout=max_wait_time,
69
+ debug_dir=None,
70
+ success_terminal_state_condition=readiness_condition,
71
+ readiness_wait_time=readiness_wait_time,
72
+ logger_fn=logger_fn,
73
+ )
74
+
75
+ currently_present_capsules = list_and_filter_capsules(
76
+ capsule.capsule_api,
77
+ None,
78
+ None,
79
+ capsule.name,
80
+ None,
81
+ None,
82
+ None,
83
+ )
84
+
85
+ force_upgrade = self.app_config.get_state("force_upgrade", False)
86
+
87
+ if len(currently_present_capsules) > 0:
88
+ # Only update the capsule if there is no upgrade in progress
89
+ # Only update a "already updating" capsule if the `--force-upgrade` flag is provided.
90
+ _curr_cap = currently_present_capsules[0]
91
+ this_capsule_is_being_updated = _curr_cap.get("status", {}).get(
92
+ "updateInProgress", False
93
+ )
94
+
95
+ if this_capsule_is_being_updated and not force_upgrade:
96
+ _upgrader = _curr_cap.get("metadata", {}).get("lastModifiedBy", None)
97
+ message = f"{capsule.capsule_type} is currently being upgraded"
98
+ if _upgrader:
99
+ message = (
100
+ f"{capsule.capsule_type} is currently being upgraded. Upgrade was launched by {_upgrader}. "
101
+ "If you wish to force upgrade, you can do so by providing the `--force-upgrade` flag."
102
+ )
103
+ raise AppConfigError(message)
104
+
105
+ logger_fn(
106
+ f"🚀 {'' if not force_upgrade else 'Force'} Upgrading {capsule.capsule_type.lower()} `{capsule.name}`....",
107
+ )
108
+ else:
109
+ logger_fn(
110
+ f"🚀 Deploying {capsule.capsule_type.lower()} `{capsule.name}`....",
111
+ )
112
+
113
+ capsule.create()
114
+ final_status = capsule.wait_for_terminal_state()
115
+ return final_status
116
+
117
+
118
+ class apps:
119
+
120
+ _name_prefix = None
121
+
122
+ @classmethod
123
+ def set_name_prefix(cls, name_prefix: str):
124
+ cls._name_prefix = name_prefix
125
+
126
+ @property
127
+ def name_prefix(self) -> str:
128
+ return self._name_prefix
129
+
130
+ @property
131
+ def Deployer(self) -> Type[AppDeployer]:
132
+ return AppDeployer
@@ -41,5 +41,47 @@ class PerimeterExtractor:
41
41
 
42
42
  @classmethod
43
43
  def during_metaflow_execution(cls) -> str:
44
- # TODO: implement this
45
- return ""
44
+ from metaflow.metaflow_config_funcs import init_config
45
+
46
+ clean_url = (
47
+ lambda url: f"https://{url}".rstrip("/")
48
+ if not url.startswith("https://")
49
+ else url
50
+ )
51
+
52
+ config = init_config()
53
+ api_server, perimeter, integrations_url = None, None, None
54
+ perimeter = config.get(
55
+ "OBP_PERIMETER", os.environ.get("OBP_PERIMETER", perimeter)
56
+ )
57
+ if perimeter is None:
58
+ raise RuntimeError(
59
+ "Perimeter not found in metaflow config or environment variables"
60
+ )
61
+
62
+ api_server = config.get(
63
+ "OBP_API_SERVER", os.environ.get("OBP_API_SERVER", api_server)
64
+ )
65
+
66
+ if api_server is not None and not api_server.startswith("https://"):
67
+ api_server = clean_url(api_server)
68
+
69
+ if api_server is not None:
70
+ return perimeter, api_server
71
+
72
+ integrations_url = config.get(
73
+ "OBP_INTEGRATIONS_URL", os.environ.get("OBP_INTEGRATIONS_URL", None)
74
+ )
75
+
76
+ if integrations_url is not None and not integrations_url.startswith("https://"):
77
+ integrations_url = clean_url(integrations_url)
78
+
79
+ if integrations_url is not None:
80
+ api_server = integrations_url.rstrip("/integrations")
81
+
82
+ if api_server is None:
83
+ raise RuntimeError(
84
+ "API server not found in metaflow config or environment variables"
85
+ )
86
+
87
+ return perimeter, api_server
@@ -76,3 +76,4 @@ from ..plugins.snowflake import Snowflake
76
76
  from ..plugins.checkpoint_datastores import nebius_checkpoints, coreweave_checkpoints
77
77
  from ..plugins.aws import assume_role
78
78
  from . import ob_internal
79
+ from .ob_internal import AppDeployer
@@ -1,3 +1,4 @@
1
1
  from ..plugins.kubernetes.pod_killer import PodKiller
2
2
  from ..plugins.fast_bakery.baker import bake_image
3
3
  from ..plugins.apps import core as app_core
4
+ from ..plugins.apps.core import AppDeployer
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ob-metaflow-extensions
3
- Version: 1.1.175rc2
3
+ Version: 1.1.175rc4
4
4
  Summary: Outerbounds Platform Extensions for Metaflow
5
5
  Author: Outerbounds, Inc.
6
6
  License: Commercial