ob-metaflow-extensions 1.1.175rc1__py2.py3-none-any.whl → 1.1.175rc3__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.
- metaflow_extensions/outerbounds/plugins/__init__.py +1 -1
- metaflow_extensions/outerbounds/plugins/apps/app_deploy_decorator.py +112 -0
- metaflow_extensions/outerbounds/plugins/apps/core/__init__.py +9 -0
- metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py +42 -374
- metaflow_extensions/outerbounds/plugins/apps/core/app_config.py +45 -225
- metaflow_extensions/outerbounds/plugins/apps/core/capsule.py +22 -6
- metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py +16 -15
- metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +12 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +828 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +285 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py +104 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py +317 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +994 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config_schema.yaml +217 -211
- metaflow_extensions/outerbounds/plugins/apps/core/dependencies.py +3 -3
- metaflow_extensions/outerbounds/plugins/apps/core/deployer.py +132 -0
- metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py +4 -36
- metaflow_extensions/outerbounds/plugins/apps/core/perimeters.py +44 -2
- metaflow_extensions/outerbounds/plugins/apps/core/validations.py +4 -9
- metaflow_extensions/outerbounds/toplevel/global_aliases_for_metaflow_package.py +1 -0
- metaflow_extensions/outerbounds/toplevel/ob_internal.py +1 -0
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/METADATA +1 -1
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/RECORD +26 -20
- metaflow_extensions/outerbounds/plugins/apps/core/cli_to_config.py +0 -99
- metaflow_extensions/outerbounds/plugins/apps/core/config_schema_autogen.json +0 -336
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.dist-info}/WHEEL +0 -0
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc3.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 .
|
|
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,
|
|
61
|
+
def __init__(self, core_config: CoreConfig):
|
|
57
62
|
"""Initialize configuration from a dictionary."""
|
|
58
|
-
self.
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return schema
|
|
80
|
+
def commit(self):
|
|
81
|
+
try:
|
|
82
|
+
self._core_config.commit()
|
|
83
|
+
self.config = self._core_config.to_dict()
|
|
84
|
+
except RequiredFieldMissingException as e:
|
|
85
|
+
raise AppConfigError(
|
|
86
|
+
"The configuration is missing the following required fields: %s. \n\tException: %s"
|
|
87
|
+
% (e.field_name, e.message)
|
|
88
|
+
)
|
|
89
|
+
except ConfigValidationFailedException as e:
|
|
90
|
+
raise AppConfigError(
|
|
91
|
+
"The configuration is invalid. \n\n\tException: %s" % (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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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)
|
|
250
|
-
"""Return the configuration as a YAML string."""
|
|
104
|
+
def to_yaml(self):
|
|
251
105
|
return self.to_json()
|
|
252
106
|
|
|
253
|
-
def
|
|
254
|
-
|
|
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
|
-
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_cli(cls, options: Dict[str, Any]):
|
|
133
|
+
return cls(CoreConfig.from_cli(options))
|
|
292
134
|
|
|
293
|
-
def
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
143
|
+
"CLI Overrides are not allowed for the following fields: %s. \n\tException: %s"
|
|
144
|
+
% (e.field_name, e.message)
|
|
325
145
|
)
|
|
@@ -246,7 +246,16 @@ class CapsuleInput:
|
|
|
246
246
|
return _return
|
|
247
247
|
|
|
248
248
|
@classmethod
|
|
249
|
-
def from_app_config(
|
|
249
|
+
def from_app_config(cls, app_config: AppConfig):
|
|
250
|
+
## Replica settings
|
|
251
|
+
replicas = app_config.get_state("replicas", {})
|
|
252
|
+
fixed, _min, _max = (
|
|
253
|
+
replicas.get("fixed"),
|
|
254
|
+
replicas.get("min"),
|
|
255
|
+
replicas.get("max"),
|
|
256
|
+
)
|
|
257
|
+
if fixed:
|
|
258
|
+
_min, _max = fixed, fixed
|
|
250
259
|
gpu_resource = app_config.get_state("resources").get("gpu")
|
|
251
260
|
resources = {}
|
|
252
261
|
shared_memory = app_config.get_state("resources").get("shared_memory")
|
|
@@ -284,16 +293,16 @@ class CapsuleInput:
|
|
|
284
293
|
**resources,
|
|
285
294
|
},
|
|
286
295
|
"autoscalingConfig": {
|
|
287
|
-
"minReplicas":
|
|
288
|
-
"maxReplicas":
|
|
296
|
+
"minReplicas": _min,
|
|
297
|
+
"maxReplicas": _max,
|
|
289
298
|
},
|
|
290
299
|
**_scheduling_config,
|
|
291
300
|
"containerStartupConfig": {
|
|
292
|
-
"entrypoint":
|
|
301
|
+
"entrypoint": cls.construct_exec_command(
|
|
293
302
|
app_config.get_state("commands")
|
|
294
303
|
)
|
|
295
304
|
},
|
|
296
|
-
"environmentVariables":
|
|
305
|
+
"environmentVariables": cls._marshal_environment_variables(app_config),
|
|
297
306
|
# "assets": [{"name": "startup-script.sh"}],
|
|
298
307
|
"authConfig": {
|
|
299
308
|
"authType": app_config.get_state("auth").get("type"),
|
|
@@ -699,6 +708,13 @@ class CapsuleDeployer:
|
|
|
699
708
|
logs = self.capsule_api.logs(self.identifier, worker_id, previous=True)
|
|
700
709
|
return logs, worker_id
|
|
701
710
|
|
|
711
|
+
def _get_min_replicas(self):
|
|
712
|
+
replicas = self._app_config.get_state("replicas", {})
|
|
713
|
+
fixed, _min, _ = replicas.get("fixed"), replicas.get("min"), replicas.get("max")
|
|
714
|
+
if fixed:
|
|
715
|
+
return fixed
|
|
716
|
+
return _min
|
|
717
|
+
|
|
702
718
|
def wait_for_terminal_state(
|
|
703
719
|
self,
|
|
704
720
|
):
|
|
@@ -708,7 +724,7 @@ class CapsuleDeployer:
|
|
|
708
724
|
self.identifier, self.current_deployment_instance_version
|
|
709
725
|
)
|
|
710
726
|
# min_replicas will always be present
|
|
711
|
-
min_replicas = self.
|
|
727
|
+
min_replicas = self._get_min_replicas()
|
|
712
728
|
workers_state_machine = CapsuleWorkersStateMachine(
|
|
713
729
|
self.identifier,
|
|
714
730
|
self.current_deployment_instance_version,
|
|
@@ -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,12 @@
|
|
|
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
|
|
12
|
+
from .typed_configs import TypedCoreConfig
|
|
@@ -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)
|