ob-metaflow-extensions 1.1.175rc1__py2.py3-none-any.whl → 1.1.175rc2__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 (18) hide show
  1. metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +42 -374
  2. metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +45 -225
  3. metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +16 -15
  4. metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +11 -0
  5. metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
  6. metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +768 -0
  7. metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +285 -0
  8. metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +870 -0
  9. metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +217 -211
  10. metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +3 -3
  11. metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +4 -36
  12. metaflow_extensions/outerbounds/plugins/apps/core/validations.py +4 -9
  13. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/METADATA +1 -1
  14. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/RECORD +16 -13
  15. metaflow_extensions/outerbounds/plugins/apps/core/cli_to_config.py +0 -99
  16. metaflow_extensions/outerbounds/plugins/apps/core/config_schema_autogen.json +0 -336
  17. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/WHEEL +0 -0
  18. {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,12 @@ import json
2
2
  import os
3
3
 
4
4
  from typing import Dict, Any
5
- from .cli_to_config import build_config_from_options
5
+ from .config import (
6
+ CoreConfig,
7
+ MergingNotAllowedFieldsException,
8
+ ConfigValidationFailedException,
9
+ RequiredFieldMissingException,
10
+ )
6
11
 
7
12
  CODE_PACKAGE_PREFIX = "mf.obp-apps"
8
13
 
@@ -53,18 +58,18 @@ class AuthType:
53
58
  class AppConfig:
54
59
  """Class representing an Outerbounds App configuration."""
55
60
 
56
- def __init__(self, config_dict: Dict[str, Any]):
61
+ def __init__(self, core_config: CoreConfig):
57
62
  """Initialize configuration from a dictionary."""
58
- self.config = config_dict or {}
59
- self.schema = self._load_schema()
63
+ self._core_config = core_config
60
64
  self._final_state: Dict[str, Any] = {}
65
+ self.config = {}
61
66
 
62
67
  def set_state(self, key, value):
63
68
  self._final_state[key] = value
64
69
  return self
65
70
 
66
71
  def get_state(self, key, default=None):
67
- return self._final_state.get(key, self.config.get(key, default))
72
+ return self._final_state.get(key, self.get(key, default))
68
73
 
69
74
  def dump_state(self):
70
75
  x = {k: v for k, v in self.config.items()}
@@ -72,187 +77,35 @@ class AppConfig:
72
77
  x[k] = v
73
78
  return x
74
79
 
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
80
+ def commit(self):
81
+ try:
82
+ self._core_config.commit()
83
+ self.config = self._core_config.to_dict()
84
+ except ConfigValidationFailedException as e:
85
+ raise AppConfigError(
86
+ "The configuration is invalid. \n\tException: %s" % (e.message)
87
+ )
88
+ except RequiredFieldMissingException as e:
89
+ raise AppConfigError(
90
+ "The configuration is missing the following required fields: %s. \n\tException: %s"
91
+ % (", ".join(e.field_name), e.message)
92
+ )
89
93
 
90
94
  def get(self, key: str, default: Any = None) -> Any:
91
95
  """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.")
96
+ config_value = self.config.get(key, default)
97
+ if config_value is None:
98
+ return default
99
+ return config_value
164
100
 
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
101
+ def to_json(self):
102
+ return json.dumps(self.config, indent=2)
248
103
 
249
- def to_yaml(self) -> str:
250
- """Return the configuration as a YAML string."""
104
+ def to_yaml(self):
251
105
  return self.to_json()
252
106
 
253
- def to_json(self) -> str:
254
- """Return the configuration as a JSON string."""
255
- return json.dumps(self.config, indent=2)
107
+ def to_dict(self):
108
+ return self.config
256
109
 
257
110
  @classmethod
258
111
  def from_file(cls, file_path: str) -> "AppConfig":
@@ -273,53 +126,20 @@ class AppConfig:
273
126
  except Exception as e:
274
127
  raise AppConfigError(f"Failed to parse configuration file: {e}")
275
128
 
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)
129
+ return cls(CoreConfig.from_dict(config_dict))
290
130
 
291
- return self
131
+ @classmethod
132
+ def from_cli(cls, options: Dict[str, Any]):
133
+ return cls(CoreConfig.from_cli(options))
292
134
 
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:
135
+ def update_from_cli_options(self, options):
136
+ cli_options_config = CoreConfig.from_cli(options)
137
+ try:
138
+ self._core_config = CoreConfig.merge_configs(
139
+ self._core_config, cli_options_config
140
+ )
141
+ except MergingNotAllowedFieldsException as e:
323
142
  raise AppConfigError(
324
- f"Field '{field_name}' does not allow union. Current value: {self.config[field_name]}, new value: {new_value}"
143
+ "CLI Overrides are not allowed for the following fields: %s. \n\tException: %s"
144
+ % (", ".join(e.field_name), e.message)
325
145
  )
@@ -15,6 +15,21 @@ from metaflow.metaflow_config import (
15
15
  DATASTORE_SYSROOT_LOCAL,
16
16
  )
17
17
 
18
+ DEFAULT_FILE_SUFFIXES = [
19
+ ".py",
20
+ ".txt",
21
+ ".yaml",
22
+ ".yml",
23
+ ".json",
24
+ ".html",
25
+ ".css",
26
+ ".js",
27
+ ".jsx",
28
+ ".ts",
29
+ ".tsx",
30
+ ".md",
31
+ ".rst",
32
+ ]
18
33
  # Default prefix for code packages in content addressed store
19
34
  CODE_PACKAGE_PREFIX = "apps-code-packages"
20
35
 
@@ -155,21 +170,7 @@ class CodePackager:
155
170
  """
156
171
  # Prepare default values
157
172
  _paths_to_include = paths_to_include or []
158
- _file_suffixes = file_suffixes or [
159
- ".py",
160
- ".txt",
161
- ".yaml",
162
- ".yml",
163
- ".json",
164
- ".html",
165
- ".css",
166
- ".js",
167
- ".jsx",
168
- ".ts",
169
- ".tsx",
170
- ".md",
171
- ".rst",
172
- ]
173
+ _file_suffixes = file_suffixes or DEFAULT_FILE_SUFFIXES
173
174
  _metadata = metadata or {}
174
175
 
175
176
  # If no package_create_fn provided, use default_package_create
@@ -0,0 +1,11 @@
1
+ from .unified_config import CoreConfig
2
+ from .cli_generator import auto_cli_options
3
+ from .config_utils import (
4
+ PureStringKVPairType,
5
+ JsonFriendlyKeyValuePairType,
6
+ CommaSeparatedListType,
7
+ MergingNotAllowedFieldsException,
8
+ ConfigValidationFailedException,
9
+ RequiredFieldMissingException,
10
+ )
11
+ from . import schema_export
@@ -0,0 +1,161 @@
1
+ """
2
+ CLI Generator for Unified Configuration System
3
+
4
+ This module automatically generates Click CLI options from the CoreConfig,
5
+ eliminating the need for manual CLI option definitions and ensuring consistency
6
+ between configuration structure and CLI interface.
7
+
8
+ It also provides machinery for merging configurations from different sources
9
+ (CLI options, config files) with proper precedence and behavior handling.
10
+ """
11
+
12
+ from typing import Any, List, Optional
13
+ import json
14
+
15
+ from ..click_importer import click
16
+ from .unified_config import (
17
+ CoreConfig,
18
+ CLIOption,
19
+ ConfigMeta,
20
+ )
21
+ from .config_utils import (
22
+ PureStringKVPairType,
23
+ JsonFriendlyKeyValuePairType,
24
+ CommaSeparatedListType,
25
+ )
26
+
27
+
28
+ class CLIGenerator:
29
+ """Generates Click CLI options from CoreConfig dataclass."""
30
+
31
+ def __init__(self, config_class: type = CoreConfig):
32
+ self.config_class = config_class
33
+ self._type_mapping = {
34
+ str: str,
35
+ int: int,
36
+ float: float,
37
+ bool: bool,
38
+ list: CommaSeparatedListType,
39
+ dict: JsonFriendlyKeyValuePairType,
40
+ }
41
+
42
+ def generate_options(self):
43
+ """Generate all CLI options from the configuration class."""
44
+ options = []
45
+
46
+ # Generate options for all fields automatically
47
+ options.extend(self._generate_all_options(self.config_class))
48
+
49
+ return options
50
+
51
+ def _generate_all_options(self, config_class: type):
52
+ """Generate all options from a config class. Returns a list of click.Options"""
53
+
54
+ def _options_from_cfg_cls(_config_class):
55
+ options = []
56
+ for field_name, field_info in _config_class._fields.items():
57
+ if ConfigMeta.is_instance(field_info.field_type):
58
+ _subfield_options = _options_from_cfg_cls(field_info.field_type)
59
+ options.extend(_subfield_options)
60
+ continue
61
+
62
+ cli_meta = field_info.cli_meta
63
+ if not cli_meta or cli_meta.hidden:
64
+ continue
65
+
66
+ option = self._create_option(field_name, field_info, cli_meta)
67
+ if option:
68
+ options.append(option)
69
+ return options
70
+
71
+ return _options_from_cfg_cls(config_class)
72
+
73
+ def _create_option(self, field_name: str, field_info, cli_meta: CLIOption):
74
+ """Create a Click option from field info and CLI metadata."""
75
+ # Use the cli_option_str from the CLIOption
76
+ option_str = cli_meta.cli_option_str
77
+ param_name = cli_meta.name
78
+
79
+ # Determine Click type
80
+ click_type = self._get_click_type(field_info, cli_meta)
81
+
82
+ # Build option parameters
83
+ help_text = cli_meta.help or field_info.help or f"Set {field_name}"
84
+ option_params = {
85
+ "help": help_text,
86
+ "default": cli_meta.default if cli_meta.default is not None else None,
87
+ "type": click_type,
88
+ }
89
+
90
+ # Handle multiple values
91
+ if cli_meta.multiple:
92
+ option_params["multiple"] = True
93
+
94
+ # Handle choices
95
+ if cli_meta.choices:
96
+ option_params["type"] = click.Choice(cli_meta.choices)
97
+
98
+ # Handle flags
99
+ if cli_meta.is_flag:
100
+ option_params["is_flag"] = True
101
+ option_params.pop("type", None)
102
+
103
+ # Handle special flag patterns (e.g., --public-access/--private-access)
104
+ return click.option(option_str, param_name, **option_params)
105
+
106
+ def _get_click_type(self, field_info, cli_meta: CLIOption) -> Any:
107
+ """Determine the appropriate Click type for a field."""
108
+ if cli_meta.click_type:
109
+ return cli_meta.click_type
110
+
111
+ # Get the field type
112
+ field_type = field_info.field_type
113
+
114
+ # Handle basic types
115
+ if field_type == list:
116
+ return CommaSeparatedListType
117
+ elif field_type == dict:
118
+ return JsonFriendlyKeyValuePairType
119
+ elif field_type == str:
120
+ return str
121
+ elif field_type == int:
122
+ return int
123
+ elif field_type == bool:
124
+ return bool
125
+ elif field_type == float:
126
+ return float
127
+
128
+ # Handle custom config types
129
+ if hasattr(field_type, "__name__") and field_type.__name__.endswith("Config"):
130
+ return str # Default to string for complex types
131
+
132
+ # Use type mapping
133
+ return self._type_mapping.get(field_type, str)
134
+
135
+ def create_decorator(self, command_type: str = "deploy") -> callable:
136
+ """Create a decorator that applies all CLI options to a command."""
137
+
138
+ def decorator(func):
139
+ # Apply options in reverse order since decorators are applied bottom-up
140
+ for option in reversed(self.generate_options()):
141
+ func = option(func)
142
+ return func
143
+
144
+ return decorator
145
+
146
+
147
+ def auto_cli_options(config_class: type = CoreConfig, command_type: str = "deploy"):
148
+ """
149
+ Decorator that automatically adds CLI options from CoreConfig.
150
+
151
+ Args:
152
+ command_type: Type of command (e.g., "deploy", "list", "delete")
153
+
154
+ Usage:
155
+ @auto_cli_options("deploy")
156
+ def deploy_command(**kwargs):
157
+ config = CoreConfig.from_cli(kwargs)
158
+ # ... use config
159
+ """
160
+ generator = CLIGenerator(config_class)
161
+ return generator.create_decorator(command_type)