ob-metaflow-extensions 1.1.174__py2.py3-none-any.whl → 1.1.175rc1__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 (31) hide show
  1. metaflow_extensions/outerbounds/plugins/__init__.py +1 -0
  2. metaflow_extensions/outerbounds/plugins/apps/app_cli.py +3 -0
  3. metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +1 -0
  4. metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py +470 -0
  5. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/__init__.py +0 -0
  6. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/__init__.py +4 -0
  7. metaflow_extensions/outerbounds/plugins/apps/core/_vendor/spinner/spinners.py +478 -0
  8. metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +1521 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +325 -0
  10. metaflow_extensions/outerbounds/plugins/apps/core/artifacts.py +0 -0
  11. metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +859 -0
  12. metaflow_extensions/outerbounds/plugins/apps/core/cli_to_config.py +99 -0
  13. metaflow_extensions/outerbounds/plugins/apps/core/click_importer.py +24 -0
  14. metaflow_extensions/outerbounds/plugins/apps/core/code_package/__init__.py +3 -0
  15. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +610 -0
  16. metaflow_extensions/outerbounds/plugins/apps/core/code_package/examples.py +125 -0
  17. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +269 -0
  18. metaflow_extensions/outerbounds/plugins/apps/core/config_schema_autogen.json +336 -0
  19. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +115 -0
  20. metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +0 -0
  21. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +110 -0
  22. metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +45 -0
  23. metaflow_extensions/outerbounds/plugins/apps/core/secrets.py +164 -0
  24. metaflow_extensions/outerbounds/plugins/apps/core/utils.py +233 -0
  25. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +22 -0
  26. metaflow_extensions/outerbounds/plugins/fast_bakery/baker.py +110 -0
  27. metaflow_extensions/outerbounds/toplevel/ob_internal.py +2 -0
  28. {ob_metaflow_extensions-1.1.174.dist-info → ob_metaflow_extensions-1.1.175rc1.dist-info}/METADATA +1 -1
  29. {ob_metaflow_extensions-1.1.174.dist-info → ob_metaflow_extensions-1.1.175rc1.dist-info}/RECORD +31 -6
  30. {ob_metaflow_extensions-1.1.174.dist-info → ob_metaflow_extensions-1.1.175rc1.dist-info}/WHEEL +0 -0
  31. {ob_metaflow_extensions-1.1.174.dist-info → ob_metaflow_extensions-1.1.175rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,325 @@
1
+ import json
2
+ import os
3
+
4
+ from typing import Dict, Any
5
+ from .cli_to_config import build_config_from_options
6
+
7
+ CODE_PACKAGE_PREFIX = "mf.obp-apps"
8
+
9
+ CAPSULE_DEBUG = os.environ.get("OUTERBOUNDS_CAPSULE_DEBUG", False)
10
+
11
+
12
+ class classproperty(property):
13
+ def __get__(self, owner_self, owner_cls):
14
+ return self.fget(owner_cls)
15
+
16
+
17
+ class AppConfigError(Exception):
18
+ """Exception raised when app configuration is invalid."""
19
+
20
+ pass
21
+
22
+
23
+ def _try_loading_yaml(file):
24
+ try:
25
+ import yaml
26
+
27
+ return yaml.safe_load(file)
28
+ except ImportError:
29
+ pass
30
+
31
+ try:
32
+ from outerbounds._vendor import yaml
33
+
34
+ return yaml.safe_load(file)
35
+ except ImportError:
36
+ pass
37
+ return None
38
+
39
+
40
+ class AuthType:
41
+ BROWSER = "Browser"
42
+ API = "API"
43
+
44
+ @classmethod
45
+ def enums(cls):
46
+ return [cls.BROWSER, cls.API]
47
+
48
+ @classproperty
49
+ def default(cls):
50
+ return cls.BROWSER
51
+
52
+
53
+ class AppConfig:
54
+ """Class representing an Outerbounds App configuration."""
55
+
56
+ def __init__(self, config_dict: Dict[str, Any]):
57
+ """Initialize configuration from a dictionary."""
58
+ self.config = config_dict or {}
59
+ self.schema = self._load_schema()
60
+ self._final_state: Dict[str, Any] = {}
61
+
62
+ def set_state(self, key, value):
63
+ self._final_state[key] = value
64
+ return self
65
+
66
+ def get_state(self, key, default=None):
67
+ return self._final_state.get(key, self.config.get(key, default))
68
+
69
+ def dump_state(self):
70
+ x = {k: v for k, v in self.config.items()}
71
+ for k, v in self._final_state.items():
72
+ x[k] = v
73
+ return x
74
+
75
+ @staticmethod
76
+ def _load_schema():
77
+ """Load the configuration schema from the YAML file."""
78
+ schema_path = os.path.join(os.path.dirname(__file__), "config_schema.yaml")
79
+ auto_gen_schema_path = os.path.join(
80
+ os.path.dirname(__file__), "config_schema_autogen.json"
81
+ )
82
+
83
+ with open(schema_path, "r") as f:
84
+ schema = _try_loading_yaml(f)
85
+ if schema is None:
86
+ with open(auto_gen_schema_path, "r") as f:
87
+ schema = json.load(f)
88
+ return schema
89
+
90
+ def get(self, key: str, default: Any = None) -> Any:
91
+ """Get a configuration value by key."""
92
+ return self.config.get(key, default)
93
+
94
+ def validate(self) -> None:
95
+ """Validate the configuration against the schema."""
96
+ self._validate_required_fields()
97
+ self._validate_field_types()
98
+ self._validate_field_constraints()
99
+
100
+ def set_deploy_defaults(self, packaging_directory: str) -> None:
101
+ """Set default values for fields that are not provided."""
102
+ if not self.config.get("auth"):
103
+ self.config["auth"] = {}
104
+ if not self.config["auth"].get("public"):
105
+ self.config["auth"]["public"] = True
106
+ if not self.config["auth"].get("type"):
107
+ self.config["auth"]["type"] = AuthType.BROWSER
108
+
109
+ if not self.config.get("health_check"):
110
+ self.config["health_check"] = {}
111
+ if not self.config["health_check"].get("enabled"):
112
+ self.config["health_check"]["enabled"] = False
113
+
114
+ if not self.config.get("resources"):
115
+ self.config["resources"] = {}
116
+ if not self.config["resources"].get("cpu"):
117
+ self.config["resources"]["cpu"] = 1
118
+ if not self.config["resources"].get("memory"):
119
+ self.config["resources"]["memory"] = "4096Mi"
120
+ if not self.config["resources"].get("disk"):
121
+ self.config["resources"]["disk"] = "20Gi"
122
+
123
+ if not self.config.get("replicas", None):
124
+ self.config["replicas"] = {
125
+ "min": 1,
126
+ "max": 1,
127
+ }
128
+ else:
129
+ # TODO: The replicas related code blocks will change as we add autoscaling
130
+ # configurations
131
+ max_is_set = self.config["replicas"].get("max", None) is not None
132
+ min_is_set = self.config["replicas"].get("min", None) is not None
133
+ if max_is_set and not min_is_set:
134
+ # If users want to set 0 replicas for min,
135
+ # then they need explicitly specify min to 0
136
+ self.config["replicas"]["min"] = self.config["replicas"]["max"]
137
+ if min_is_set and not max_is_set:
138
+ # In the situations where we dont have min/max replicas, we can
139
+ # set max to min.
140
+ self.config["replicas"]["max"] = self.config["replicas"].get("min")
141
+
142
+ def _validate_required_fields(self) -> None:
143
+ """Validate that all required fields are present."""
144
+ required_fields = self.schema.get("required", [])
145
+ for field in required_fields:
146
+ if field not in self.config:
147
+ raise AppConfigError(
148
+ f"Required field '{field}' is missing from the configuration."
149
+ )
150
+
151
+ def _validate_field_types(self) -> None:
152
+ """Validate that fields have correct types."""
153
+ properties = self.schema.get("properties", {})
154
+
155
+ for field, value in self.config.items():
156
+ if field not in properties:
157
+ raise AppConfigError(f"Unknown field '{field}' in configuration.")
158
+
159
+ field_schema = properties[field]
160
+ field_type = field_schema.get("type")
161
+
162
+ if field_type == "string" and not isinstance(value, str):
163
+ raise AppConfigError(f"Field '{field}' must be a string.")
164
+
165
+ elif field_type == "integer" and not isinstance(value, int):
166
+ raise AppConfigError(f"Field '{field}' must be an integer.")
167
+
168
+ elif field_type == "boolean" and not isinstance(value, bool):
169
+ raise AppConfigError(f"Field '{field}' must be a boolean.")
170
+
171
+ elif field_type == "array" and not isinstance(value, list):
172
+ raise AppConfigError(f"Field '{field}' must be an array.")
173
+
174
+ elif field_type == "object" and not isinstance(value, dict):
175
+ raise AppConfigError(f"Field '{field}' must be an object.")
176
+
177
+ def _validate_field_constraints(self) -> None:
178
+ """Validate field-specific constraints."""
179
+ properties = self.schema.get("properties", {})
180
+
181
+ # Validate name
182
+ if "name" in self.config:
183
+ name = self.config["name"]
184
+ max_length = properties["name"].get("maxLength", 20)
185
+ if len(name) > max_length:
186
+ raise AppConfigError(
187
+ f"App name '{name}' exceeds maximum length of {max_length} characters."
188
+ )
189
+
190
+ # Validate port
191
+ if "port" in self.config:
192
+ port = self.config["port"]
193
+ min_port = properties["port"].get("minimum", 1)
194
+ max_port = properties["port"].get("maximum", 65535)
195
+ if port < min_port or port > max_port:
196
+ raise AppConfigError(
197
+ f"Port number {port} is outside valid range ({min_port}-{max_port})."
198
+ )
199
+
200
+ # Validate dependencies (only one type allowed)
201
+ if "dependencies" in self.config:
202
+ deps = self.config["dependencies"]
203
+ if not isinstance(deps, dict):
204
+ raise AppConfigError("Dependencies must be an object.")
205
+
206
+ valid_dep_types = [
207
+ "from_requirements_file",
208
+ "from_pyproject_toml",
209
+ ]
210
+
211
+ found_types = [dep_type for dep_type in valid_dep_types if dep_type in deps]
212
+
213
+ if len(found_types) > 1:
214
+ raise AppConfigError(
215
+ f"You can only specify one mode of specifying dependencies. You have specified : {found_types} . Please only set one."
216
+ )
217
+
218
+ # Validate that each tag has exactly one key
219
+ if "tags" in self.config:
220
+ tags = self.config["tags"]
221
+ for tag in tags:
222
+ if not isinstance(tag, dict):
223
+ raise AppConfigError(
224
+ "Each tag must be a dictionary. %s is of type %s"
225
+ % (str(tag), type(tag))
226
+ )
227
+ if len(tag.keys()) != 1:
228
+ raise AppConfigError(
229
+ "Each tag must have exactly one key-value pair. Tag %s has %d key-value pairs."
230
+ % (str(tag), len(tag.keys()))
231
+ )
232
+ if "replicas" in self.config:
233
+ replicas = self.config["replicas"]
234
+ if not isinstance(replicas, dict):
235
+ raise AppConfigError("Replicas must be an object.")
236
+ max_is_set = self.config["replicas"].get("max", None) is not None
237
+ min_is_set = self.config["replicas"].get("min", None) is not None
238
+ if max_is_set and min_is_set:
239
+ if replicas.get("min") > replicas.get("max"):
240
+ raise AppConfigError(
241
+ "Min replicas must be less than equals max replicas. %s > %s"
242
+ % (replicas.get("min"), replicas.get("max"))
243
+ )
244
+
245
+ def to_dict(self) -> Dict[str, Any]:
246
+ """Return the configuration as a dictionary."""
247
+ return self.config
248
+
249
+ def to_yaml(self) -> str:
250
+ """Return the configuration as a YAML string."""
251
+ return self.to_json()
252
+
253
+ def to_json(self) -> str:
254
+ """Return the configuration as a JSON string."""
255
+ return json.dumps(self.config, indent=2)
256
+
257
+ @classmethod
258
+ def from_file(cls, file_path: str) -> "AppConfig":
259
+ """Create a configuration from a file."""
260
+ if not os.path.exists(file_path):
261
+ raise AppConfigError(f"Configuration file '{file_path}' does not exist.")
262
+
263
+ with open(file_path, "r") as f:
264
+ try:
265
+ config_dict = _try_loading_yaml(f)
266
+ if config_dict is None:
267
+ config_dict = json.load(f)
268
+ except json.JSONDecodeError as e:
269
+ raise AppConfigError(
270
+ "The PyYAML package is not available as a dependency and JSON parsing of the configuration file also failed %s: \n%s"
271
+ % (file_path, str(e))
272
+ )
273
+ except Exception as e:
274
+ raise AppConfigError(f"Failed to parse configuration file: {e}")
275
+
276
+ return cls(config_dict)
277
+
278
+ def update_from_cli_options(self, options):
279
+ """
280
+ Update configuration from CLI options using the same logic as build_config_from_options.
281
+ This ensures consistent handling of CLI options whether they come from a config file
282
+ or direct CLI input.
283
+ """
284
+ cli_config = build_config_from_options(options)
285
+
286
+ # Process each field using allow_union property
287
+ for key, value in cli_config.items():
288
+ if key in self.schema.get("properties", {}):
289
+ self._update_field(key, value)
290
+
291
+ return self
292
+
293
+ def _update_field(self, field_name, new_value):
294
+ """Update a field based on its allow_union property."""
295
+ properties = self.schema.get("properties", {})
296
+
297
+ # Skip if field doesn't exist in schema
298
+ if field_name not in properties:
299
+ return
300
+
301
+ field_schema = properties[field_name]
302
+ allow_union = field_schema.get("allow_union", False)
303
+
304
+ # If field doesn't exist in config, just set it
305
+ if field_name not in self.config:
306
+ self.config[field_name] = new_value
307
+ return
308
+
309
+ # If allow_union is True, merge values based on type
310
+ if allow_union:
311
+ current_value = self.config[field_name]
312
+
313
+ if isinstance(current_value, list) and isinstance(new_value, list):
314
+ # For lists, append new items
315
+ self.config[field_name].extend(new_value)
316
+ elif isinstance(current_value, dict) and isinstance(new_value, dict):
317
+ # For dicts, update with new values
318
+ self.config[field_name].update(new_value)
319
+ else:
320
+ # For other types, replace with new value
321
+ self.config[field_name] = new_value
322
+ else:
323
+ raise AppConfigError(
324
+ f"Field '{field_name}' does not allow union. Current value: {self.config[field_name]}, new value: {new_value}"
325
+ )