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.
- 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/code_package/code_packager.py +16 -15
- metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py +11 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py +161 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py +768 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py +285 -0
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py +870 -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/experimental/__init__.py +4 -36
- metaflow_extensions/outerbounds/plugins/apps/core/validations.py +4 -9
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/METADATA +1 -1
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/RECORD +16 -13
- 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.175rc2.dist-info}/WHEEL +0 -0
- {ob_metaflow_extensions-1.1.175rc1.dist-info → ob_metaflow_extensions-1.1.175rc2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Dict, List, Optional, Union, Type, Callable
|
|
4
|
+
from ..click_importer import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FieldBehavior:
|
|
8
|
+
"""
|
|
9
|
+
Defines how configuration fields behave when merging values from multiple sources.
|
|
10
|
+
|
|
11
|
+
FieldBehavior controls the merging logic when the same field receives values from
|
|
12
|
+
different configuration sources (CLI options, config files, environment variables).
|
|
13
|
+
This is crucial for maintaining consistent and predictable configuration behavior
|
|
14
|
+
across different deployment scenarios.
|
|
15
|
+
|
|
16
|
+
The behavior system allows fine-grained control over how different types of fields
|
|
17
|
+
should handle conflicting values, ensuring that sensitive configuration (like
|
|
18
|
+
dependency specifications) cannot be accidentally overridden while still allowing
|
|
19
|
+
flexible configuration for runtime parameters.
|
|
20
|
+
|
|
21
|
+
Behavior Types:
|
|
22
|
+
|
|
23
|
+
UNION (Default):
|
|
24
|
+
- **For Primitive Types**: Override value takes precedence
|
|
25
|
+
- **For Lists**: Values are merged by extending the base list with override values
|
|
26
|
+
- **For Dictionaries**: Values are merged by updating base dict with override values
|
|
27
|
+
- **For Nested Objects**: Recursively merge nested configuration objects
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
```python
|
|
31
|
+
# Base config: {"tags": ["prod", "web"]}
|
|
32
|
+
# CLI config: {"tags": ["urgent"]}
|
|
33
|
+
# Result: {"tags": ["prod", "web", "urgent"]}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
NOT_ALLOWED:
|
|
37
|
+
- CLI values cannot override config file values
|
|
38
|
+
- CLI values are only used if config file value is None
|
|
39
|
+
- Ensures critical configuration is only set in one place to avoid ambiguity.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
```python
|
|
43
|
+
# Base config: {"dependencies": {"numpy": "1.21.0"}}
|
|
44
|
+
# CLI config: {"dependencies": {"numpy": "1.22.0"}}
|
|
45
|
+
# Result: Exception is raised
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# Base config: {"dependencies": {"pypi": null, "conda": null}}
|
|
50
|
+
# CLI config: {"dependencies": {"pypi": {"numpy": "1.22.0"}}}
|
|
51
|
+
# Result: {"dependencies": {"pypi": {"numpy": "1.22.0"}}} # since there is nothing in base config, the CLI config is used.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Integration with Merging:
|
|
55
|
+
The behavior is enforced by the `merge_field_values` function during configuration
|
|
56
|
+
merging. Each field's behavior is checked and the appropriate merging logic is applied.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
UNION = "union" # CLI values are merged with config file values
|
|
60
|
+
# CLI values are not allowed to ovveride the config values
|
|
61
|
+
# unless config values are not specified
|
|
62
|
+
NOT_ALLOWED = "not_allowed"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CLIOption:
|
|
66
|
+
"""Metadata container for automatic CLI option generation from configuration fields.
|
|
67
|
+
|
|
68
|
+
CLIOption defines how a ConfigField should be exposed as a command-line option in the
|
|
69
|
+
generated CLI interface. It provides a declarative way to specify CLI parameter names,
|
|
70
|
+
help text, validation rules, and Click-specific behaviors without tightly coupling
|
|
71
|
+
configuration definitions to CLI implementation details.
|
|
72
|
+
|
|
73
|
+
This class bridges the gap between configuration field definitions and Click option
|
|
74
|
+
generation, allowing the same field definition to work seamlessly across different
|
|
75
|
+
interfaces (CLI, config files, programmatic usage).
|
|
76
|
+
|
|
77
|
+
Click Integration:
|
|
78
|
+
The CLIOption metadata is used by CLIGenerator to create Click options:
|
|
79
|
+
```python
|
|
80
|
+
@click.option("--port", "port", type=int, help="Application port")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This is automatically generated from:
|
|
84
|
+
```python
|
|
85
|
+
port = ConfigField(
|
|
86
|
+
cli_meta=CLIOption(
|
|
87
|
+
name="port",
|
|
88
|
+
cli_option_str="--port",
|
|
89
|
+
help="Application port"
|
|
90
|
+
),
|
|
91
|
+
field_type=int
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
name : str
|
|
99
|
+
Parameter name used in Click option and function signature (e.g., "my_foo").
|
|
100
|
+
cli_option_str : str
|
|
101
|
+
Command-line option string (e.g., "--foo", "--enable/--disable").
|
|
102
|
+
help : Optional[str], optional
|
|
103
|
+
Help text displayed in CLI help output.
|
|
104
|
+
short : Optional[str], optional
|
|
105
|
+
Short option character (e.g., "-f" for "--foo").
|
|
106
|
+
multiple : bool, optional
|
|
107
|
+
Whether the option accepts multiple values.
|
|
108
|
+
is_flag : bool, optional
|
|
109
|
+
Whether this is a boolean flag option.
|
|
110
|
+
choices : Optional[List[str]], optional
|
|
111
|
+
List of valid choices for the option.
|
|
112
|
+
default : Any, optional
|
|
113
|
+
Default value for the CLI option (separate from ConfigField default).
|
|
114
|
+
hidden : bool, optional
|
|
115
|
+
Whether to hide this option from CLI (config file only).
|
|
116
|
+
click_type : Optional[Any], optional
|
|
117
|
+
Custom Click type for specialized parsing (e.g., KeyValuePair).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
name: str, # This name corresponds to the `"my_foo"` in `@click.option("--foo","my_foo")`
|
|
123
|
+
cli_option_str: str, # This corresponds to the `--foo` in the `@click.option("--foo","my_foo")`
|
|
124
|
+
help: Optional[str] = None,
|
|
125
|
+
short: Optional[str] = None,
|
|
126
|
+
multiple: bool = False,
|
|
127
|
+
is_flag: bool = False,
|
|
128
|
+
choices: Optional[List[str]] = None,
|
|
129
|
+
default: Any = None,
|
|
130
|
+
hidden: bool = False,
|
|
131
|
+
click_type: Optional[Any] = None,
|
|
132
|
+
):
|
|
133
|
+
self.name = name
|
|
134
|
+
self.cli_option_str = cli_option_str
|
|
135
|
+
self.help = help
|
|
136
|
+
self.short = short
|
|
137
|
+
self.multiple = multiple
|
|
138
|
+
self.is_flag = is_flag
|
|
139
|
+
self.choices = choices
|
|
140
|
+
self.default = default
|
|
141
|
+
self.hidden = hidden
|
|
142
|
+
self.click_type = click_type
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class ConfigField:
|
|
146
|
+
"""Descriptor for configuration fields with comprehensive metadata and behavior control.
|
|
147
|
+
|
|
148
|
+
ConfigField is a Python descriptor that provides a declarative way to define configuration
|
|
149
|
+
fields with rich metadata, validation, CLI integration, and merging behavior. It acts as
|
|
150
|
+
both a data descriptor (controlling get/set access) and a metadata container.
|
|
151
|
+
|
|
152
|
+
Key Functionality:
|
|
153
|
+
- **Descriptor Protocol**: Implements __get__, __set__, and __set_name__ to control
|
|
154
|
+
field access and automatically capture the field name during class creation.
|
|
155
|
+
- **Type Safety**: Optional strict type checking during value assignment.
|
|
156
|
+
- **CLI Integration**: Automatic CLI option generation via CLIOption metadata.
|
|
157
|
+
- **Validation**: Built-in validation functions and required field checks.
|
|
158
|
+
- **Merging Behavior**: Controls how values are merged from different sources (CLI, config files).
|
|
159
|
+
- **Default Values**: Supports both static defaults and callable defaults for dynamic initialization.
|
|
160
|
+
|
|
161
|
+
Merging Behaviors:
|
|
162
|
+
- **UNION**: Values from different sources are merged (lists extended, dicts updated).
|
|
163
|
+
- **NOT_ALLOWED**: Override values are ignored if base value exists.
|
|
164
|
+
|
|
165
|
+
Field Lifecycle:
|
|
166
|
+
1. **Definition**: Field is defined in a ConfigMeta-based class
|
|
167
|
+
2. **Registration**: __set_name__ is called to register the field name
|
|
168
|
+
3. **Initialization**: Field is initialized with None or nested config objects
|
|
169
|
+
4. **Population**: Values are set from CLI options, config files, or direct assignment
|
|
170
|
+
5. **Validation**: Validation functions are called during commit phase
|
|
171
|
+
6. **Default Application**: Default values are applied to None fields
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
Basic field definition:
|
|
175
|
+
```python
|
|
176
|
+
name = ConfigField(
|
|
177
|
+
field_type=str,
|
|
178
|
+
required=True,
|
|
179
|
+
help="Application name",
|
|
180
|
+
example="myapp"
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Field with CLI integration:
|
|
185
|
+
```python
|
|
186
|
+
port = ConfigField(
|
|
187
|
+
cli_meta=CLIOption(
|
|
188
|
+
name="port",
|
|
189
|
+
cli_option_str="--port",
|
|
190
|
+
help="Application port"
|
|
191
|
+
),
|
|
192
|
+
field_type=int,
|
|
193
|
+
required=True,
|
|
194
|
+
validation_fn=lambda x: 1 <= x <= 65535
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Nested configuration field:
|
|
199
|
+
```python
|
|
200
|
+
resources = ConfigField(
|
|
201
|
+
field_type=ResourceConfig,
|
|
202
|
+
help="Resource configuration"
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
default : Any or Callable[["ConfigField"], Any], optional
|
|
209
|
+
Default value for the field. Can be a static value or a callable for dynamic defaults.
|
|
210
|
+
cli_meta : CLIOption, optional
|
|
211
|
+
CLIOption instance defining CLI option generation parameters.
|
|
212
|
+
field_type : type, optional
|
|
213
|
+
Expected type of the field value (used for validation and nesting).
|
|
214
|
+
required : bool, optional
|
|
215
|
+
Whether the field must have a non-None value after configuration.
|
|
216
|
+
help : str, optional
|
|
217
|
+
Help text describing the field's purpose.
|
|
218
|
+
behavior : str, optional
|
|
219
|
+
FieldBehavior controlling how values are merged from different sources.
|
|
220
|
+
example : Any, optional
|
|
221
|
+
Example value for documentation and schema generation.
|
|
222
|
+
strict_types : bool, optional
|
|
223
|
+
Whether to enforce type checking during value assignment.
|
|
224
|
+
validation_fn : callable, optional
|
|
225
|
+
Optional function to validate field values.
|
|
226
|
+
is_experimental : bool, optional
|
|
227
|
+
Whether this field is experimental (for documentation).
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
default: Union[Any, Callable[["ConfigField"], Any]] = None,
|
|
233
|
+
cli_meta=None,
|
|
234
|
+
field_type=None,
|
|
235
|
+
required=False,
|
|
236
|
+
help=None,
|
|
237
|
+
behavior: str = FieldBehavior.UNION,
|
|
238
|
+
example=None,
|
|
239
|
+
strict_types=True,
|
|
240
|
+
validation_fn: Optional[callable] = None,
|
|
241
|
+
is_experimental=False, # This property is for bookkeeping purposes and for export in schema.
|
|
242
|
+
):
|
|
243
|
+
if behavior == FieldBehavior.NOT_ALLOWED and ConfigMeta.is_instance(field_type):
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"NOT_ALLOWED behavior cannot be set for ConfigMeta-based objects."
|
|
246
|
+
)
|
|
247
|
+
if callable(default) and not ConfigMeta.is_instance(field_type):
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"Default value for {field_type} is a callable but it is not a ConfigMeta-based object."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
self.default = default
|
|
253
|
+
self.cli_meta = cli_meta
|
|
254
|
+
self.field_type = field_type
|
|
255
|
+
self.strict_types = strict_types
|
|
256
|
+
# Strict types means that the __set__ will raise an error if the type of the valu
|
|
257
|
+
# doesn't match the type of the field.
|
|
258
|
+
self.required = required
|
|
259
|
+
self.help = help
|
|
260
|
+
self.behavior = behavior
|
|
261
|
+
self.example = example
|
|
262
|
+
self.name = None
|
|
263
|
+
self.validation_fn = validation_fn
|
|
264
|
+
self.is_experimental = is_experimental
|
|
265
|
+
|
|
266
|
+
def __set_name__(self, owner, name):
|
|
267
|
+
self.name = name
|
|
268
|
+
|
|
269
|
+
def __get__(self, instance, owner):
|
|
270
|
+
if instance is None:
|
|
271
|
+
return self
|
|
272
|
+
# DEFAULTS need to be explicilty set to ensure that
|
|
273
|
+
# only explicitly set values are return on get
|
|
274
|
+
return instance.__dict__.get(self.name)
|
|
275
|
+
|
|
276
|
+
def __set__(self, instance, value):
|
|
277
|
+
# TODO: handle this execption at top level if necessary.
|
|
278
|
+
if value is not None and self.strict_types:
|
|
279
|
+
if not isinstance(value, self.field_type):
|
|
280
|
+
raise ValueError(f"Value {value} is not of type {self.field_type}")
|
|
281
|
+
instance.__dict__[self.name] = value
|
|
282
|
+
|
|
283
|
+
def __str__(self) -> str:
|
|
284
|
+
type_name = (
|
|
285
|
+
getattr(self.field_type, "__name__", "typing.Any")
|
|
286
|
+
if self.field_type
|
|
287
|
+
else "typing.Any"
|
|
288
|
+
)
|
|
289
|
+
return f"<ConfigField name='{self.name}' type={type_name} default={self.default!r}>"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ConfigMeta(type):
|
|
293
|
+
"""Metaclass that transforms regular classes into configuration classes with automatic field management.
|
|
294
|
+
|
|
295
|
+
ConfigMeta is the core metaclass that enables the declarative configuration system. It automatically
|
|
296
|
+
processes ConfigField descriptors defined in class bodies and transforms them into fully functional
|
|
297
|
+
configuration classes with standardized initialization, field access, and metadata management.
|
|
298
|
+
|
|
299
|
+
Key Transformations:
|
|
300
|
+
- **Field Collection**: Automatically discovers and collects all ConfigField instances from the class body.
|
|
301
|
+
- **Metadata Storage**: Stores field metadata in a `_fields` class attribute for runtime introspection.
|
|
302
|
+
- **Auto-Generated __init__**: Creates a standardized __init__ method that handles field initialization.
|
|
303
|
+
- **Field Access**: Injects helper methods like `_get_field` for programmatic field access.
|
|
304
|
+
- **Nested Object Support**: Automatically instantiates nested configuration objects during initialization.
|
|
305
|
+
|
|
306
|
+
Class Transformation Process:
|
|
307
|
+
1. **Discovery**: Scan the class namespace for ConfigField instances
|
|
308
|
+
2. **Registration**: Store found fields in `_fields` dictionary
|
|
309
|
+
3. **Method Injection**: Add `_get_field` helper method to the class
|
|
310
|
+
4. **__init__ Generation**: Create standardized initialization logic
|
|
311
|
+
5. **Class Creation**: Return the transformed class with all enhancements
|
|
312
|
+
|
|
313
|
+
Generated __init__ Behavior:
|
|
314
|
+
- Initializes all fields to None by default (explicit defaulting is done separately)
|
|
315
|
+
- Automatically creates instances of nested ConfigMeta-based classes
|
|
316
|
+
- Accepts keyword arguments to override field values during instantiation
|
|
317
|
+
- Ensures consistent initialization patterns across all configuration classes
|
|
318
|
+
|
|
319
|
+
Usage Pattern:
|
|
320
|
+
```python
|
|
321
|
+
class MyConfig(metaclass=ConfigMeta):
|
|
322
|
+
name = ConfigField(field_type=str, required=True)
|
|
323
|
+
port = ConfigField(field_type=int, default=8080)
|
|
324
|
+
resources = ConfigField(field_type=ResourceConfig)
|
|
325
|
+
|
|
326
|
+
# The metaclass transforms this into a fully functional config class
|
|
327
|
+
config = MyConfig() # Uses auto-generated __init__
|
|
328
|
+
config.name = "myapp" # Uses ConfigField descriptor
|
|
329
|
+
field_info = config._get_field("name") # Uses injected helper method
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Integration Points:
|
|
333
|
+
- **CLI Generation**: Field metadata is used to automatically generate CLI options
|
|
334
|
+
- **Config Loading**: Fields are populated from dictionaries, YAML, or JSON files
|
|
335
|
+
- **Validation**: Field validation functions are called during config commit
|
|
336
|
+
- **Merging**: Field behaviors control how values are merged from different sources
|
|
337
|
+
- **Export**: Configuration instances can be exported back to dictionaries
|
|
338
|
+
|
|
339
|
+
The metaclass ensures that all configuration classes have consistent behavior and
|
|
340
|
+
interfaces, regardless of their specific field definitions.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def is_instance(value) -> bool:
|
|
345
|
+
return hasattr(value, "_fields")
|
|
346
|
+
|
|
347
|
+
def __new__(mcs, name, bases, namespace):
|
|
348
|
+
# Collect field metadata
|
|
349
|
+
fields = {}
|
|
350
|
+
for key, value in namespace.items():
|
|
351
|
+
if isinstance(value, ConfigField):
|
|
352
|
+
fields[key] = value
|
|
353
|
+
|
|
354
|
+
# Store fields metadata on the class
|
|
355
|
+
namespace["_fields"] = fields
|
|
356
|
+
|
|
357
|
+
# Inject a function to get configField from a field name
|
|
358
|
+
def get_field(cls, field_name: str) -> ConfigField:
|
|
359
|
+
return fields[field_name]
|
|
360
|
+
|
|
361
|
+
namespace["_get_field"] = get_field
|
|
362
|
+
|
|
363
|
+
# Auto-generate __init__ method;
|
|
364
|
+
# Override it for all classes.
|
|
365
|
+
def __init__(self, **kwargs):
|
|
366
|
+
# Initialize all fields with Nones or other values
|
|
367
|
+
# We initiaze with None because defaulting should be a
|
|
368
|
+
# constant behavior
|
|
369
|
+
for field_name, field_info in fields.items():
|
|
370
|
+
default_value = None
|
|
371
|
+
|
|
372
|
+
# Handle nested config objects
|
|
373
|
+
if ConfigMeta.is_instance(field_info.field_type):
|
|
374
|
+
# Create nested config instance with defaults
|
|
375
|
+
default_value = field_info.field_type()
|
|
376
|
+
|
|
377
|
+
# Set from kwargs or use default
|
|
378
|
+
if field_name in kwargs:
|
|
379
|
+
setattr(self, field_name, kwargs[field_name])
|
|
380
|
+
else:
|
|
381
|
+
setattr(self, field_name, default_value)
|
|
382
|
+
|
|
383
|
+
return super().__new__(mcs, name, bases, namespace)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def apply_defaults(config) -> None:
|
|
387
|
+
"""
|
|
388
|
+
Apply default values to any fields that are still None.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
config: instance of a ConfigMeta object
|
|
392
|
+
"""
|
|
393
|
+
for field_name, field_info in config._fields.items():
|
|
394
|
+
current_value = getattr(config, field_name, None)
|
|
395
|
+
|
|
396
|
+
if current_value is None:
|
|
397
|
+
# The nested configs will never be set to None
|
|
398
|
+
# Since we always override the init function.
|
|
399
|
+
# The init function will always instantiate the
|
|
400
|
+
# sub-objects under the class
|
|
401
|
+
# Set default value for regular fields
|
|
402
|
+
setattr(config, field_name, field_info.default)
|
|
403
|
+
elif ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
|
|
404
|
+
current_value
|
|
405
|
+
):
|
|
406
|
+
|
|
407
|
+
# Apply defaults to nested config (to any sub values that might need it)
|
|
408
|
+
apply_defaults(current_value)
|
|
409
|
+
# also apply defaults to the current value if a defaults callable is provided.
|
|
410
|
+
# Certain top level config fields might require default setting based on the
|
|
411
|
+
# what the current value in the fields is set.
|
|
412
|
+
if field_info.default:
|
|
413
|
+
field_info.default(current_value)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class ConfigValidationFailedException(Exception):
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
field_name: str,
|
|
420
|
+
field_info: ConfigField,
|
|
421
|
+
current_value,
|
|
422
|
+
message: str = None,
|
|
423
|
+
):
|
|
424
|
+
self.field_name = field_name
|
|
425
|
+
self.field_info = field_info
|
|
426
|
+
self.current_value = current_value
|
|
427
|
+
self.message = (
|
|
428
|
+
f"Validation failed for field {field_name} with value {current_value}"
|
|
429
|
+
)
|
|
430
|
+
if message is not None:
|
|
431
|
+
self.message = message
|
|
432
|
+
|
|
433
|
+
super().__init__(self.message)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class RequiredFieldMissingException(ConfigValidationFailedException):
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class MergingNotAllowedFieldsException(ConfigValidationFailedException):
|
|
441
|
+
def __init__(
|
|
442
|
+
self,
|
|
443
|
+
field_name: str,
|
|
444
|
+
field_info: ConfigField,
|
|
445
|
+
current_value: Any,
|
|
446
|
+
override_value: Any,
|
|
447
|
+
):
|
|
448
|
+
super().__init__(
|
|
449
|
+
field_name=field_name,
|
|
450
|
+
field_info=field_info,
|
|
451
|
+
current_value=current_value,
|
|
452
|
+
message=f"Merging not allowed for field {field_name} with value {current_value} and override value {override_value}",
|
|
453
|
+
)
|
|
454
|
+
self.override_value = override_value
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def validate_required_fields(config_instance):
|
|
458
|
+
for field_name, field_info in config_instance._fields.items():
|
|
459
|
+
if field_info.required:
|
|
460
|
+
current_value = getattr(config_instance, field_name, None)
|
|
461
|
+
if current_value is None:
|
|
462
|
+
raise RequiredFieldMissingException(
|
|
463
|
+
field_name, field_info, current_value
|
|
464
|
+
)
|
|
465
|
+
if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
|
|
466
|
+
current_value
|
|
467
|
+
):
|
|
468
|
+
validate_required_fields(current_value)
|
|
469
|
+
# TODO : Fix the exception handling over here.
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def validate_config_meta(config_instance):
|
|
473
|
+
for field_name, field_info in config_instance._fields.items():
|
|
474
|
+
current_value = getattr(config_instance, field_name, None)
|
|
475
|
+
|
|
476
|
+
if ConfigMeta.is_instance(field_info.field_type) and ConfigMeta.is_instance(
|
|
477
|
+
current_value
|
|
478
|
+
):
|
|
479
|
+
validate_config_meta(current_value)
|
|
480
|
+
|
|
481
|
+
if field_info.validation_fn:
|
|
482
|
+
if not field_info.validation_fn(current_value):
|
|
483
|
+
raise ConfigValidationFailedException(
|
|
484
|
+
field_name, field_info, current_value
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def config_meta_to_dict(config_instance) -> Dict[str, Any]:
|
|
489
|
+
"""Convert a configuration instance to a nested dictionary.
|
|
490
|
+
|
|
491
|
+
Recursively converts ConfigMeta-based configuration instances to dictionaries,
|
|
492
|
+
handling nested config objects and preserving the structure.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
config_instance: Instance of a ConfigMeta-based configuration class
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Nested dictionary representation of the configuration
|
|
499
|
+
|
|
500
|
+
Examples:
|
|
501
|
+
# Convert a config instance to dict
|
|
502
|
+
|
|
503
|
+
config_dict = to_dict(config)
|
|
504
|
+
|
|
505
|
+
# Result will be:
|
|
506
|
+
# {
|
|
507
|
+
# "name": "myapp",
|
|
508
|
+
# "port": 8000,
|
|
509
|
+
# "resources": {
|
|
510
|
+
# "cpu": "500m",
|
|
511
|
+
# "memory": "1Gi",
|
|
512
|
+
# "gpu": None,
|
|
513
|
+
# "disk": "20Gi"
|
|
514
|
+
# },
|
|
515
|
+
# "auth": None,
|
|
516
|
+
# ...
|
|
517
|
+
# }
|
|
518
|
+
"""
|
|
519
|
+
if config_instance is None:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
# Check if this is a ConfigMeta-based class
|
|
523
|
+
if not ConfigMeta.is_instance(config_instance):
|
|
524
|
+
# If it's not a config object, return as-is
|
|
525
|
+
return config_instance
|
|
526
|
+
|
|
527
|
+
result = {}
|
|
528
|
+
|
|
529
|
+
# Iterate through all fields defined in the class
|
|
530
|
+
for field_name, field_info in config_instance._fields.items():
|
|
531
|
+
# Get the current value
|
|
532
|
+
value = getattr(config_instance, field_name, None)
|
|
533
|
+
|
|
534
|
+
# Handle nested config objects recursively
|
|
535
|
+
if value is not None and ConfigMeta.is_instance(value):
|
|
536
|
+
# It's a nested config object
|
|
537
|
+
result[field_name] = config_meta_to_dict(value)
|
|
538
|
+
elif isinstance(value, list) and value:
|
|
539
|
+
# Handle lists that might contain config objects
|
|
540
|
+
result[field_name] = [
|
|
541
|
+
config_meta_to_dict(item) if ConfigMeta.is_instance(item) else item
|
|
542
|
+
for item in value
|
|
543
|
+
]
|
|
544
|
+
elif isinstance(value, dict) and value:
|
|
545
|
+
# Handle dictionaries that might contain config objects
|
|
546
|
+
result[field_name] = {
|
|
547
|
+
k: config_meta_to_dict(v) if ConfigMeta.is_instance(v) else v
|
|
548
|
+
for k, v in value.items()
|
|
549
|
+
}
|
|
550
|
+
else:
|
|
551
|
+
# Primitive type or None
|
|
552
|
+
result[field_name] = value
|
|
553
|
+
|
|
554
|
+
return result
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def merge_field_values(
|
|
558
|
+
base_value: Any, override_value: Any, field_info, behavior: str
|
|
559
|
+
) -> Any:
|
|
560
|
+
"""
|
|
561
|
+
Merge individual field values based on behavior.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
base_value: Value from base config
|
|
565
|
+
override_value: Value from override config
|
|
566
|
+
field_info: Field metadata
|
|
567
|
+
behavior: FieldBehavior for this field
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
Merged value
|
|
571
|
+
"""
|
|
572
|
+
# Handle NOT_ALLOWED behavior
|
|
573
|
+
if behavior == FieldBehavior.NOT_ALLOWED:
|
|
574
|
+
if base_value is not None and override_value is not None:
|
|
575
|
+
raise MergingNotAllowedFieldsException(
|
|
576
|
+
field_name=field_info.name,
|
|
577
|
+
field_info=field_info,
|
|
578
|
+
current_value=base_value,
|
|
579
|
+
override_value=override_value,
|
|
580
|
+
)
|
|
581
|
+
if base_value is None:
|
|
582
|
+
return override_value
|
|
583
|
+
return base_value
|
|
584
|
+
|
|
585
|
+
# Handle UNION behavior (default)
|
|
586
|
+
if behavior == FieldBehavior.UNION:
|
|
587
|
+
# If override is None, use base value
|
|
588
|
+
if override_value is None:
|
|
589
|
+
return (
|
|
590
|
+
base_value if base_value is not None else None
|
|
591
|
+
) # We will not set defaults!
|
|
592
|
+
|
|
593
|
+
# If base is None, use override value
|
|
594
|
+
if base_value is None:
|
|
595
|
+
return override_value
|
|
596
|
+
|
|
597
|
+
# Handle nested config objects
|
|
598
|
+
if ConfigMeta.is_instance(field_info.field_type):
|
|
599
|
+
if isinstance(base_value, field_info.field_type) and isinstance(
|
|
600
|
+
override_value, field_info.field_type
|
|
601
|
+
):
|
|
602
|
+
# Merge nested configs recursively
|
|
603
|
+
merged_nested = field_info.field_type()
|
|
604
|
+
for (
|
|
605
|
+
nested_field_name,
|
|
606
|
+
nested_field_info,
|
|
607
|
+
) in field_info.field_type._fields.items():
|
|
608
|
+
nested_base = getattr(base_value, nested_field_name, None)
|
|
609
|
+
nested_override = getattr(override_value, nested_field_name, None)
|
|
610
|
+
nested_behavior = getattr(
|
|
611
|
+
nested_field_info, "behavior", FieldBehavior.UNION
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
merged_nested_value = merge_field_values(
|
|
615
|
+
nested_base, nested_override, nested_field_info, nested_behavior
|
|
616
|
+
)
|
|
617
|
+
setattr(merged_nested, nested_field_name, merged_nested_value)
|
|
618
|
+
|
|
619
|
+
return merged_nested
|
|
620
|
+
else:
|
|
621
|
+
# One is not a config object, use override
|
|
622
|
+
return override_value
|
|
623
|
+
|
|
624
|
+
# Handle lists (union behavior merges lists)
|
|
625
|
+
if isinstance(base_value, list) and isinstance(override_value, list):
|
|
626
|
+
# Merge lists by extending
|
|
627
|
+
merged_list = base_value.copy()
|
|
628
|
+
merged_list.extend(override_value)
|
|
629
|
+
return merged_list
|
|
630
|
+
|
|
631
|
+
# Handle dicts (union behavior merges dicts)
|
|
632
|
+
if isinstance(base_value, dict) and isinstance(override_value, dict):
|
|
633
|
+
merged_dict = base_value.copy()
|
|
634
|
+
merged_dict.update(override_value)
|
|
635
|
+
return merged_dict
|
|
636
|
+
|
|
637
|
+
# For other types, override takes precedence
|
|
638
|
+
return override_value
|
|
639
|
+
|
|
640
|
+
# Default: override takes precedence
|
|
641
|
+
return (
|
|
642
|
+
override_value
|
|
643
|
+
if override_value is not None
|
|
644
|
+
else (base_value if base_value is not None else None)
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class JsonFriendlyKeyValuePair(click.ParamType):
|
|
649
|
+
name = "KV-PAIR" # type: ignore
|
|
650
|
+
|
|
651
|
+
def convert(self, value, param, ctx):
|
|
652
|
+
# Parse a string of the form KEY=VALUE into a dict {KEY: VALUE}
|
|
653
|
+
if len(value.split("=", 1)) != 2:
|
|
654
|
+
self.fail(
|
|
655
|
+
f"Invalid format for {value}. Expected format: KEY=VALUE", param, ctx
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
key, _value = value.split("=", 1)
|
|
659
|
+
try:
|
|
660
|
+
return {key: json.loads(_value)}
|
|
661
|
+
except json.JSONDecodeError:
|
|
662
|
+
return {key: _value}
|
|
663
|
+
except Exception as e:
|
|
664
|
+
self.fail(f"Invalid value for {value}. Error: {e}", param, ctx)
|
|
665
|
+
|
|
666
|
+
def __str__(self):
|
|
667
|
+
return repr(self)
|
|
668
|
+
|
|
669
|
+
def __repr__(self):
|
|
670
|
+
return "KV-PAIR"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
class CommaSeparatedList(click.ParamType):
|
|
674
|
+
name = "COMMA-SEPARATED-LIST" # type: ignore
|
|
675
|
+
|
|
676
|
+
def convert(self, value, param, ctx):
|
|
677
|
+
if isinstance(value, list):
|
|
678
|
+
return value
|
|
679
|
+
if isinstance(value, str):
|
|
680
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
681
|
+
return value
|
|
682
|
+
|
|
683
|
+
def __str__(self):
|
|
684
|
+
return repr(self)
|
|
685
|
+
|
|
686
|
+
def __repr__(self):
|
|
687
|
+
return "COMMA-SEPARATED-LIST"
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
class PureStringKVPair(click.ParamType):
|
|
691
|
+
"""Click type for key-value pairs (KEY=VALUE)."""
|
|
692
|
+
|
|
693
|
+
name = "key=value"
|
|
694
|
+
|
|
695
|
+
def convert(self, value, param, ctx):
|
|
696
|
+
if isinstance(value, dict):
|
|
697
|
+
return value
|
|
698
|
+
try:
|
|
699
|
+
key, val = value.split("=", 1)
|
|
700
|
+
return {key: val}
|
|
701
|
+
except ValueError:
|
|
702
|
+
self.fail(f"'{value}' is not a valid key=value pair", param, ctx)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
PureStringKVPairType = PureStringKVPair()
|
|
706
|
+
CommaSeparatedListType = CommaSeparatedList()
|
|
707
|
+
JsonFriendlyKeyValuePairType = JsonFriendlyKeyValuePair()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def populate_config_recursive(
|
|
711
|
+
config_instance,
|
|
712
|
+
config_class,
|
|
713
|
+
source_data,
|
|
714
|
+
get_source_key_fn,
|
|
715
|
+
get_source_value_fn,
|
|
716
|
+
):
|
|
717
|
+
"""
|
|
718
|
+
Recursively populate a config instance from source data.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
config_instance: Config object to populate
|
|
722
|
+
config_class: Class of the config object
|
|
723
|
+
source_data: Source data (dict, CLI options, etc.)
|
|
724
|
+
get_source_key_fn: Function to get the source key for a field
|
|
725
|
+
get_source_value_fn: Function to get the value from source for a key
|
|
726
|
+
"""
|
|
727
|
+
|
|
728
|
+
for field_name, field_info in config_class._fields.items():
|
|
729
|
+
# When we populate the ConfigMeta based objects, we want to do the following:
|
|
730
|
+
# If we find some key associated to the object inside the source data, then we populate the object
|
|
731
|
+
# with it. If that key corresponds to a nested config meta object then we just recusively pass down the
|
|
732
|
+
# value of the key from source data and populate the nested object other wise just end up setting the object
|
|
733
|
+
source_key = get_source_key_fn(field_name, field_info)
|
|
734
|
+
if source_key and source_key in source_data:
|
|
735
|
+
value = get_source_value_fn(source_data, source_key)
|
|
736
|
+
if value is not None:
|
|
737
|
+
# Handle nested config objects (for dict sources with nested data)
|
|
738
|
+
if ConfigMeta.is_instance(field_info.field_type) and isinstance(
|
|
739
|
+
value, dict
|
|
740
|
+
):
|
|
741
|
+
nested_config = field_info.field_type()
|
|
742
|
+
populate_config_recursive(
|
|
743
|
+
nested_config,
|
|
744
|
+
field_info.field_type,
|
|
745
|
+
value, # For dict, use the nested dict as source
|
|
746
|
+
get_source_key_fn,
|
|
747
|
+
get_source_value_fn,
|
|
748
|
+
)
|
|
749
|
+
setattr(config_instance, field_name, nested_config)
|
|
750
|
+
else:
|
|
751
|
+
# Direct value assignment for regular fields
|
|
752
|
+
setattr(config_instance, field_name, value)
|
|
753
|
+
elif ConfigMeta.is_instance(field_info.field_type):
|
|
754
|
+
# It might be possible that the source key that respresents a subfield is
|
|
755
|
+
# is present at the root level but there is no field pertainig to the ConfigMeta
|
|
756
|
+
# itself. So always recurisvely check the subtree and no matter what keep instantiating
|
|
757
|
+
# any nested config objects.
|
|
758
|
+
_nested_config = field_info.field_type()
|
|
759
|
+
populate_config_recursive(
|
|
760
|
+
_nested_config,
|
|
761
|
+
field_info.field_type,
|
|
762
|
+
source_data,
|
|
763
|
+
get_source_key_fn,
|
|
764
|
+
get_source_value_fn,
|
|
765
|
+
)
|
|
766
|
+
setattr(config_instance, field_name, _nested_config)
|
|
767
|
+
else:
|
|
768
|
+
pass
|